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.json

Usage

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

  1. 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.
  2. 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.
  3. Bridged interaction. The Content's own onMouseEnter cancels the pending close, so hovering from the Trigger into the Content keeps the card open indefinitely. Leaving the Content schedules a close on the same closeDelay.
  4. Controlled and uncontrolled. Pass open for fully controlled state, or defaultOpen for uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition for value / defaultValue pairs. onOpenChange fires in either mode.
  5. 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.
  6. ARIA correctness. The Trigger carries aria-expanded and aria-controls; the Content carries role="dialog" and aria-labelledby back at the Trigger. Assistive tech announces "expanded" / "collapsed" and reads the surfaced content on focus.
  7. Animated reveal. The Content uses Framer Motion AnimatePresence so it unmounts cleanly after its exit transition. Opacity + scale + a 4 px slide run through SPRINGS.smooth so timing matches the rest of the library.

Props

HoverCard.Root

PropTypeDefaultDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state — uncontrolled mode only.
onOpenChange(open: boolean) => voidFires whenever the open state flips, in either mode.
openDelaynumber700Delay in ms before opening on hover / focus.
closeDelaynumber300Delay in ms before closing on un-hover / blur.
classNamestringMerged onto the rendered <div> container.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

HoverCard.Trigger

PropTypeDefaultDescription
classNamestringMerged onto the rendered <button>.
onMouseEnter / onMouseLeave / onFocus / onBlurevent handlerRun before the timer logic; call event.preventDefault() to suppress.
...restButtonHTMLAttributes<HTMLButtonElement>Any other <button> prop.

HoverCard.Content

PropTypeDefaultDescription
classNamestringMerged onto the rendered floating <div>.
onMouseEnter / onMouseLeaveMouseEventHandler<HTMLDivElement>Run before the timer logic; call event.preventDefault() to suppress.
...restHTMLAttributes<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-expanded tracks the open state on the Trigger; aria-controls points at the Content's id. Assistive tech reads "expanded" / "collapsed" and can jump to the controlled region.
  • The Content carries role="dialog" and aria-labelledby back 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-accent and 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-motion still 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 over radix-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.