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-cardor 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
1"use client";2
3import React, { useEffect, useId, useRef, useState } from "react";4import { ENTER_FROM, LAYER, ORIGIN, Portal, Side, useAnchoredPosition } from "./Floating";5
6interface HoverCardProps {7 /** What you hover: a link, a name, an avatar */8 trigger: React.ReactNode;9 children: React.ReactNode;10 side?: Side;11 align?: "start" | "center" | "end";12 /** Milliseconds to wait before opening, and before closing */13 openDelay?: number;14 closeDelay?: number;15 className?: string;16}17
18/* A preview that floats up when you rest the pointer on something, like19 a profile behind a username. It waits a beat before opening, stays20 open while the pointer crosses into it, and opens on keyboard focus21 too. It's a preview: anything in it should also be reachable by22 following the trigger. */23export function HoverCard({24 trigger,25 children,26 side = "bottom",27 align = "center",28 openDelay = 450,29 closeDelay = 200,30 className = "",31}: HoverCardProps) {32 const uid = useId();33 const ref = useRef<HTMLSpanElement>(null);34 const [open, setOpen] = useState(false);35 const [mounted, setMounted] = useState(false);36 const [visible, setVisible] = useState(false);37 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);38 const { setFloating, side: placed } = useAnchoredPosition(ref, { open: mounted, side, align, gap: 10 });39
40 if (open && !mounted) setMounted(true);41 if (!open && visible) setVisible(false);42
43 useEffect(() => {44 if (open) {45 const id = requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));46 return () => cancelAnimationFrame(id);47 }48 const t = setTimeout(() => setMounted(false), 180);49 return () => clearTimeout(t);50 }, [open]);51
52 useEffect(() => () => {53 if (timer.current) clearTimeout(timer.current);54 }, []);55
56 useEffect(() => {57 if (!open) return;58 const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);59 document.addEventListener("keydown", onKey);60 return () => document.removeEventListener("keydown", onKey);61 }, [open]);62
63 const later = (next: boolean, ms: number) => {64 if (timer.current) clearTimeout(timer.current);65 timer.current = setTimeout(() => setOpen(next), ms);66 };67 // Touch has no hover; a tap goes through to the trigger instead68 const enter = (e: React.PointerEvent) => e.pointerType !== "touch" && later(true, openDelay);69 const leave = (e: React.PointerEvent) => e.pointerType !== "touch" && later(false, closeDelay);70
71 return (72 <span73 ref={ref}74 className={["inline-flex", className].join(" ")}75 onPointerEnter={enter}76 onPointerLeave={leave}77 onFocus={() => later(true, 0)}78 onBlur={() => later(false, closeDelay)}79 >80 {React.isValidElement(trigger)81 ? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {82 "aria-describedby": mounted ? `${uid}-card` : undefined,83 })84 : trigger}85 {mounted && (86 <Portal>87 <div88 ref={setFloating}89 id={`${uid}-card`}90 onPointerEnter={enter}91 onPointerLeave={leave}92 className="w-[300px] max-w-[calc(100vw-16px)] rounded-[18px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"93 style={{94 position: "fixed",95 top: 0,96 left: 0,97 zIndex: LAYER.floating,98 transformOrigin: ORIGIN[placed],99 opacity: visible ? 1 : 0,100 transform: visible ? "none" : `${ENTER_FROM[placed]} scale(0.96)`,101 background: "var(--panel-bezel)",102 boxShadow: "var(--overlay-shadow)",103 }}104 >105 <div className="rounded-[13px] bg-panel-face p-4 text-[13px] text-ink-body" style={{ boxShadow: "var(--panel-face-shadow)" }}>106 {children}107 </div>108 </div>109 </Portal>110 )}111 </span>112 );113}components/ui/Floating.tsxtsx
1"use client";2
3import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";4import { createPortal } from "react-dom";5
6/* ── Portal ───────────────────────────────────────────────────────── */7/* Anything that floats over the page is rendered at the end of <body>.8 Left where it was declared, a panel lives inside whatever stacking9 context its parents made (a transform, a filter, an isolate), and a10 later sibling on the page can paint straight over it, whatever its11 z-index says. */12const noop = () => () => {};13
14export function Portal({ children }: { children: React.ReactNode }) {15 const client = useSyncExternalStore(noop, () => true, () => false);16 return client ? createPortal(children, document.body) : null;17}18
19/* Layers, lowest first: dialogs, then toasts, then anything anchored to20 a control (so a select inside a modal opens over the modal), then21 tooltips over all of it. */22export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;23
24/* ── Anchored position ────────────────────────────────────────────── */25export type Side = "top" | "bottom" | "left" | "right";26export type Align = "start" | "center" | "end";27
28const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };29const PAD = 8;30
31interface AnchorOptions {32 open: boolean;33 side?: Side;34 align?: Align;35 /** Space between the anchor and the panel */36 gap?: number;37 /** Make the panel at least as wide as the anchor (selects, comboboxes) */38 matchWidth?: boolean;39}40
41/* Places a fixed-position panel next to its anchor: on the side asked42 for unless the other side has more room, then nudged back inside the43 viewport. Follows the anchor while the page scrolls or resizes. The44 position is written straight onto the element, so scrolling never45 re-renders the component. */46export function useAnchoredPosition(47 anchor: RefObject<HTMLElement | null>,48 { open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,49) {50 const [placed, setPlaced] = useState<Side>(side);51 const floating = useRef<HTMLElement | null>(null);52
53 const update = useCallback(() => {54 const a = anchor.current?.getBoundingClientRect();55 const el = floating.current;56 if (!a || !el) return;57 if (matchWidth) el.style.minWidth = `${a.width}px`;58
59 // offset sizes ignore the enter transform, so these are the settled size60 const w = el.offsetWidth;61 const h = el.offsetHeight;62 const vw = document.documentElement.clientWidth;63 const vh = window.innerHeight;64 const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };65 const vertical = side === "top" || side === "bottom";66 const need = (vertical ? h : w) + gap + PAD;67
68 let s = side;69 if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];70
71 let top: number;72 let left: number;73 if (s === "top" || s === "bottom") {74 top = s === "bottom" ? a.bottom + gap : a.top - gap - h;75 left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;76 } else {77 left = s === "right" ? a.right + gap : a.left - gap - w;78 top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;79 }80 left = Math.max(PAD, Math.min(left, vw - w - PAD));81 top = Math.max(PAD, Math.min(top, vh - h - PAD));82
83 el.style.top = `${Math.round(top)}px`;84 el.style.left = `${Math.round(left)}px`;85 el.dataset.side = s;86 setPlaced(s);87 }, [anchor, side, align, gap, matchWidth]);88
89 /* Attach this as the panel's ref: it's placed before its first paint */90 const setFloating = useCallback(91 (el: HTMLElement | null) => {92 floating.current = el;93 if (el) update();94 },95 [update],96 );97
98 useEffect(() => {99 if (!open) return;100 let frame = 0;101 const schedule = () => {102 cancelAnimationFrame(frame);103 frame = requestAnimationFrame(update);104 };105 window.addEventListener("scroll", schedule, true);106 window.addEventListener("resize", schedule);107 // The panel can change size while open (a filtered list, a new month)108 const ro = new ResizeObserver(schedule);109 if (floating.current) ro.observe(floating.current);110 if (anchor.current) ro.observe(anchor.current);111 return () => {112 cancelAnimationFrame(frame);113 window.removeEventListener("scroll", schedule, true);114 window.removeEventListener("resize", schedule);115 ro.disconnect();116 };117 }, [open, update, anchor]);118
119 return { setFloating, side: placed, floatingRef: floating, update };120}121
122/* Where the panel grows from, and where it slides in from, for each side */123export const ORIGIN: Record<Side, string> = {124 top: "bottom",125 bottom: "top",126 left: "right",127 right: "left",128};129export const ENTER_FROM: Record<Side, string> = {130 top: "translateY(4px)",131 bottom: "translateY(-4px)",132 left: "translateX(4px)",133 right: "translateX(-4px)",134};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;}