Qure UI
Components

Preview Card

A card that appears when a link is hovered, so a reader can see where it goes without going there.

Findings were compared against the prior chest radiograph of 14 March, which showed no consolidation.

'use client'import {  PreviewCard, PreviewCardBody, PreviewCardContent, PreviewCardDescription,  PreviewCardMeta, PreviewCardTitle, PreviewCardTrigger,} from '@/registry/qure/ui/preview-card'export default function PreviewCardDemo() {  return (    <p className="max-w-sm text-sm leading-6">      Findings were compared against the prior{' '}      <PreviewCard>        <PreviewCardTrigger href="#">chest radiograph of 14 March</PreviewCardTrigger>        <PreviewCardContent>          <PreviewCardBody>            <PreviewCardTitle>CR Chest PA — 14 March 2026</PreviewCardTitle>            <PreviewCardDescription>              Reported as clear. No consolidation, effusion or pneumothorax. Cardiomediastinal              contours within normal limits.            </PreviewCardDescription>            <PreviewCardMeta>              <dt>Accession</dt>              <dd className="font-mono text-xs">ACC-4409123</dd>              <dt>Reported by</dt>              <dd>Dr A Ramanathan</dd>            </PreviewCardMeta>          </PreviewCardBody>        </PreviewCardContent>      </PreviewCard>      , which showed no consolidation.    </p>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/preview-card.json

Usage

import {
  PreviewCard, PreviewCardBody, PreviewCardContent,
  PreviewCardDescription, PreviewCardMeta, PreviewCardTitle, PreviewCardTrigger,
} from '@/components/ui/preview-card'
<PreviewCard>
  <PreviewCardTrigger href="/studies/ACC-4409123">chest radiograph of 14 March</PreviewCardTrigger>
  <PreviewCardContent>
    <PreviewCardBody>
      <PreviewCardTitle>CR Chest PA — 14 March 2026</PreviewCardTitle>
      <PreviewCardDescription>Reported as clear.</PreviewCardDescription>
    </PreviewCardBody>
  </PreviewCardContent>
</PreviewCard>

What it is for, and what it is not

It answers "what is this?" without spending a navigation — the patient behind a name in a worklist, the study behind an accession number, the guideline behind a citation.

It is not a Tooltip. A tooltip names a control in a few words, cannot be entered with the pointer, and holds no links. This holds a card's worth of content, stays open while you move onto it, and can contain links of its own.

It is not a Popover either: a popover opens on click and can hold controls a user must operate. A preview card opens on hover, which means it also opens by accident.

Everything in a preview card must be reachable another way. It never appears on a touch screen, it never appears for a user who navigates by voice, and a keyboard user only gets it by focusing the link. Content that is only in the card is content most of your users cannot see.

Composition

<PreviewCard>            {/* owns open state; optionally a handle */}
  <PreviewCardTrigger /> {/* an <a>. delay and closeDelay live here */}
  <PreviewCardContent>   {/* portal + positioner + popup */}
    <PreviewCardMedia /> {/* optional full-bleed strip: a key image */}
    <PreviewCardBody>
      <PreviewCardTitle />
      <PreviewCardDescription />
      <PreviewCardMeta /> {/* a <dl> of accession, modality, referrer */}
    </PreviewCardBody>
  </PreviewCardContent>
</PreviewCard>

The popup is not clipped, because the arrow is one of its children and an overflow on the popup would eat it. PreviewCardMedia and PreviewCardBody do their own clipping instead.

A patient summary

'use client'import { Avatar, AvatarFallback } from '@/registry/qure/ui/avatar'import { Badge } from '@/registry/qure/ui/badge'import {  PreviewCard, PreviewCardBody, PreviewCardContent, PreviewCardDescription,  PreviewCardMeta, PreviewCardTitle, PreviewCardTrigger,} from '@/registry/qure/ui/preview-card'/** * The card can hold other components — a badge for acuity, an avatar for the * reader. Everything in it is also on the study page; this only saves the * trip. */export default function PreviewCardPatient() {  return (    <div className="text-sm">      <PreviewCard>        <PreviewCardTrigger href="#">Sunita Deshmukh</PreviewCardTrigger>        <PreviewCardContent side="bottom" showArrow>          <PreviewCardBody>            <div className="flex items-start justify-between gap-3">              <PreviewCardTitle>Sunita Deshmukh</PreviewCardTitle>              <Badge tone="urgent">Lung-RADS 4B</Badge>            </div>            <PreviewCardDescription>              62F, ex-smoker. 11mm spiculated nodule, right upper lobe, up from 8mm on the              September study. Flagged for MDT.            </PreviewCardDescription>            <PreviewCardMeta>              <dt>MRN</dt>              <dd className="font-mono text-xs">MRN-882014</dd>              <dt>Modality</dt>              <dd>CT Thorax</dd>              <dt>Assigned</dt>              <dd className="flex items-center gap-2">                <Avatar className="size-5">                  <AvatarFallback>RP</AvatarFallback>                </Avatar>                Dr R Patel              </dd>            </PreviewCardMeta>          </PreviewCardBody>        </PreviewCardContent>      </PreviewCard>    </div>  )}

The card takes other components — a Badge for acuity, an Avatar for the assigned reader. Keep it to what a reader needs in order to decide whether to open the study; a preview that has to be scrolled has stopped being a preview.

One card for a whole worklist

'use client'import {  createPreviewCardHandle, PreviewCard, PreviewCardBody, PreviewCardContent,  PreviewCardDescription, PreviewCardMeta, PreviewCardTitle, PreviewCardTrigger,} from '@/registry/qure/ui/preview-card'interface Study {  accession: string  patient: string  summary: string  modality: string  referrer: string}const rows: Study[] = [  {    accession: 'ACC-4471902',    patient: 'Sunita Deshmukh',    summary: '11mm spiculated nodule, right upper lobe. Up from 8mm in September.',    modality: 'CT Thorax',    referrer: 'Respiratory clinic',  },  {    accession: 'ACC-4471887',    patient: 'Michael Okafor',    summary: 'Left basal consolidation with a small parapneumonic effusion.',    modality: 'CR Chest PA',    referrer: 'A&E',  },  {    accession: 'ACC-4471863',    patient: 'Aditi Rao',    summary: 'No prior imaging on record. Screening study, awaiting first read.',    modality: 'CT Thorax LD',    referrer: 'Lung health check',  },]/** * One card for the whole table. Each trigger carries its row as `payload`, * and the root renders from whichever trigger opened it — a card per row * would mount thirty popups to show one. */const studyCard = createPreviewCardHandle<Study>()export default function PreviewCardWorklist() {  return (    <div className="w-full max-w-md">      <ul className="divide-y text-sm">        {rows.map((row) => (          <li key={row.accession} className="flex items-center justify-between gap-4 py-2.5">            <PreviewCardTrigger handle={studyCard} payload={row} href="#">              {row.patient}            </PreviewCardTrigger>            <span className="font-mono text-xs text-muted-foreground">{row.accession}</span>          </li>        ))}      </ul>      <PreviewCard handle={studyCard}>        {/* Base UI hands the render prop `{ payload }`, not the payload            itself — the object leaves room for future state alongside it. */}        {({ payload: study }) =>          study ? (            <PreviewCardContent side="right" align="start">              <PreviewCardBody>                <PreviewCardTitle>{study.patient}</PreviewCardTitle>                <PreviewCardDescription>{study.summary}</PreviewCardDescription>                <PreviewCardMeta>                  <dt>Accession</dt>                  <dd className="font-mono text-xs">{study.accession}</dd>                  <dt>Modality</dt>                  <dd>{study.modality}</dd>                  <dt>Referrer</dt>                  <dd>{study.referrer}</dd>                </PreviewCardMeta>              </PreviewCardBody>            </PreviewCardContent>          ) : null        }      </PreviewCard>    </div>  )}

Thirty rows should not mount thirty popups. createPreviewCardHandle() makes a handle; each trigger passes its row as payload, and one root renders from whichever trigger opened it by taking a function as its children.

The payload is typed — createPreviewCardHandle<Study>() — and arrives as undefined before any trigger has been used, so the render function has to handle that case.

Placement

'use client'import {  PreviewCard, PreviewCardBody, PreviewCardContent, PreviewCardDescription,  PreviewCardTitle, PreviewCardTrigger,} from '@/registry/qure/ui/preview-card'const sides = ['top', 'right', 'bottom', 'left'] as const/** * `side` is a preference, not a promise — Base UI flips the card to the * opposite side when there is not room, which is why the arrow reads its own * placement off `data-side` rather than being told where to point. */export default function PreviewCardSide() {  return (    <div className="flex flex-wrap items-center justify-center gap-6 text-sm">      {sides.map((side) => (        <PreviewCard key={side}>          <PreviewCardTrigger href="#">Opens {side}</PreviewCardTrigger>          <PreviewCardContent side={side} showArrow>            <PreviewCardBody>              <PreviewCardTitle>Lung-RADS 4B</PreviewCardTitle>              <PreviewCardDescription>                Findings with a 5–15% likelihood of malignancy. Chest CT with or without contrast,                PET/CT, or tissue sampling.              </PreviewCardDescription>            </PreviewCardBody>          </PreviewCardContent>        </PreviewCard>      ))}    </div>  )}

side is a preference rather than a promise: Base UI flips the card when there is no room, which is why the arrow reads its own placement off data-side rather than being told where to point. showArrow is off by default — with an 8px offset the card already reads as belonging to the link, and an arrow adds a second thing pointing at it.

Delays

'use client'import {  PreviewCard, PreviewCardBody, PreviewCardContent, PreviewCardDescription,  PreviewCardTitle, PreviewCardTrigger,} from '@/registry/qure/ui/preview-card'/** * `delay` and `closeDelay` are Trigger props in Base UI, not Root props. * The defaults are 600ms and 300ms; the second link shortens the open to * 150ms so that reading down a paragraph of links throws cards at you. */export default function PreviewCardDelay() {  return (    <div className="flex max-w-sm flex-col gap-4 text-sm">      <PreviewCard>        <PreviewCardTrigger href="#">Default — 600ms to open</PreviewCardTrigger>        <PreviewCardContent>          <PreviewCardBody>            <PreviewCardTitle>Fleischner Society, 2017</PreviewCardTitle>            <PreviewCardDescription>              Guidelines for the management of incidental pulmonary nodules detected on CT.            </PreviewCardDescription>          </PreviewCardBody>        </PreviewCardContent>      </PreviewCard>      <PreviewCard>        <PreviewCardTrigger href="#" delay={150} closeDelay={100}>          Eager — 150ms, and closes fast        </PreviewCardTrigger>        <PreviewCardContent>          <PreviewCardBody>            <PreviewCardTitle>Fleischner Society, 2017</PreviewCardTitle>            <PreviewCardDescription>              Sweep the pointer across this one and the card fires on the way past. That is the              argument for the longer default.            </PreviewCardDescription>          </PreviewCardBody>        </PreviewCardContent>      </PreviewCard>    </div>  )}

delay and closeDelay are props on PreviewCardTrigger, not on the root — unlike Tooltip, where they sit on the provider. The defaults are 600ms and 300ms.

600ms is long on purpose. A card that fires at 150ms turns a paragraph of links into a flicker show as the pointer crosses it, and the closing delay is what lets you move diagonally from the link onto the card without it vanishing on the way.

Keyboard

Tab to the link and the card opens on focus; Escape closes it. It does not trap focus and it is not a dialog — links inside it are reached by tabbing on, and it stays open while they are focused.

API Reference

Everything Base UI's Preview Card accepts.

On PreviewCardTrigger:

Prop

Type

On PreviewCardContent — the positioning props are forwarded to Base UI's Positioner:

Prop

Type

On this page