# HoverCard (/docs/hover-card)





`HoverCard` sits between [`Popover`](/docs/popover) and [`Tooltip`](/docs/tooltip): it's a rich panel *and* it opens on a delay — the "hover a username, see a profile card" pattern. It's a styled layer over [Ark UI's HoverCard](https://ark-ui.com/): Ark owns the open/close state, the open/close delays, positioning, and dismiss-on-outside-interaction; the kit adds the portaled, animated panel chrome (reusing `Popover`'s surface).

Unlike `Tooltip`, there's no single-call convenience form — a hover card's content is arbitrary rich markup, so it's compound-only, like `Popover`.

```tsx
import { HoverCard } from '@42-central/ui-react/hover-card';

<HoverCard.Root>
  <HoverCard.Trigger asChild>
    <a href="/norm">@the-norm</a>
  </HoverCard.Trigger>
  <HoverCard.Content>
    Rich <strong>preview</strong> content.
  </HoverCard.Content>
</HoverCard.Root>
```

## Playground [#playground]

<story.Playground />

## Basic usage [#basic-usage]

<Preview align="center" code="&#x22;<HoverCard.Root>\n  <HoverCard.Trigger asChild>\n    <a href=\&#x22;/norm\&#x22;>@the-norm</a>\n  </HoverCard.Trigger>\n  <HoverCard.Content className=\&#x22;flex w-64 flex-col gap-2 p-3\&#x22;>\n    <div className=\&#x22;flex items-center gap-3\&#x22;>\n      <Avatar name=\&#x22;The Norm\&#x22; color=\&#x22;name\&#x22; />\n      <div className=\&#x22;flex flex-col\&#x22;>\n        <Text size=\&#x22;sm\&#x22; span className=\&#x22;font-medium\&#x22;>The Norm</Text>\n        <Text size=\&#x22;xs\&#x22; c=\&#x22;muted\&#x22; span>@the-norm</Text>\n      </div>\n    </div>\n    <Text size=\&#x22;sm\&#x22;>\n      The reference every project is graded against.\n    </Text>\n  </HoverCard.Content>\n</HoverCard.Root>&#x22;">
  <HoverCardExample />
</Preview>

## Delays [#delays]

Ark's machine defaults to a 600ms `openDelay` and a 300ms `closeDelay` — long enough that an incidental pass of the cursor doesn't pop a card open, but short enough to feel responsive on purpose. Both pass straight through to Ark; the kit adds no timer logic of its own.

<Preview align="center" code="`<HoverCard.Root openDelay={0} closeDelay={0}>…</HoverCard.Root>`">
  <HoverCardDelaysExample />
</Preview>

## Positioning [#positioning]

