Components

Getting started

Components154

More

A preview of what a link points to: it rises off the link after a rest, stays open while you move onto it, and closes after a short grace.

Used forMentions and usernamesIssue and PR referencesDocs linksTables

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/hover-card.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Mentions and usernames

    A profile with a Follow button on @ada.

  • Issue and PR references

    Status and title behind #482.

  • Docs links

    A summary of the page before you leave.

  • Tables

    A customer's details behind their name.

What it handles for you

The gap between a link and its card is where most hover cards close by accident; a grace period and shared hover fix it.

Instead of wiring upRadix HoverCardlink previewprofile popover
  1. Travel-safe

    Pointer on the link or the card keeps it open; 300ms grace after leaving both.

  2. Rests before opening

    600ms, so passing over links doesn't flash cards.

  3. Keyboard

    Focus opens it, Escape closes it.

  4. Touch-honest

    No hover on touch: the link just works.

Overview

Hover previews usually close the moment the pointer leaves the link, so nothing inside them can be used, or they stay stuck open. This one rises off the link after 600ms, stays open while you travel onto it (so its buttons work), and closes 300ms after you leave both. Keyboard focus opens it at once; touch never does, so a tap still follows the link.

States

rest
After 600ms on the link the card rises 6px from its side out of a 4px blur (320ms).
travel
Moving onto the card keeps it open.
leave
300ms after leaving both, it fades (160ms).

Usage

usage.tsx
import { HoverCard } from "@/components/ui/hover-card";<HoverCard content={<ProfilePreview user={ada} />}>  <a href="/people/ada">@ada</a></HoverCard>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Props

PropTypeDescription
childrenReactElementThe link it previews.
contentReactNodeThe card.
side / alignSide / Aligndefault "bottom" / "start"Where it sits.
openDelay / closeDelaynumberdefault 600 / 300Rest and grace in ms.
widthnumberdefault 300Card width.

Accessibility and motion

  • Placement, presence and dismissal come from floating.ts, shared with tooltip, popover and the menus.
  • Nothing in the card should be the only way to reach something: touch and screen readers may never see it.
  • Reduced motion: it fades.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/hover-card.tsx
