Hover Card
A floating card surfaced on hover or focus of a Trigger. Composed as a Radix-style compound — Root, Trigger, Content. The card stays open while the pointer is over either the Trigger or the Content, so users can slide into the card to read it.
Customize
Timing
300
200
Installation
npx shadcn@latest add https://craftbits.dev/r/hover-card.jsonUsage
HoverCard is a compound — Root owns the open-state machine and the hover timers, Trigger is the hoverable element, Content is the floating card that mounts only while open.
import { HoverCard } from "@craft-bits/core";
<HoverCard.Root openDelay={400} closeDelay={200}>
<HoverCard.Trigger>@vercel</HoverCard.Trigger>
<HoverCard.Content>
Frontend cloud — build, preview, and ship.
</HoverCard.Content>
</HoverCard.Root>Take control of the open state with open + onOpenChange:
"use client";
import { useState } from "react";
import { HoverCard } from "@craft-bits/core";
const [open, setOpen] = useState(false);
<HoverCard.Root open={open} onOpenChange={setOpen}>
{/* ... */}
</HoverCard.Root>Understanding the component
- Compound parts. Root owns the open state, hover timers, and stable ids; Trigger renders a real
<button type="button">so keyboard users get Enter / Space activation for free; Content mounts only while open and floats below the Trigger via absolute positioning. The compound mirrors Radix conventions so the API feels familiar. - Hover with intent.
openDelay(default 700 ms) keeps the card from flashing on accidental hovers;closeDelay(default 300 ms) gives the user time to slide the pointer into the card before it dismisses. Both timers cancel each other — entering the Trigger cancels a pending close, leaving the Trigger cancels a pending open. - Bridged interaction. The Content's own
onMouseEntercancels the pending close, so hovering from the Trigger into the Content keeps the card open indefinitely. Leaving the Content schedules a close on the samecloseDelay. - Controlled and uncontrolled. Pass
openfor fully controlled state, ordefaultOpenfor uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition forvalue/defaultValuepairs.onOpenChangefires in either mode. - Focus parity. Focusing the Trigger via keyboard opens the card immediately (no delay) — the user signalled intent, no need to wait. Blurring schedules a close on the standard
closeDelay. - ARIA correctness. The Trigger carries
aria-expandedandaria-controls; the Content carriesrole="dialog"andaria-labelledbyback at the Trigger. Assistive tech announces "expanded" / "collapsed" and reads the surfaced content on focus. - Animated reveal. The Content uses Framer Motion
AnimatePresenceso it unmounts cleanly after its exit transition. Opacity + scale + a 4 px slide run throughSPRINGS.smoothso timing matches the rest of the library.
Props
HoverCard.Root
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Initial open state — uncontrolled mode only. |
onOpenChange | (open: boolean) => void | — | Fires whenever the open state flips, in either mode. |
openDelay | number | 700 | Delay in ms before opening on hover / focus. |
closeDelay | number | 300 | Delay in ms before closing on un-hover / blur. |
className | string | — | Merged onto the rendered <div> container. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
HoverCard.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the rendered <button>. |
onMouseEnter / onMouseLeave / onFocus / onBlur | event handler | — | Run before the timer logic; call event.preventDefault() to suppress. |
...rest | ButtonHTMLAttributes<HTMLButtonElement> | — | Any other <button> prop. |
HoverCard.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the rendered floating <div>. |
onMouseEnter / onMouseLeave | MouseEventHandler<HTMLDivElement> | — | Run before the timer logic; call event.preventDefault() to suppress. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Accessibility
- The Trigger is a real
<button type="button">so keyboard users get Enter / Space activation for free and screen readers announce it as "button". aria-expandedtracks the open state on the Trigger;aria-controlspoints at the Content's id. Assistive tech reads "expanded" / "collapsed" and can jump to the controlled region.- The Content carries
role="dialog"andaria-labelledbyback at the Trigger so the dialog announces with the trigger's text on focus. - Focus opens the card immediately (no
openDelay) — keyboard users signalled intent and shouldn't wait on a hover timer designed for mouse paths. - Focus ring uses
--cb-accentand an inset offset so keyboard focus is visible even on a borderless trigger. - Motion is short and uses opacity + scale + a small vertical slide, all compositor-friendly. Users with
prefers-reduced-motionstill get the open / closed states — only the spring is muted, never the affordance.
Credits
- Extracted from:
algoflashcards(src/platform/ui/hover-card.tsx). The original was a thin wrapper overradix-ui's HoverCard primitive. The craft-bits version drops the runtime dependency on radix-hover-card and rebuilds the open-state machine + hover-intent timers in plain React so the component ships without an extra peer.