`position` sets the placement; the panel flips and shifts to stay in view (Ark / Floating UI). Prefer the logical, **RTL-aware** sides `start` / `end` — they resolve to left / right based on the active direction, so the card mirrors automatically under `dir="rtl"`. (Physical `left` / `right` and Ark's aligned variants like `top-start` still work.) Unlike `Popover`, `width` defaults to `"auto"` (size to content) rather than `"target"` — a hover card's content is usually much larger than its trigger.

<Preview align="center" code="&#x22;<HoverCard.Root position=\&#x22;start\&#x22;>…</HoverCard.Root>\n<HoverCard.Root position=\&#x22;top\&#x22;>…</HoverCard.Root>\n<HoverCard.Root position=\&#x22;bottom\&#x22;>…</HoverCard.Root>\n<HoverCard.Root position=\&#x22;end\&#x22;>…</HoverCard.Root>&#x22;">
  <HoverCardPlacements />
</Preview>

## Portal [#portal]

`withinPortal` (default `true`) renders the card in a Portal so it escapes clipping ancestors; pass `container` to portal somewhere other than `document.body`. Set `withinPortal={false}` to keep the card inline instead — it's then clipped by an `overflow: hidden` ancestor like any other child, as long as that ancestor (or one between it and the panel) is positioned (e.g. `position: relative`) — standard CSS: `overflow: hidden` alone doesn't clip a `position: absolute` descendant unless something establishes a containing block for it.

<Preview align="center" code="&#x22;<HoverCard.Root>…</HoverCard.Root> // portaled by default\n<HoverCard.Root withinPortal={false}>…</HoverCard.Root> // clips with its container&#x22;">
  <HoverCardPortalExample />
</Preview>

## Disabled [#disabled]

`disabled` prevents the card from opening at all — hover and focus are both no-ops.

<Preview align="center" code="`<HoverCard.Root disabled>…</HoverCard.Root>`">
  <HoverCardDisabledExample />
</Preview>

## Grouping — `HoverCard.Group` [#grouping--hovercardgroup]

`HoverCard.Group` puts several trigger targets (a row of avatars, say) on **one shared card** instead of a separate card per trigger — the same `HoverCard.Trigger` / `HoverCard.Content` parts `HoverCard.Root` uses, just several of each, matched by a shared `value` (like `Tabs.Trigger` / `Tabs.Content`). Ark's HoverCard machine natively supports several named triggers under a single `Root`/`Content` — hovering from one target to the next while the card is already open just repositions the *same* panel, no close/reopen and no delay replay — and a CSS transition on that reposition makes the panel visibly slide from one target to the next.

`Trigger`/`Content` must be `Group`'s own direct children — `Group` renders no DOM node of its own (like `Root`), so for row-level layout (overlapping avatars, say), wrap `Group` itself instead of wrapping them inside it.

<Preview align="center" code="&#x22;<HoverCard.Group>\n  <HoverCard.Trigger value=\&#x22;ada\&#x22;>\n    <Avatar name=\&#x22;Ada Lovelace\&#x22; />\n  </HoverCard.Trigger>\n  <HoverCard.Trigger value=\&#x22;grace\&#x22;>\n    <Avatar name=\&#x22;Grace Hopper\&#x22; />\n  </HoverCard.Trigger>\n\n  <HoverCard.Content value=\&#x22;ada\&#x22;>Ada's card content</HoverCard.Content>\n  <HoverCard.Content value=\&#x22;grace\&#x22;>Grace's card content</HoverCard.Content>\n</HoverCard.Group>&#x22;">
  <HoverCardGroupExample />
</Preview>

## Accessibility [#accessibility]

A hover card is a **preview of where a link goes**, for sighted mouse users. That is Ark's design, and the kit keeps it:

* **Touch never opens it.** Ark ignores touch pointers, because a tap belongs to the link it previews.
* **Its content can't be focused.** Focusing the trigger opens the card, but Tab moves past it, so nothing inside is keyboard-reachable.
* **Screen readers aren't told it opened.** The trigger carries no `aria-expanded` or `aria-controls`.

So keep its content a preview — everything in it should also be reachable by following the trigger's link. For a card people act on (a copy button, a "View profile" link, a follow button), use [`Popover`'s `mode="hover"`](/docs/popover#hover-mode) instead: it keeps the delayed hover preview, and a click, tap or Enter pins it as a regular, focusable, dismissable popover.

## Anatomy [#anatomy]

* **`HoverCard.Root`** — wraps Ark's `HoverCard.Root`; owns open state, delays, and positioning.
* **`HoverCard.Trigger`** — the trigger; use `asChild` to wrap your own element. Inside a `Group`, give it a `value` to identify its target.
* **`HoverCard.Content`** — the portaled, animated panel. Inside a `Group`, give it a `value` matching its `Trigger`.
* **`HoverCard.Group`** — one shared `Root`/`Content` across several `value`-matched `Trigger`/`Content` pairs, so the panel slides between them instead of closing and reopening.

### `testId` [#testid]

`HoverCard.Root` and `HoverCard.Group` are headless providers with no DOM node of their own, so neither takes a `testId`. `HoverCard.Content` (the panel — one `testId` per `value`, inside a `Group`) takes its own `testId`. `HoverCard.Trigger` is a raw Ark re-export and was left alone: Ark already forwards an arbitrary `data-testid` straight through natively. See [Testing](/docs/testing).

## API [#api]

`HoverCard.Root` props (beyond Ark's `HoverCard.Root`):

<TypeTable
  type="{
  width: {
    type: '&#x22;target&#x22; | &#x22;auto&#x22;',
    default: '&#x22;auto&#x22;',
    description:
      &#x22;Match the trigger's width, or size to content. Defaults to auto, unlike Popover's target — a hover card's content is usually much larger than its trigger.&#x22;,
  },
  position: {
    type: &#x22;Placement&#x22;,
    default: '&#x22;bottom&#x22;',
    description:
      &#x22;Panel placement. Logical start/end are RTL-aware; also accepts top/bottom/left/right and aligned variants like top-start.&#x22;,
  },
  gutter: {
    type: &#x22;number&#x22;,
    description: &#x22;Gap in px between the trigger and the panel.&#x22;,
  },
  offset: {
    type: &#x22;{ mainAxis?: number; crossAxis?: number }&#x22;,
    description: &#x22;Fine-grained positioner offset.&#x22;,
  },
  openDelay: {
    type: &#x22;number&#x22;,
    default: &#x22;600&#x22;,
    description: &#x22;Delay before opening, in ms — Ark's own default.&#x22;,
  },
  closeDelay: {
    type: &#x22;number&#x22;,
    default: &#x22;300&#x22;,
    description: &#x22;Delay before closing, in ms — Ark's own default.&#x22;,
  },
  withinPortal: {
    type: &#x22;boolean&#x22;,
    default: &#x22;true&#x22;,
    description: &#x22;Render the panel in a Portal so it escapes clipping ancestors.&#x22;,
  },
  container: {
    type: &#x22;HTMLElement | null&#x22;,
    description: &#x22;Portal target. Defaults to document.body.&#x22;,
  },
  disabled: {
    type: &#x22;boolean&#x22;,
    description: &#x22;Prevent the card from opening.&#x22;,
  },
  open: { type: &#x22;boolean&#x22;, description: &#x22;Controlled open state.&#x22; },
  defaultOpen: {
    type: &#x22;boolean&#x22;,
    description: &#x22;Uncontrolled initial open state.&#x22;,
  },
  onOpenChange: {
    type: &#x22;(open: boolean) => void&#x22;,
    description: &#x22;Fires when the card opens or closes.&#x22;,
  },
  onClose: {
    type: &#x22;() => void&#x22;,
    description:
      &#x22;Fires on close (any source) — pointer/focus leaving, Escape, outside interaction, or a controlled onOpenChange.&#x22;,
  },
  testId: {
    type: &#x22;string&#x22;,
    description: &#x22;See Testing above.&#x22;,
  },
}"
/>

`HoverCard.Group` props (the same panel-level shape as `HoverCard.Root`, since there's one shared panel):

<TypeTable
  type="{
  width: {
    type: '&#x22;target&#x22; | &#x22;auto&#x22;',
    default: '&#x22;auto&#x22;',
    description: &#x22;Match every trigger's width, or size to content.&#x22;,
  },
  position: {
    type: &#x22;Placement&#x22;,
    default: '&#x22;bottom&#x22;',
    description: &#x22;Panel placement relative to the active trigger.&#x22;,
  },
  gutter: {
    type: &#x22;number&#x22;,
    description: &#x22;Gap in px between the active trigger and the panel.&#x22;,
  },
  offset: {
    type: &#x22;{ mainAxis?: number; crossAxis?: number }&#x22;,
    description: &#x22;Fine-grained positioner offset.&#x22;,
  },
  openDelay: {
    type: &#x22;number&#x22;,
    default: &#x22;600&#x22;,
    description: &#x22;Delay before the first hover opens the card, in ms — Ark's own default.&#x22;,
  },
  closeDelay: {
    type: &#x22;number&#x22;,
    default: &#x22;300&#x22;,
    description:
      &#x22;Delay before closing once the pointer leaves the whole group, in ms — Ark's own default.&#x22;,
  },
  withinPortal: {
    type: &#x22;boolean&#x22;,
    default: &#x22;true&#x22;,
    description: &#x22;Render the panel in a Portal so it escapes clipping ancestors.&#x22;,
  },
  container: {
    type: &#x22;HTMLElement | null&#x22;,
    description: &#x22;Portal target. Defaults to document.body.&#x22;,
  },
  onOpenChange: {
    type: &#x22;(open: boolean) => void&#x22;,
    description: &#x22;Fires when the shared card opens or closes.&#x22;,
  },
  onClose: {
    type: &#x22;() => void&#x22;,
    description:
      &#x22;Fires on close (any source) — pointer/focus leaving the whole group, Escape, outside interaction, or a controlled onOpenChange.&#x22;,
  },
}"
/>

`HoverCard.Content`'s one prop beyond Ark's own `HoverCard.Content`, meaningful only inside a `Group`:

<TypeTable
  type="{
  value: {
    type: &#x22;string&#x22;,
    description:
      &#x22;Identifies which Group target this content belongs to — Group renders whichever Content's value matches the live triggerValue. Ignored (and never forwarded to the DOM) outside a Group.&#x22;,
  },
}"
/>
