Feedback

Hover Card

A hover-and-focus card for arbitrary rich content — avatar, stats, links, buttons — with an open delay, a close grace period the pointer can cross into, clip-aware flipping and tap-to-toggle on touch.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type HoverCardSide = "top" | "right" | "bottom" | "left"
export type HoverCardAlign = "start" | "center" | "end"

/** Breathing room kept between the card and the edge it opens toward (px). */
const EDGE_MARGIN = 8
/** Floor for the measured caps — below this the card is unusable anyway. */
const MIN_SIZE = 96
/** Anything matching this can hold focus, so it can also receive it back on close. */
const FOCUSABLE = 'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])'

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/hover-card.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "HoverCard" component: a card of arbitrary
rich content revealed by hovering or focusing a trigger. No dependencies beyond
React and a cn() class merger — no positioning library, no animation library.

Contract
- export const HoverCard = React.forwardRef<HTMLSpanElement, HoverCardProps>,
  HoverCardProps extends React.HTMLAttributes<HTMLSpanElement> minus
  children / onFocus / onBlur.
- Props:
  - trigger: React.ReactElement (required) — one element that can hold focus
    (button, link, anything with tabIndex). It is cloned ONLY to merge
    aria-describedby, so its own props, ref and handlers survive untouched.
  - children: React.ReactNode — the card body. Anything: avatar rows, stat
    lines, links, buttons. Interactive children stay clickable and tabbable.
  - openDelay = 300 / closeDelay = 150 (ms, both clamped with Math.max(0, …);
    0 means "commit synchronously", never "never").
  - side = "top" | "right" | "bottom" | "left" (default "bottom")
  - align = "start" | "center" | "end" (default "center")
  - sideOffset = 8 (px gap between trigger and card)
  - open? + onOpenChange? — controlled when `open` is passed; onOpenChange fires
    on every transition either way, and never fires twice for the same value.
  - disabled = false — kills every trigger path and overrides a controlled
    `open`.
  - className lands on the CARD (that is where width and padding belong);
    the forwarded ref and the remaining props land on the wrapper span.
- The card gets a useId() id; the trigger gets aria-describedby pointing at it
  while open, merged with any aria-describedby the trigger already had.

Behavior
- Pointer: entering the trigger starts an openDelay timer; leaving starts a
  closeDelay timer. Both handlers live on the wrapper span, and the card is
  rendered INSIDE that wrapper, so a pointer travelling trigger -> gap -> card
  produces leave-then-enter and the enter cancels the pending close. That
  closeDelay window is the grace period: without it the sideOffset gap closes
  the card halfway to the button the user was aiming at.
- Timer hygiene: every entry point clears BOTH timers before scheduling, so
  sweeping across a paragraph of triggers can never stack pending opens; the
  timers are also cleared on unmount. Timer callbacks read the live open state
  and the live onOpenChange through a ref, never through a stale closure.
- Keyboard: focus opens immediately (no delay) but only when the trigger
  matches :focus-visible — a mouse click that lands focus on the trigger is
  already covered by hover, and on touch it would fight the tap path. Blur
  closes, unless focus merely moved into the card (relatedTarget still inside
  the wrapper), which is what lets Tab walk into the card's own links.
- Escape closes from anywhere (document listener, active only while open) and
  a pointerdown outside the wrapper closes too.
- When the card unmounts while it holds focus (Escape, or the pointer leaving
  after a click inside), focus is handed back to the first focusable element in
  the trigger — guarded by isConnected, and by a "restoring" ref so the
  programmatic focus is not mistaken for a keyboard visit and does not reopen
  what was just closed.
- Touch: hover is not a gesture. Record event.pointerType on pointerdown and
  ignore pointerenter/pointerleave when it is "touch"; instead a tap toggles the
  card. The opening tap calls preventDefault so a link trigger reveals the card
  instead of navigating away from the thing being previewed; the second tap
  closes and lets the trigger behave normally. Taps whose target is inside the
  card are ignored by that handler.
- Positioning: measure against the nearest CLIPPING ancestor, not the viewport.
  Walk up the parents, and for every node whose computed overflowX/overflowY is
  not "visible" intersect its rect into a clip box. Then: flip to the opposite
  side when the preferred side cannot fit the card and the opposite side has
  more room; shift along the cross axis to stay inside the clip; publish
  maxWidth/maxHeight from the room that actually exists, with the card set to
  overflow-auto so tall content scrolls instead of being cut. Offsets are
  wrapper-relative, and the main axis is anchored with `bottom`/`right` for the
  flipped sides so the position never depends on the card's own height.
