Skip to content

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.

preview-card/basic.tsx

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 onAnnouncedReachable by keyboardHolds
Preview Cardhover, focus, after a long delaynono — Tab goes past ita summary of the link
Popoverpress, and hover on requestyesyes — it is tabbableanything that matters
Tooltiphover, focusas the trigger's labeln/aa 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.

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.

preview-card/inline.tsx

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.

preview-card/placement.tsx

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.

preview-card/sizes.tsx

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.

preview-card/delay.tsx

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.

preview-card/media.tsx

The palette in this library is rebuilt from one seed colour using CSS relative colour syntax, so a theme is one variable.

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.

preview-card/multiple-triggers.tsx

Reviewed by @ada, @bea and @cyrus before it shipped.

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.

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.

preview-card/viewport.tsx

The generators are ramp.mjs, motion.mjs and check-contrast.mjs. Move between them without leaving the card.

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.

preview-card/controlled.tsx

Shipped so far: v0.3, v0.4, v0.5.

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().

preview-card/detached-trigger.tsx

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

Keyboard interactions
KeyBehaviour
TabMoves 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.
EscCloses the card, leaving focus on the link.
EnterFollows 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.

Theming tokens for PreviewCard
PropertyControlsDefault
--forte-preview-card-trigger-colorLink colourvar(--forte-color-primary-text)
--forte-preview-card-trigger-underline-colorColour 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-offsetDistance between the text baseline and its underline0.15em
--forte-preview-card-z-indexStacking order of the positioner50
--forte-preview-card-bgCard surface colourvar(--forte-color-overlay)
--forte-preview-card-fgCard text colourvar(--forte-color-foreground)
--forte-preview-card-radiusCorner radiusvar(--forte-radius-surface)
--forte-preview-card-paddingPadding inside the cardvar(--forte-surface-p)
--forte-preview-card-gapVertical gap between the card's childrenvar(--forte-space-3)
--forte-preview-card-shadowCard 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-widthWidth of the hairline that separates the card from the page1px
--forte-preview-card-border-colorColour of that hairline. The arrow's outline follows it.var(--forte-color-border)
--forte-preview-card-font-sizeBase font size inside the cardvar(--forte-font-size-2)
--forte-preview-card-max-widthWidth cap, further clamped to the space the positioner reports as available; what the size prop sets — 16rem for sm, 26rem for lg20rem
--forte-preview-card-arrow-widthArrow base width1em
--forte-preview-card-arrow-heightArrow height, and the distance it is pushed out from the card0.5em
--forte-preview-card-travelHow far the card slides in from, per sidevar(--forte-travel-sm)
--forte-preview-card-enter-scaleScale the card grows from and shrinks back tovar(--forte-scale-enter)
--forte-preview-card-enter-durationLength of the opening gesturevar(--forte-duration-normal)
--forte-preview-card-enter-easeCurve of the opening gesturevar(--forte-ease-emphasized)
--forte-preview-card-exit-durationLength of the closing gesturevar(--forte-duration-fast)
--forte-preview-card-exit-easeCurve of the closing gesturevar(--forte-ease-exit)
--forte-preview-card-resize-durationHow long the card takes to resize between two links' content; only in play when a PreviewCard.Viewport is presentvar(--forte-duration-normal)
--forte-preview-card-viewport-travelHow far the incoming and outgoing content slide during a swapvar(--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

Props for PreviewCardRoot
PropTypeDefaultDescription
actionsRefRefObject<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.
childrenReactNode | PayloadChildRenderFunction<Payload>The trigger and popup for this card. May also be a render function receiving `{ payload }` from the trigger that opened it.
defaultOpenbooleanfalseWhether the card is open when it first mounts. For a controlled card use `open` instead.
defaultTriggerIdstring | nullThe initially attached trigger id. Pairs with `defaultOpen` the way `triggerId` pairs with `open`.
handlePreviewCardHandle<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.
openbooleanWhether the card is currently open. Pass this together with `onOpenChange` to control the card.
triggerIdstring | nullWhich 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

Props for PreviewCardTrigger
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeDelaynumber300How 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.
delaynumber600How long the pointer must rest on the link before the card opens, in milliseconds. Also the wait before a focused link opens it.
handlePreviewCardHandle<Payload>Associates a detached trigger with the `PreviewCard.Root` carrying the same handle, created once outside render with `PreviewCard.createHandle()`.
hrefstringWhere 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.
idstringIdentifies the trigger. Also how `PreviewCard.Root`'s `triggerId` names the active trigger in controlled multi-trigger mode.
payloadPayloadData 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.
renderReactElement<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.

Props for PreviewCardPopup
PropTypeDefaultDescription
alignAligncenterHow the card lines up with the link along the chosen side.
alignOffsetnumber | OffsetFunction0Shifts the card along the alignment axis, in pixels, or a function returning one.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullThe element the card positions against, when it should not be the link. Accepts an element, a ref, a getter, or a virtual element.
arrowPaddingnumber5Minimum distance, in pixels, the arrow keeps from the card's corners before it is allowed to sit off-centre (`data-uncentered`).
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
collisionAvoidanceCollisionAvoidanceHow the card reacts when it would overflow the boundary — whether it flips, shifts, or stays put.
collisionBoundaryBoundaryclipping-ancestorsThe boundary the card tries to stay inside of.
collisionPaddingPadding5Space, in pixels, kept between the card and the collision boundary.
containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | nullWhere 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.
disableAnchorTrackingbooleanfalseStops the card re-measuring the anchor on scroll and resize. Cheaper, but the card drifts if the anchor moves.
keepMountedbooleanfalseKeeps 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).
positionerClassNamestringAdditional 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"absoluteWhether the card is positioned with `position: absolute` or `position: fixed`.
sideSidebottomWhich side of the link to place the card on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction.
sideOffsetnumber | OffsetFunction8Gap 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.
sizePreviewCardSizemdWidth 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.
stickybooleanfalseKeeps the card glued to the link while it scrolls out of view instead of letting it detach.

PreviewCard.Arrow

Props for PreviewCardArrow
PropTypeDefaultDescription
childrenReactNodeReplaces 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.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

PreviewCard.Viewport

Props for PreviewCardViewport
PropTypeDefaultDescription
childrenReactNodeThe content that changes from link to link.
classNamestringAdditional 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.