Preview Card
A card that previews a link's destination, revealed by resting on the link. It
is the Wikipedia hover-summary and the profile card behind an @mention: a
courtesy for anyone with a pointer, and never the only way to the content.
The first algorithm intended for a machine was published in 1843 by Ada Lovelace, working from Menabrea’s notes.
Preview card, popover, or tooltip?
All three are anchored surfaces. What separates them is who the content is for, and how it is reached.
| Opens on | Announced | Reachable by keyboard | Holds | |
|---|---|---|---|---|
| Preview Card | hover, focus, after a long delay | no | no — Tab goes past it | a summary of the link |
| Popover | press, and hover on request | yes | yes — it is tabbable | anything that matters |
| Tooltip | hover, focus | as the trigger's label | n/a | a label for a control |
The test is what happens if the surface never appears. If the user would miss it, the content belongs in a popover; this component is for the case where the card is a shortcut and the link itself is the answer.
Import
import { PreviewCard } from "@forte-ui/react";Anatomy
PreviewCard.Popup collapses Base UI's Portal and Positioner into one
part, so the common case is a single element. Its props are drawn from all
three.
<PreviewCard.Root>
<PreviewCard.Trigger href="/u/ada" />
<PreviewCard.Popup>
<PreviewCard.Arrow />
<PreviewCard.Viewport />
</PreviewCard.Popup>
</PreviewCard.Root>There is no Title, Description, Close or Footer here, and their absence
is the component. A popover has them because they wire up aria-labelledby,
aria-describedby and a way out — a card that is never announced and never
focused has nothing to wire, and nothing to escape from. The popup is a flex
column with a gap; the layout inside it is yours.
The trigger is a link
PreviewCard.Trigger renders an <a>, so give it an href. Without one
the element is not focusable and not announced as a link, which strands the
keyboard user — the one person for whom the card was never going to be the
point. For a router link, pass it through render:
<PreviewCard.Trigger render={<Link href="/u/ada" />}>@ada</PreviewCard.Trigger>A link that wraps across lines anchors the card to the line the pointer is actually on, not to the union rectangle of all its lines. That happens without configuration.
Most of the interface vocabulary here comes from the Base UI primitives this library is built on, which supply the anatomy.
The arrow
There is no arrow prop. Render <PreviewCard.Arrow /> as a child of
PreviewCard.Popup and the wedge appears, pointing back at the link; leave it
out and the card is a plain rectangle. It positions and rotates itself from the
side the card resolved to, and takes the card's own fill and hairline, so a
re-skinned card re-skins the arrow with it.
Two things to know. sideOffset has to leave room for it — the default 8px
clears the default arrow, so only a smaller offset is a problem. And with a
PreviewCard.Viewport, keep the arrow outside the viewport, as a direct child
of the popup: the viewport clips, and the arrow lives past the card's edge.
Examples
Sides
side on PreviewCard.Popup picks the edge the card opens from, and align
moves it along that edge. Both are hints: the card flips to the opposite side
when it would overflow the collision boundary, which is why the left and
right cards below may land elsewhere in a narrow frame. Alongside the four
physical sides, "inline-start" and "inline-end" follow writing direction
instead of staying pinned to a physical edge.
Sizes
size sets a width cap, not a width. The card shrinks to fit its content
and only grows this wide when the content asks for it — and it is clamped again
to whatever room the positioner reports as available, so a wide card on a narrow
screen narrows rather than overflowing.
Timing
delay on PreviewCard.Trigger is how long the pointer has to rest before the
card opens, and it is the setting that decides whether the component reads as
helpful or as a rash. The default is 600ms — long enough that crossing a
link on the way somewhere else never opens anything. closeDelay (default
300ms) is how long the card lingers after the pointer leaves.
Rest on no wait, the default, or a long wait and compare how long each one makes you hold still.
closeDelay is not the whole journey from link to card. Base UI grants a safe
polygon over the gap, so the pointer may cut the corner diagonally without the
card closing under it; the delay is the tolerance for overshooting, not for the
trip.
Beyond a profile
The popup is a flex column with a padding and a gap, and both are knobs, so a
card with edge-to-edge media turns them off and puts the padding back on the
text below. What it must not do is add overflow: clip to hold the media
inside the corners: the Arrow is a child of the popup and is placed outside
its box, so clipping would eat the wedge. Round the media itself instead.
The palette in this library is rebuilt from one seed colour using CSS relative colour syntax, so a theme is one variable.
Multiple links, one card
One PreviewCard.Root can serve every mention in a paragraph. Each link hands
the popup a payload, and the popup reads it from the render-function form of
Root's children — so the card is written once and only one popup element is
ever mounted.
Guard on payload being undefined. It is undefined before the first open, and
again while the card animates out, because Base UI keeps the popup mounted until
the exit transition finishes.
Animating between links
PreviewCard.Viewport animates that swap. It keeps a clone of the outgoing
panel mounted for the length of the transition, slides the two past each other
in the direction the new link lies, and resizes the card to follow.
Wrap only the content that changes, and keep PreviewCard.Arrow outside the
viewport. Without a viewport the content still swaps correctly — it just cuts
rather than sliding, which is the right choice when the panels are similar
enough that the movement would be noise.
Controlled
A controlled card has two pieces of state: open, and triggerId naming the
link it is anchored to. There is no separate onTriggerIdChange — the trigger
that caused the change arrives on onOpenChange's event details, so both are
updated from the one handler. Changing triggerId alone re-anchors the card
without closing it.
Detached triggers
PreviewCard.createHandle() connects a PreviewCard.Root to links that are not
its children, for when declaring the card beside every mention is not practical.
Pass the same handle to the root and to every trigger. The handle is also an
imperative remote: open(triggerId) and close().
Two rules run through the whole codebase: Tokens are the API and Two axes, not a variant list.
Create the handle outside render. One made during render is a new object on
every pass, and the root and its triggers stop recognising each other. open()
takes a trigger id rather than a payload — the card still has to be anchored to
something — so give each detached trigger an explicit id.
Accessibility
| Key | Behaviour |
|---|---|
| Tab | Moves to the link, which opens the card after the trigger delay. Tabbing again moves to the next element in the page — not into the card. |
| Esc | Closes the card, leaving focus on the link. |
| Enter | Follows the link, as on any other link. |
The card carries no role, no aria-haspopup and no aria-describedby, and
Base UI leaves it that way on purpose. A screen reader announces the link as an
ordinary link and the card never interrupts — which is the correct behaviour for
a surface holding a summary of the page the link already names, and the reason
the content rule at the top of this page is absolute.
Two consequences worth stating plainly:
- Anything actionable in the card is pointer-only. The card stays open while the pointer is inside it, so a link in there is clickable; a keyboard user never gets to it. Repeat it on the destination.
- There is no touch story. The card opens on hover and on focus, neither of which a touch tap produces. On a phone the link is simply a link, which is why the link has to be enough.
In forced-colors mode the card's shadow is stripped by the browser, so the
component carries a transparent border that becomes the visible system-coloured
boundary, and the arrow is drawn as two SVG paths rather than the traditional
CSS-border triangle, which would degrade into a filled rectangle. The trigger
carries .forte-link, so it repaints as LinkText with an underline. Reduced
motion is handled by the motion tokens: the travel collapses to 0px and the
scale to exactly 1, leaving a plain opacity fade.
Theming
Every value below is a custom property you can override.
--forte-preview-card-z-index is declared on the positioner, the three
--forte-preview-card-trigger-* values on the trigger, the viewport pair on the
viewport, and everything else on the popup.
| Property | Controls | Default |
|---|---|---|
--forte-preview-card-trigger-color | Link colour | var(--forte-color-primary-text) |
--forte-preview-card-trigger-underline-color | Colour of the link's underline. Muted by default so the rule reads as an underline rather than as a second line of text. | color-mix( in oklab, currentcolor 45%, transparent ) |
--forte-preview-card-trigger-underline-offset | Distance between the text baseline and its underline | 0.15em |
--forte-preview-card-z-index | Stacking order of the positioner | 50 |
--forte-preview-card-bg | Card surface colour | var(--forte-color-overlay) |
--forte-preview-card-fg | Card text colour | var(--forte-color-foreground) |
--forte-preview-card-radius | Corner radius | var(--forte-radius-surface) |
--forte-preview-card-padding | Padding inside the card | var(--forte-surface-p) |
--forte-preview-card-gap | Vertical gap between the card's children | var(--forte-space-3) |
--forte-preview-card-shadow | Card shadow (stripped by the browser in forced-colors mode, which is why the element also carries .forte-hc-surface) | var(--forte-shadow-4) |
--forte-preview-card-border-width | Width of the hairline that separates the card from the page | 1px |
--forte-preview-card-border-color | Colour of that hairline. The arrow's outline follows it. | var(--forte-color-border) |
--forte-preview-card-font-size | Base font size inside the card | var(--forte-font-size-2) |
--forte-preview-card-max-width | Width cap, further clamped to the space the positioner reports as available; what the size prop sets — 16rem for sm, 26rem for lg | 20rem |
--forte-preview-card-arrow-width | Arrow base width | 1em |
--forte-preview-card-arrow-height | Arrow height, and the distance it is pushed out from the card | 0.5em |
--forte-preview-card-travel | How far the card slides in from, per side | var(--forte-travel-sm) |
--forte-preview-card-enter-scale | Scale the card grows from and shrinks back to | var(--forte-scale-enter) |
--forte-preview-card-enter-duration | Length of the opening gesture | var(--forte-duration-normal) |
--forte-preview-card-enter-ease | Curve of the opening gesture | var(--forte-ease-emphasized) |
--forte-preview-card-exit-duration | Length of the closing gesture | var(--forte-duration-fast) |
--forte-preview-card-exit-ease | Curve of the closing gesture | var(--forte-ease-exit) |
--forte-preview-card-resize-duration | How long the card takes to resize between two links' content; only in play when a PreviewCard.Viewport is present | var(--forte-duration-normal) |
--forte-preview-card-viewport-travel | How far the incoming and outgoing content slide during a swap | var(--forte-travel-sm) |
Where you set these matters. Each one is declared on its own element, so setting
it on an ancestor, on :root or on a theme scope has no effect — an element's
own declaration wins over an inherited value. Override them on the element that
declares them: className on PreviewCard.Popup for most,
positionerClassName for the stacking one, className on
PreviewCard.Trigger for the link. The portal makes the ancestor route doubly
useless: the popup renders on document.body, so nothing above the link is an
ancestor of it in the first place.
To re-skin every card at once, re-point the global tokens the defaults resolve
to — --forte-color-overlay, --forte-color-border, --forte-color-primary-text,
--forte-radius-surface, --forte-surface-p, --forte-shadow-4, --forte-space-*,
--forte-font-*, --forte-travel-sm. The card only reads those and never
re-declares them, so a value set on :root or a theme scope does reach it.
Two defaults differ from Popover's deliberately, and both are about a surface
nobody asked for. The entrance runs on --forte-ease-emphasized rather than a
spring: a popover answers a press and can afford some snap, while an overshoot
on something that appeared because the reader held still reads as the page
twitching. And the trigger's underline is a muted color-mix of its own text
colour, so a paragraph with six mentions in it does not turn into six rules of
solid accent.
The stylesheet also computes --forte-preview-card-enter-x /
--forte-preview-card-enter-y from --forte-preview-card-travel and the resolved
data-side, and --forte-preview-card-viewport-x from
--forte-preview-card-viewport-travel and data-activation-direction. They are
internal; change the travel tokens instead.
API reference
PreviewCard.Root
| Prop | Type | Default | Description |
|---|---|---|---|
actionsRef | RefObject<PreviewCardRootActions | null> | Imperative escape hatch. `close()` closes the card; `unmount()` forces the popup out of the DOM, for when an exit animation is driven externally. | |
children | ReactNode | PayloadChildRenderFunction<Payload> | The trigger and popup for this card. May also be a render function receiving `{ payload }` from the trigger that opened it. | |
defaultOpen | boolean | false | Whether the card is open when it first mounts. For a controlled card use `open` instead. |
defaultTriggerId | string | null | The initially attached trigger id. Pairs with `defaultOpen` the way `triggerId` pairs with `open`. | |
handle | PreviewCardHandle<Payload> | Associates this card with detached triggers created through `PreviewCard.createHandle()`, so one popup can serve triggers that live elsewhere in the tree. | |
onOpenChange | ((open: boolean, eventDetails: PreviewCardRootChangeEventDetails) => void) | Called when the card wants to open or close. The second argument carries the reason (`'trigger-hover'`, `'trigger-focus'`, `'trigger-press'`, `'outside-press'`, `'escape-key'`, `'imperative-action'`, `'none'`), the trigger involved, and can `cancel()` the change. | |
onOpenChangeComplete | ((open: boolean) => void) | Called after the open or close transition has finished. Use this rather than a timer when work has to wait for the card to actually leave. | |
open | boolean | Whether the card is currently open. Pass this together with `onOpenChange` to control the card. | |
triggerId | string | null | Which trigger the card is currently attached to, for controlled multi-trigger setups. There is no separate `onTriggerIdChange` — read the new id off `eventDetails.trigger` inside `onOpenChange`. |
PreviewCard.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeDelay | number | 300 | How long the card lingers after the pointer leaves, in milliseconds. The pointer only has to reach the popup within this window along a direct path — Base UI grants the diagonal — so this is the tolerance for overshooting, not the whole journey. |
delay | number | 600 | How long the pointer must rest on the link before the card opens, in milliseconds. Also the wait before a focused link opens it. |
handle | PreviewCardHandle<Payload> | Associates a detached trigger with the `PreviewCard.Root` carrying the same handle, created once outside render with `PreviewCard.createHandle()`. | |
href | string | Where the link goes. Not optional in practice: the card is an enrichment of a link, and an `<a>` without `href` is neither focusable nor announced as a link — so leaving it off strands every keyboard user, who is also the one person for whom the card is not the point. | |
id | string | Identifies the trigger. Also how `PreviewCard.Root`'s `triggerId` names the active trigger in controlled multi-trigger mode. | |
payload | Payload | Data handed to the card when this trigger opens it, so one popup can render a different profile per link. Read it from the render-function form of `PreviewCard.Root`'s children. | |
render | ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>, PreviewCardTriggerState> | Replaces the rendered `<a>` with another element or component — `render={<Link href="/u/ada" />}` for a router link is the common case. The trigger's own link styling steps aside when this is present, so the two never fight over the cascade. |
PreviewCard.Popup
Renders the portal, the positioner and the popup as one part, so its props are drawn from all three.
| Prop | Type | Default | Description |
|---|---|---|---|
align | Align | center | How the card lines up with the link along the chosen side. |
alignOffset | number | OffsetFunction | 0 | Shifts the card along the alignment axis, in pixels, or a function returning one. |
anchor | Element | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null | The element the card positions against, when it should not be the link. Accepts an element, a ref, a getter, or a virtual element. | |
arrowPadding | number | 5 | Minimum distance, in pixels, the arrow keeps from the card's corners before it is allowed to sit off-centre (`data-uncentered`). |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
collisionAvoidance | CollisionAvoidance | How the card reacts when it would overflow the boundary — whether it flips, shifts, or stays put. | |
collisionBoundary | Boundary | clipping-ancestors | The boundary the card tries to stay inside of. |
collisionPadding | Padding | 5 | Space, in pixels, kept between the card and the collision boundary. |
container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | Where the portal renders. Defaults to `document.body`; point it at a container when the card has to live inside a specific stacking or shadow root. | |
disableAnchorTracking | boolean | false | Stops the card re-measuring the anchor on scroll and resize. Cheaper, but the card drifts if the anchor moves. |
keepMounted | boolean | false | Keeps the portal — and therefore the card — in the DOM while it is closed. Needed when something inside must stay mounted (an iframe, a media element). |
positionerClassName | string | Additional class name(s) for the positioner element, which owns placement and `z-index`. Use it to re-stack a single card through `--forte-preview-card-z-index`. | |
positionMethod | "fixed" | "absolute" | absolute | Whether the card is positioned with `position: absolute` or `position: fixed`. |
side | Side | bottom | Which side of the link to place the card on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction. |
sideOffset | number | OffsetFunction | 8 | Gap between link and card, in pixels, or a function returning one. When an `Arrow` is rendered this must exceed the arrow's height or the arrow overlaps the link; the default leaves room for the default arrow. It is also the gap the pointer crosses on its way in. Base UI covers the trip with a safe polygon, so a wider offset is safe — but it is the one number that makes the card feel out of reach if it grows much past the text it belongs to. |
size | PreviewCardSize | md | Width cap for the card. The card shrinks to fit its content and only grows to this width when the content asks for it, so this is a ceiling rather than a fixed measure. Further clamped to the space the positioner reports as available. |
sticky | boolean | false | Keeps the card glued to the link while it scrolls out of view instead of letting it detach. |
PreviewCard.Arrow
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | Replaces the built-in wedge. The default SVG inherits the card's colours through `--forte-preview-card-bg`, so a custom skin usually needs nothing here. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
PreviewCard.Viewport
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The content that changes from link to link. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
PreviewCard.createHandle
PreviewCard.createHandle<Payload>() returns a handle connecting a
PreviewCard.Root to detached PreviewCard.Triggers. Pass it to the handle
prop of both. It also exposes open(triggerId), close() and isOpen, which
take effect only while a root using the handle is mounted.