- Measurement runs in a ResizeObserver callback — observe() delivers once
  immediately, and that first delivery is the initial measurement, so no
  setState ever happens in an effect body. The same callback is wired to
  capture-phase scroll and to resize while open. Placement state is dropped on
  close (render-phase prevOpen compare), so the next open re-measures before it
  paints instead of flashing the previous position.
- Accessibility: the card is a plain container referenced by aria-describedby,
  NOT role="dialog". Nothing here traps or manages focus, so announcing a dialog
  would promise semantics the component does not implement; as a plain container
  sitting right after the trigger in DOM order its buttons and links are simply
  the next Tab stops. Document that trade-off: content is read as a
  supplementary description, so a card that is really a task surface belongs in
  a Popover or Dialog instead.

Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground / border / shadow-md
  for the card, focus-visible:ring-2 ring-ring on whatever the consumer passes as
  the trigger. No hardcoded colors, no arbitrary radii.
- Wrapper: relative inline-block, so the component drops into a sentence.
- Card: absolute z-50 block w-72 overflow-auto overscroll-contain rounded-lg border
  p-4 text-sm, merged last through cn() so w-64 / p-0 / max-w-* from the consumer
  win. It is a block-level span rather than a div, because the trigger usually
  lives in running text and a div would be invalid inside a paragraph.
- Reveal: the card renders invisible (opacity-0 scale-95) until the first
  measurement lands one frame later, then transitions to opacity-100 scale-100.
  That single flag doubles as the enter animation and as the guard against
  painting an unmeasured position. transition-[opacity,scale] +
  motion-reduce:transition-none — under reduced motion the card simply appears.
- Before the first measurement the card uses a percentage fallback placement
  (top/bottom/left/right 100% + a translate for centered alignment) so it is
  never parked at the wrapper origin.

Customization levers
- Timing: openDelay is the "did the user mean it" threshold (200-500ms reads
  well; 0 for a dense data grid); closeDelay is the grace period — raise it
  toward 300ms if you also raise sideOffset, lower it to 0 only when the card
  has nothing clickable in it.
- Geometry: side + align + sideOffset are the whole placement API; the flip and
  the edge shift are automatic and are not props on purpose — they depend on the
  clip box, not on the caller.
- Card shell: pass className to change width (w-64 / w-96), padding (p-0 for a
  card with its own header image), radius or shadow. Add an arrow by absolutely
  positioning a rotated square inside the card and keying it off the resolved
  side — you would need to expose that side, which the component keeps private
  today.
- Content: the body is entirely yours; keep it to one primary action, because
  everything in it is announced as a description of the trigger.
- Escape hatch: if the card must escape an overflow:hidden ancestor entirely,
  render it through a portal and switch the wrapper-relative offsets to
  viewport-absolute ones — the placement math is otherwise unchanged.

Concepts

  • Grace period — the interval between "pointer left the trigger" and "card actually closes". It is the cheap half of the classic safe-triangle problem: with an 8px gap between trigger and card, a diagonal move toward a button inside the card leaves the trigger for a few frames, and without that window the card dies under the cursor. Here the card lives inside the trigger's wrapper, so re-entering it cancels the pending close instead of scheduling a second open.
  • Intent delayopenDelay is not decoration, it is the promise that sweeping a pointer across a paragraph of mentions opens nothing. Every scheduling path clears both timers first, so a fast sweep leaves zero pending work behind rather than a queue of cards about to appear.
  • Clipping-ancestor placement — flipping is decided against the nearest ancestor that clips its overflow (docs stage, dashboard card, sidebar), never against the viewport. Measuring against the window is how an overlay ends up drawn inside a clip: present in the DOM, invisible on screen and impossible to click.
  • Measure, then reveal — the first measurement arrives in the ResizeObserver callback that fires on observe(), and the card stays transparent until it lands; the same flag drives the fade-in, so the unmeasured frame is never painted and no state is set inside an effect body.
  • Describedby over dialog — the card is supplementary information about the trigger, so it is announced through aria-describedby and stays a plain container. role="dialog" would advertise focus management this component deliberately does not do; instead the card sits right after the trigger in DOM order and its links and buttons are simply the next Tab stops.
  • Hover is not a touch gesture — on a coarse pointer there is no hover state to disambiguate, so pointerType === "touch" switches the component to tap-to-toggle and the opening tap suppresses the trigger's default action; enter/leave events from touch pointers are ignored outright so a tap cannot schedule a close.

On This Page