"use client";import * as React from "react";import { createPortal } from "react-dom";import { enterStyle, themeVars, useFloating, usePresence, type Align, type Side } from "./floating";import { useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * HOVER CARD: a preview of what a link points to * *   rest     after resting on the link (600ms) the card rises off *            it out of a blur, from the point nearest to it *   travel   moving from the link onto the card keeps it open, so *            its buttons can be used; leaving both closes it after *            a short grace (300ms), so a diagonal path doesn't *   keys     focusing the link from the keyboard opens it at once; *            Escape closes it * * Touch has no hover: a tap follows the link, as people expect. * The card adds to the link, so nothing in it should be the only * way to reach something. * ───────────────────────────────────────────────────────── */export interface HoverCardProps {  /** The link or name it previews. */  children: React.ReactElement<{ "aria-describedby"?: string }>;  content: React.ReactNode;  side?: Side;  align?: Align;  /** Rest before it opens (ms). */  openDelay?: number;  /** Grace before it closes once the pointer has left both (ms). */  closeDelay?: number;  width?: number;  className?: string;}export function HoverCard({ children, content, side = "bottom", align = "start", openDelay = 600, closeDelay = 300, width = 300, className = "" }: HoverCardProps) {  const reduced = useInteractionReducedMotion();  const anchor = React.useRef<HTMLSpanElement>(null);  const surface = React.useRef<HTMLDivElement>(null);  const [open, setOpen] = React.useState(false);  const timer = React.useRef(0);  const id = React.useId();  const { mounted, shown } = usePresence(open, 160);  const place = useFloating({ open: mounted, anchor, floating: surface, side, align, offset: 8 });  const [vars, setVars] = React.useState<React.CSSProperties>({});  const later = (next: boolean, ms: number) => {    window.clearTimeout(timer.current);    timer.current = window.setTimeout(() => setOpen(next), ms);  };  const enter = (e: React.PointerEvent) => e.pointerType === "mouse" && later(true, open ? 0 : openDelay);  const leave = (e: React.PointerEvent) => e.pointerType === "mouse" && later(false, closeDelay);  React.useEffect(() => () => window.clearTimeout(timer.current), []);  React.useEffect(() => {    if (!open) return;    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);    document.addEventListener("keydown", onKey);    return () => document.removeEventListener("keydown", onKey);  }, [open]);  React.useLayoutEffect(() => {    if (mounted) setVars(themeVars(anchor.current));  }, [mounted]);  return (    <>      <span        ref={anchor}        className="inline"        onPointerEnter={enter}        onPointerLeave={leave}        onFocus={(e) => {          if ((e.target as HTMLElement).matches(":focus-visible")) {            window.clearTimeout(timer.current);            setOpen(true);          }        }}        onBlur={(e) => {          if (!surface.current?.contains(e.relatedTarget as Node)) later(false, 0);        }}      >        {React.cloneElement(children, { "aria-describedby": open ? id : children.props["aria-describedby"] })}      </span>      {mounted &&        createPortal(          <div            ref={surface}            id={id}            role="tooltip"            onPointerEnter={enter}            onPointerLeave={leave}            className={`fixed left-0 top-0 z-[55] max-w-[calc(100vw_-_16px)] rounded-[16px] bg-popover p-4 text-[13.5px] text-popover-foreground shadow-[0_0_0_1px_var(--border)] ${className}`}            style={{ ...vars, width, left: place.x, top: place.y, transformOrigin: place.origin, ...enterStyle(shown, place.side, reduced) }}          >            {content}          </div>,          document.body,        )}    </>  );}export default HoverCard;
components/floating.ts
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * FLOATING: what every popover, menu and tooltip shares * *   place     beside an anchor on a side, lined up start, centre *             or end; flips to the other side when it doesn't fit *             and shifts to stay `padding` inside the viewport; *             follows scroll and resize *   presence  mounts on open, shows a frame later (so the entry *             transitions run), hides, then unmounts after `exit` *   dismiss   Escape and a press outside both close it *   theme     the portal carries the anchor's theme tokens, so a *             light specimen on a dark page stays light * ───────────────────────────────────────────────────────── */export type Side = "top" | "bottom" | "left" | "right";export type Align = "start" | "center" | "end";const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring", "--card"];/** The anchor's theme tokens as inline styles for something portalled out of its tree. */export function themeVars(anchor: Element | null): React.CSSProperties {  if (!anchor) return {};  const style = getComputedStyle(anchor);  const vars: Record<string, string> = {};  for (const t of TOKENS) {    const v = style.getPropertyValue(t);    if (v) vars[t] = v;  }  return vars as React.CSSProperties;}const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };export interface Placement {  x: number;  y: number;  side: Side;  /** Where the surface grows from, for transform-origin. */  origin: string;  ready: boolean;}/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */export function useFloating({  open,  anchor,  point,  floating,  side = "bottom",  align = "center",  offset = 8,  padding = 8,}: {  open: boolean;  anchor?: React.RefObject<Element | null>;  point?: { x: number; y: number } | null;  floating: React.RefObject<HTMLElement | null>;  side?: Side;  align?: Align;  offset?: number;  padding?: number;}): Placement {  const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: "center", ready: false });  React.useLayoutEffect(() => {    if (!open) return;    let frame = 0;    const measure = () => {      const el = floating.current;      if (!el) return;      const a = point        ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }        : anchor?.current?.getBoundingClientRect();      if (!a) return;      const w = el.offsetWidth;      const h = el.offsetHeight;      const vw = window.innerWidth;      const vh = window.innerHeight;      const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding };      const need = side === "top" || side === "bottom" ? h + offset : w + offset;      const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;      let x: number;      let y: number;      if (s === "top" || s === "bottom") {        y = s === "bottom" ? a.bottom + offset : a.top - offset - h;        x = align === "start" ? a.left : align === "end" ? a.right - w : a.left + a.width / 2 - w / 2;        if (point) x = a.left;      } else {        x = s === "right" ? a.right + offset : a.left - offset - w;        y = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + a.height / 2 - h / 2;      }      x = Math.max(padding, Math.min(vw - padding - w, x));      y = Math.max(padding, Math.min(vh - padding - h, y));      // Grow from the point on the anchor's edge nearest the surface.      const ox = s === "left" ? "100%" : s === "right" ? "0%" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`;      const oy = s === "top" ? "100%" : s === "bottom" ? "0%" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`;      const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true };      setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next));    };    const schedule = () => {      cancelAnimationFrame(frame);      frame = requestAnimationFrame(measure);    };    measure();    const ro = new ResizeObserver(schedule);    if (floating.current) ro.observe(floating.current);    window.addEventListener("scroll", schedule, { capture: true, passive: true });    window.addEventListener("resize", schedule);    return () => {      cancelAnimationFrame(frame);      ro.disconnect();      window.removeEventListener("scroll", schedule, { capture: true });      window.removeEventListener("resize", schedule);    };  }, [open, anchor, point, floating, side, align, offset, padding]);  return open ? place : { ...place, ready: false };}/** Mount → show next frame → hide → unmount after `exit` ms. */export function usePresence(open: boolean, exit = 160) {  const [mounted, setMounted] = React.useState(open);  const [shown, setShown] = React.useState(false);  if (open && !mounted) setMounted(true);  React.useEffect(() => {    if (open) {      let inner = 0;      const outer = requestAnimationFrame(() => {        inner = requestAnimationFrame(() => setShown(true));      });      return () => {        cancelAnimationFrame(outer);        cancelAnimationFrame(inner);      };    }    const timer = window.setTimeout(() => {      setMounted(false);      setShown(false);    }, exit);    return () => window.clearTimeout(timer);  }, [open, exit]);  return { mounted: mounted || open, shown: open && shown };}/** Escape and presses outside `refs` call onDismiss while `open`. */export function useDismiss(open: boolean, onDismiss: (reason: "escape" | "outside") => void, refs: React.RefObject<Element | null>[]) {  const latest = React.useRef({ onDismiss, refs });  React.useEffect(() => {    latest.current = { onDismiss, refs };  });  React.useEffect(() => {    if (!open) return;    const onKey = (e: KeyboardEvent) => {      if (e.key !== "Escape") return;      e.stopPropagation();      latest.current.onDismiss("escape");    };    const onDown = (e: PointerEvent) => {      const target = e.target as Node;      if (latest.current.refs.some((r) => r.current?.contains(target))) return;      latest.current.onDismiss("outside");    };    document.addEventListener("keydown", onKey);    document.addEventListener("pointerdown", onDown, true);    return () => {      document.removeEventListener("keydown", onKey);      document.removeEventListener("pointerdown", onDown, true);    };  }, [open]);}/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */export function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = "cubic-bezier(0.16,1,0.3,1)"): React.CSSProperties {  const d = 6;  const shift = side === "top" ? `0 ${d}px` : side === "bottom" ? `0 -${d}px` : side === "left" ? `${d}px 0` : `-${d}px 0`;  return {    opacity: shown ? 1 : 0,    translate: shown || reduced ? "0 0" : shift,    scale: shown || reduced ? "1" : "0.96",    filter: shown || reduced ? "blur(0px)" : "blur(4px)",    transition: reduced      ? "opacity 120ms ease-out"      : shown        ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}`        : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`,  };}

Registry item: /r/hover-card.json