overlaysclient

hover card

a preview that floats up when you rest on a link or a name, and stays while you move into it.

the first published algorithm for a machine was written by ada lovelace in 1843.

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add hover-card

or by hand: drop these files into components/ui and import from there. hover card leans on Floating.tsx, so those come along too.

components/ui/HoverCard.tsxtsx
"use client";
import React, { useEffect, useId, useRef, useState } from "react";
import { ENTER_FROM, LAYER, ORIGIN, Portal, Side, useAnchoredPosition } from "./Floating";
interface HoverCardProps {
/** What you hover: a link, a name, an avatar */
trigger: React.ReactNode;
children: React.ReactNode;
side?: Side;
align?: "start" | "center" | "end";
/** Milliseconds to wait before opening, and before closing */
openDelay?: number;
closeDelay?: number;
className?: string;
}
/* A preview that floats up when you rest the pointer on something, like
a profile behind a username. It waits a beat before opening, stays
open while the pointer crosses into it, and opens on keyboard focus
too. It's a preview: anything in it should also be reachable by
following the trigger. */
export function HoverCard({
trigger,
children,
side = "bottom",
align = "center",
openDelay = 450,
closeDelay = 200,
className = "",
}: HoverCardProps) {
const uid = useId();
const ref = useRef<HTMLSpanElement>(null);
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const { setFloating, side: placed } = useAnchoredPosition(ref, { open: mounted, side, align, gap: 10 });
if (open && !mounted) setMounted(true);
if (!open && visible) setVisible(false);
useEffect(() => {
if (open) {
const id = requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
return () => cancelAnimationFrame(id);
}
const t = setTimeout(() => setMounted(false), 180);
return () => clearTimeout(t);
}, [open]);
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
const later = (next: boolean, ms: number) => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setOpen(next), ms);
};
// Touch has no hover; a tap goes through to the trigger instead
const enter = (e: React.PointerEvent) => e.pointerType !== "touch" && later(true, openDelay);
const leave = (e: React.PointerEvent) => e.pointerType !== "touch" && later(false, closeDelay);
return (
<span
ref={ref}
className={["inline-flex", className].join(" ")}
onPointerEnter={enter}
onPointerLeave={leave}
onFocus={() => later(true, 0)}
onBlur={() => later(false, closeDelay)}
>
{React.isValidElement(trigger)
? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
"aria-describedby": mounted ? `${uid}-card` : undefined,
})
: trigger}
{mounted && (
<Portal>
<div
ref={setFloating}
id={`${uid}-card`}
onPointerEnter={enter}
onPointerLeave={leave}
className="w-[300px] max-w-[calc(100vw-16px)] rounded-[18px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"
style={{
position: "fixed",
top: 0,
left: 0,
zIndex: LAYER.floating,
transformOrigin: ORIGIN[placed],
opacity: visible ? 1 : 0,
transform: visible ? "none" : `${ENTER_FROM[placed]} scale(0.96)`,
background: "var(--panel-bezel)",
boxShadow: "var(--overlay-shadow)",
}}
>
<div className="rounded-[13px] bg-panel-face p-4 text-[13px] text-ink-body" style={{ boxShadow: "var(--panel-face-shadow)" }}>
{children}
</div>
</div>
</Portal>
)}
</span>
);
}
components/ui/Floating.tsxtsx
"use client";
import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
/* ── Portal ───────────────────────────────────────────────────────── */
/* Anything that floats over the page is rendered at the end of <body>.
Left where it was declared, a panel lives inside whatever stacking
context its parents made (a transform, a filter, an isolate), and a
later sibling on the page can paint straight over it, whatever its
z-index says. */
const noop = () => () => {};
export function Portal({ children }: { children: React.ReactNode }) {
const client = useSyncExternalStore(noop, () => true, () => false);
return client ? createPortal(children, document.body) : null;
}
/* Layers, lowest first: dialogs, then toasts, then anything anchored to
a control (so a select inside a modal opens over the modal), then
tooltips over all of it. */
export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;
/* ── Anchored position ────────────────────────────────────────────── */
export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
const PAD = 8;
interface AnchorOptions {
open: boolean;
side?: Side;
align?: Align;
/** Space between the anchor and the panel */
gap?: number;
/** Make the panel at least as wide as the anchor (selects, comboboxes) */
matchWidth?: boolean;
}
/* Places a fixed-position panel next to its anchor: on the side asked
for unless the other side has more room, then nudged back inside the
viewport. Follows the anchor while the page scrolls or resizes. The
position is written straight onto the element, so scrolling never
re-renders the component. */
export function useAnchoredPosition(
anchor: RefObject<HTMLElement | null>,
{ open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,
) {
const [placed, setPlaced] = useState<Side>(side);
const floating = useRef<HTMLElement | null>(null);
const update = useCallback(() => {
const a = anchor.current?.getBoundingClientRect();
const el = floating.current;
if (!a || !el) return;
if (matchWidth) el.style.minWidth = `${a.width}px`;
// offset sizes ignore the enter transform, so these are the settled size
const w = el.offsetWidth;
const h = el.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };
const vertical = side === "top" || side === "bottom";
const need = (vertical ? h : w) + gap + PAD;
let s = side;
if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];
let top: number;
let left: number;
if (s === "top" || s === "bottom") {
top = s === "bottom" ? a.bottom + gap : a.top - gap - h;
left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;
} else {
left = s === "right" ? a.right + gap : a.left - gap - w;
top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;
}
left = Math.max(PAD, Math.min(left, vw - w - PAD));
top = Math.max(PAD, Math.min(top, vh - h - PAD));
el.style.top = `${Math.round(top)}px`;
el.style.left = `${Math.round(left)}px`;
el.dataset.side = s;
setPlaced(s);
}, [anchor, side, align, gap, matchWidth]);
/* Attach this as the panel's ref: it's placed before its first paint */
const setFloating = useCallback(
(el: HTMLElement | null) => {
floating.current = el;
if (el) update();
},
[update],
);
useEffect(() => {
if (!open) return;
let frame = 0;
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(update);
};
window.addEventListener("scroll", schedule, true);
window.addEventListener("resize", schedule);
// The panel can change size while open (a filtered list, a new month)
const ro = new ResizeObserver(schedule);
if (floating.current) ro.observe(floating.current);
if (anchor.current) ro.observe(anchor.current);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", schedule, true);
window.removeEventListener("resize", schedule);
ro.disconnect();
};
}, [open, update, anchor]);
return { setFloating, side: placed, floatingRef: floating, update };
}
/* Where the panel grows from, and where it slides in from, for each side */
export const ORIGIN: Record<Side, string> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
export const ENTER_FROM: Record<Side, string> = {
top: "translateY(4px)",
bottom: "translateY(-4px)",
left: "translateX(4px)",
right: "translateX(-4px)",
};

usage

tsx
import { HoverCard } from "@blurrow/ui";
tsx
<HoverCard trigger={<a href="/ada">ada</a>}>…</HoverCard>

props

straight from the source, so they're always up to date.

propsts
interface HoverCardProps {
/** What you hover: a link, a name, an avatar */
trigger: React.ReactNode;
children: React.ReactNode;
side?: Side;
align?: "start" | "center" | "end";
/** Milliseconds to wait before opening, and before closing */
openDelay?: number;
closeDelay?: number;
className?: string;
}