Preview Card
A popup that previews a link destination on hover.
Codeapp/demos/public/preview-card.tsx
import { clientEntry, type Handle } from 'remix/component'
import { Avatar, AvatarFallback, AvatarImage } from '@stemsui/registry/ui/avatar.tsx'
import { buttonVariants } from '@stemsui/registry/ui/button.tsx'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@stemsui/registry/ui/preview-card.tsx'
function CalendarIcon() {
return () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="size-4 opacity-70"
>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</svg>
)
}
export const PreviewCardDemo = clientEntry(
import.meta.url,
function PreviewCardDemo(handle: Handle) {
let lastChange = 'none'
return () => (
<div class="flex flex-col items-center gap-10">
<PreviewCard
onOpenChange={(open, details) => {
lastChange = `${open ? 'opened' : 'closed'} (${details.reason})`
void handle.update()
}}
>
<PreviewCardTrigger
href="https://github.com/remix-run"
class={buttonVariants({ variant: 'link' })}
data-testid="remix-link"
>
@remix-run
</PreviewCardTrigger>
<PreviewCardContent class="w-80">
<div class="flex justify-between gap-4">
<Avatar>
<AvatarImage src="https://github.com/remix-run.png" alt="@remix-run" />
<AvatarFallback>RR</AvatarFallback>
</Avatar>
<div class="flex flex-col gap-1">
<h4 class="text-sm font-semibold">@remix-run</h4>
<p class="text-sm">
Remix 3 – build better websites with web standards, created and maintained by
@shopify.
</p>
<div class="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<CalendarIcon />
<span>Open source since 2021</span>
</div>
</div>
</div>
</PreviewCardContent>
</PreviewCard>
{/* A <div>, not a <p>: the card is a <div> and can't live inside a paragraph. */}
<div class="max-w-md text-center text-sm text-muted-foreground">
Stems UI components are built on{' '}
<PreviewCard>
<PreviewCardTrigger
href="https://github.com/remix-run/remix"
class="font-medium text-foreground underline underline-offset-4"
delay={300}
data-testid="mixins-link"
>
Remix mixins
</PreviewCardTrigger>
<PreviewCardContent side="top" class="w-64 text-left">
<p class="text-sm">
<strong>Mixins</strong> attach behavior to elements you render yourself:{' '}
<code class="rounded bg-muted px-1 font-mono text-xs whitespace-nowrap">
mix={'{'}tooltip.trigger(){'}'}
</code>
.
</p>
</PreviewCardContent>
</PreviewCard>
, so you own every element.
</div>
<p class="text-sm text-muted-foreground" data-testid="preview-card-result">
Last change: <span class="font-medium text-foreground">{lastChange}</span>
</p>
</div>
)
},
)
Installation
npx shadcn add @stemsui/preview-cardRequires the one-time setup. Behavior: stems-ui/preview-card.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/preview-card
preview-card shows a preview of a link's destination when the link is hovered or focused with
the keyboard. The card opens in the top layer after a delay, can be hovered, and never takes
focus. App code owns the markup and visual styles.
Base UI equivalent: Preview Card (shadcn/ui calls it Hover Card).
Usage
import * as previewCard from 'stems-ui/preview-card'
function Mention() {
return () => (
<previewCard.Context>
<a href="https://github.com/remix-run" mix={previewCard.trigger()}>
@remix-run
</a>
<div
class="w-64 rounded-lg bg-white p-4 shadow-md transition-[opacity,scale] origin-(--transform-origin) data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0"
mix={previewCard.popup({ sideOffset: 8 })}
>
Remix – build better websites.
</div>
</previewCard.Context>
)
}
The card is a visual enhancement for sighted mouse and keyboard users: screen readers only see the link, and the card doesn't open on touch. Keep everything in it available on the linked page.
Control the state with open and onOpenChange(open, details), or let the context own it with
defaultOpen. Listen for bubbling changes on the trigger or an ancestor:
<div mix={previewCard.onPreviewCardChange((event) => console.log(event.open, event.reason))}>…</div>
Differences from Base UI
- No Portal, no Positioner element. The popup is shown in the top layer with
popover="manual"+showPopover(). Base UI's Positioner props are options ofpopup(), and the positioner's data attributes and CSS variables are set on the popup element. - Render the card outside inline text containers like
<p>: the popup is usually a<div>, which can't be a child of a paragraph (the HTML parser would move it). Use a<div>or<span>wrapper. - Because the card is rendered where you put it (not portaled to the end of
<body>), links inside it follow the trigger in Tab order while it's open. - The Viewport part,
actionsRef, inline-rect anchoring for wrapped links,--positioner-*/--popup-*variables,data-anchor-hidden,collisionAvoidance,collisionBoundary,sticky,positionMethod, anddisableAnchorTrackingare not supported. - The popup element always stays in the DOM (closed popups get
hidden).
stems-ui/preview-card
Context: provider that owns the open state (Base UI'sPreviewCard.Root). Read it in components withhandle.context.get(previewCard.Context)(open,openReason,setOpen(open, { reason }),activeTrigger,side,align, …).open:boolean. Controlled open state.defaultOpen:boolean. Initial uncontrolled state. Defaults tofalse.onOpenChange(open, details): called before the state changes.detailshasreason,event,trigger, andcancel()(keeps the current state).onOpenChangeComplete(open): called after the enter or exit animation finished.
trigger(options?): the link (<a href>). Rendersdata-popup-open; doesn't change the link's role, ARIA, or press behavior.delay:number. How long to wait before opening on hover or keyboard focus, in ms. Defaults to600.closeDelay:number. How long to wait before closing after the pointer leaves, in ms. Defaults to300.
popup(options?): the card (Base UI's Positioner + Popup). Setsid,popover="manual",tabIndex={-1},hiddenwhile closed, and the attributes and variables below. No role (Base UI doesn't announce it). Never moves focus.side:'top' | 'bottom' | 'left' | 'right' | 'inline-start' | 'inline-end'. Defaults to'bottom'. Flips to the opposite side when it doesn't fit.align:'start' | 'center' | 'end'. Defaults to'center'.sideOffset,alignOffset:number | (data) => number. Default0.collisionPadding:number | { top, right, bottom, left }. Defaults to5.arrowPadding:number. Defaults to5.anchor:HTMLElement | { x, y, width?, height? } | () => ….
arrow(): an element inside the popup that points at the anchor (inlineposition: absoluteplusleftortop; addoverflow: visibleto the popup).backdrop(): an optional overlay shown in the top layer beneath the card (popover="manual",role="presentation"). Style it to cover the viewport.onPreviewCardChange(handler): listener mixin forPreviewCardChangeEvent(stems:preview-card-change, bubbles from the active trigger, hasopenandreason).
Change reasons
'trigger-hover', 'trigger-focus' (keyboard focus and blur), 'outside-press',
'escape-key', 'focus-out' (focus left the card), and 'none' (state changed through the
controlled open prop).
Detached triggers
Base UI's createHandle(): triggers can live anywhere in the document, outside the Context,
linked by a handle. Each trigger can pass a payload, so one popup can serve many triggers with
different data.
import * as previewCard from 'stems-ui/preview-card'
let people = previewCard.createHandle<{ name: string }>()
// Anywhere, e.g. in a table row:
<button mix={previewCard.trigger({ handle: people, payload: { name: 'Ada' } })}>Ada</button>
<button mix={previewCard.trigger({ handle: people, payload: { name: 'Grace' } })}>Grace</button>
// Elsewhere:
<previewCard.Context handle={people}>
{({ payload }) => <div mix={previewCard.popup()}>{payload?.name}</div>}
</previewCard.Context>
createHandle<Payload>()returns the handle. Pass it to theContext(handleprop, read once) and to each detachedtrigger({ handle, payload })(read on the trigger's first render).Payloadtypespayloadin theContext's children function.- Detached triggers register with the handle, so they behave like triggers inside the
Context:data-popup-openfollows the card, hover and focus open it from any trigger, the popup anchors to the active trigger, focus returns to it, and a press on another trigger counts as inside (it moves the popup instead of dismissing it). - The active trigger's
payloadis{ payload }in a children render function (re-run each time theContextrenders) andpayloadon the context value (handle.context.get(previewCard.Context).payload). It'sundefineduntil a trigger has been active, and stays while the popup animates out. A children function only works with JSX (createElementrejects function children).payloadalso works on triggers inside theContext. - Handle methods:
isOpen,open(triggerId?)(opens, or moves an open popup, to the trigger with thatid; reason'imperative-action'),close(). They act on the mountedContextand are ignored while none is mounted. A remountedContextstarts fresh. ContextpropstriggerId(controlled, pair withopen; storedetails.trigger?.idfromonOpenChange) anddefaultTriggerIdpick the active trigger by elementid.- Detached triggers aren't re-rendered by the
Context; they update their state attributes directly when it renders. On the server nothing is shared (a handle is usually a module-level singleton), so a detached trigger renders closed and withoutaria-controls; it getsaria-controlsonce theContextmounts in the browser.
Behavior notes
- Hover: opens after
delay(mouse and pen only, not touch). Leaving the link closes the card aftercloseDelay, unless the pointer moves onto the card (or toward it, via hover-aim). - Keyboard focus: opens after
delaywhen the link matches:focus-visible(Tab), not when a pointer press focused it. Blur closes it unless focus moved into the card. - Press: pressing the link follows it; the card doesn't toggle.
- Escape closes the card through the shared dismiss-layer stack and stops the event, so a card inside a popover or dialog closes without closing its container. An outside press closes it too.
- Focus never moves into or out of the card.
Styling hooks
| Part | Attribute | Values / notes |
|---|---|---|
| trigger | data-popup-open | present while open (on the trigger that opened it) |
| popup | data-open | present when open |
| popup | data-closed | present when closed (also while animating out) |
| popup | data-side | final side: top | bottom | left | right | inline-* |
| popup | data-align | start | center | end |
| popup | data-starting-style | present on the first frame of opening |
| popup | data-ending-style | present while animating out |
| popup | hidden | present when closed and not animating |
| arrow | data-side | same as the popup |
| arrow | data-align | same as the popup |
| arrow | data-uncentered | present when the arrow can't point at the anchor's center |
| arrow | data-open | present when open |
| arrow | data-closed | present when closed |
| backdrop | data-open | present when open |
| backdrop | data-closed | present when closed |
| backdrop | data-starting-style | present on the first frame of opening |
| backdrop | data-ending-style | present while animating out |
| Part | CSS variable | Value |
|---|---|---|
| popup | --anchor-width | the anchor's width in px |
| popup | --anchor-height | the anchor's height in px |
| popup | --available-width | space available on the final side, in px |
| popup | --available-height | space available on the final side, in px |
| popup | --transform-origin | the anchor point relative to the popup (for scale/zoom-in) |
