# Preview Card

> Preview a linked destination on hover or focus without changing the link's behavior.

Source: https://www.honestui.com/docs/components/preview-card

```tsx
import { CornerUpLeft as CornerUpLeftIcon, Star as StarIcon } from "honestui/icons"

import { Button } from "@/components/honest-ui/ui/button"
import {
  PreviewCard,
  PreviewCardPopup,
  PreviewCardTrigger,
} from "@/components/honest-ui/ui/preview-card"

export function PreviewCardDemo() {
  return (
    <PreviewCard>
      <PreviewCardTrigger render={<Button variant="ghost" />}>
        honestui/ui
      </PreviewCardTrigger>
      <PreviewCardPopup>
        <div className="flex flex-col gap-4">
          <div className="flex flex-col gap-1">
            <h4 className="text-sm font-medium">honestui/ui</h4>
            <p className="text-sm text-muted-foreground">
              Beautifully designed components that you can copy and
              paste into your apps.
            </p>
          </div>
          <div className="flex items-center gap-4 text-xs text-muted-foreground">
            <div className="flex items-center gap-1">
              <span
                className="size-2 rounded-full bg-blue-500"
                aria-hidden="true"
              ></span>
              <span>TypeScript</span>
            </div>
            <div className="flex items-center gap-1">
              <StarIcon className="size-3" />
              <span>58.2k</span>
            </div>
            <div className="flex items-center gap-1">
              <CornerUpLeftIcon className="size-3" />
              <span>5.1k</span>
            </div>
          </div>
        </div>
      </PreviewCardPopup>
    </PreviewCard>
  )
}

```

## Overview [#overview]

Use Preview Card to show a small summary when someone hovers or focuses a link: profiles, repositories, documents, issues, plans. The preview helps people decide whether the destination is worth opening — it never replaces the destination itself.

The component is honest about its role: the trigger is a real link and works exactly as it would without the card. Touch devices have no hover, so their users simply navigate; the preview is a progressive enhancement for pointer and keyboard contexts.

## Anatomy [#anatomy]

A preview card has two visible parts plus positioning internals:

* **PreviewCardTrigger** renders an `<a>` element — your link — and manages the hover/focus timing.
* **PreviewCardPopup** portals a positioned surface next to the trigger. It accepts `side` (`bottom` by default), `align` (`center`), and `sideOffset` (4 px), forwarding them to Base UI's positioner.
* **PreviewCardArrow** optionally connects the popup visually to its trigger.
* **PreviewCardViewport** is only needed when several triggers share one popup whose content swaps with an animated transition between them.

Inside the popup, content is yours: title, description, metadata, media. Keep it brief enough to scan in the moment before deciding to click.

## Timing and interaction [#timing-and-interaction]

The trigger waits 600 ms of continuous hover before opening (`delay`) and 300 ms after the pointer leaves before closing (`closeDelay`). The closeDelay lets people move from the link into the popup to read or copy text without it vanishing. Keyboard focus also waits for `delay`, so if you shorten it, remember you're shortening it for keyboard users too. <kbd>Escape</kbd> dismisses an open card, as does clicking elsewhere.

Both delays are tunable per trigger. Resist tuning the delay to zero: see [Don't do this](#dont-do-this).

## Accessibility [#accessibility]

Because the trigger is a genuine anchor, everything about links holds: it is reachable by <kbd>Tab</kbd>, announced by its text, activatable with <kbd>Enter</kbd>, and supports middle-click, context menus, and copy-link. Opening the card changes none of that — treat the popup as supplementary information layered over navigation.

The popup appears on focus too, so keyboard users get the same summary pointer users see (after the shared delay). Content inside the popup should be readable but nonessential: nothing there may be the only place a fact or action exists, because touch users and people who click straight through will never see it.

Colors come from theme tokens, so popups adapt to dark mode automatically. Positioning supports logical sides (`inline-start`, `inline-end`) alongside physical ones, and the arrow mirrors accordingly, so cards work in right-to-left locales. Long titles wrap inside the fixed-width popup rather than overflowing it.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;preview-card&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui/react
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/preview-card.tsx

```tsx
"use client"

import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"

import { cn } from "@/lib/utils"

const PreviewCard = PreviewCardPrimitive.Root

function PreviewCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
  return (
    <PreviewCardPrimitive.Trigger data-slot="preview-card-trigger" {...props} />
  )
}

function PreviewCardPopup({
  className,
  children,
  align = "center",
  side = "bottom",
  sideOffset = 4,
  ...props
}: PreviewCardPrimitive.Popup.Props & {
  align?: PreviewCardPrimitive.Positioner.Props["align"]
  side?: PreviewCardPrimitive.Positioner.Props["side"]
  sideOffset?: PreviewCardPrimitive.Positioner.Props["sideOffset"]
}) {
  return (
    <PreviewCardPrimitive.Portal>
      <PreviewCardPrimitive.Positioner
        data-slot="preview-card-positioner"
        className="z-[var(--hui-z-index-portal)] h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) [--animation-duration:var(--hui-duration-moderate)] [--easing:var(--hui-ease-out)] [transition:opacity_var(--animation-duration)_var(--easing)] motion-safe:has-[[data-slot=preview-card-viewport]]:[transition:top_var(--animation-duration)_var(--easing),left_var(--animation-duration)_var(--easing),right_var(--animation-duration)_var(--easing),bottom_var(--animation-duration)_var(--easing),opacity_var(--animation-duration)_var(--easing)]"
        sideOffset={sideOffset}
        align={align}
        side={side}
      >
        <PreviewCardPrimitive.Popup
          data-slot="preview-card-content"
          className={cn(
            "relative box-border h-[var(--popup-height,auto)] w-[var(--popup-width,auto)] origin-(--transform-origin) rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] p-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-primary)] shadow-[var(--hui-shadow-soft)] [transition:opacity_var(--animation-duration)_var(--easing)] data-ending-style:opacity-0 data-starting-style:opacity-0 motion-safe:[transition:opacity_var(--animation-duration)_var(--easing),transform_var(--animation-duration)_var(--easing)] motion-safe:data-ending-style:scale-90 motion-safe:data-starting-style:scale-90 motion-safe:has-[[data-slot=preview-card-viewport]]:[transition:width_var(--animation-duration)_var(--easing),height_var(--animation-duration)_var(--easing),opacity_var(--animation-duration)_var(--easing),transform_var(--animation-duration)_var(--easing)]",
            className
          )}
          {...props}
        >
          {children}
        </PreviewCardPrimitive.Popup>
      </PreviewCardPrimitive.Positioner>
    </PreviewCardPrimitive.Portal>
  )
}

function PreviewCardArrow({
  className,
  ...props
}: PreviewCardPrimitive.Arrow.Props) {
  return (
    <PreviewCardPrimitive.Arrow
      data-slot="preview-card-arrow"
      className={cn(
        "z-[var(--hui-z-index-portal)] [filter:drop-shadow(0_1px_0_var(--hui-color-border-base-primary))_drop-shadow(0_1px_1px_var(--hui-color-border-base-primary))] data-[side=top]:bottom-[-7px] data-[side=bottom]:top-0 data-[side=bottom]:-translate-y-full data-[side=bottom]:rotate-180 data-[side=left]:right-0 data-[side=left]:translate-x-full data-[side=left]:-translate-y-1/2 data-[side=left]:-rotate-90 data-[side=inline-start]:right-0 data-[side=inline-start]:translate-x-full data-[side=inline-start]:-translate-y-1/2 data-[side=inline-start]:-rotate-90 data-[side=right]:left-0 data-[side=right]:-translate-x-full data-[side=right]:-translate-y-1/2 data-[side=right]:rotate-90 data-[side=inline-end]:left-0 data-[side=inline-end]:-translate-x-full data-[side=inline-end]:-translate-y-1/2 data-[side=inline-end]:rotate-90 [&_svg]:text-[var(--hui-color-background-base-primary)]",
        className
      )}
      {...props}
    />
  )
}

function PreviewCardViewport({
  className,
  ...props
}: PreviewCardPrimitive.Viewport.Props) {
  return (
    <PreviewCardPrimitive.Viewport
      data-slot="preview-card-viewport"
      className={cn(
        "relative h-full w-full overflow-clip [&_[data-current]]:w-(--popup-width) [&_[data-current]]:translate-x-0 [&_[data-current]]:opacity-100 [&_[data-current]]:[transition:opacity_calc(var(--animation-duration)/2)_var(--easing)] [&_[data-previous]]:w-(--popup-width) [&_[data-previous]]:translate-x-0 [&_[data-previous]]:opacity-100 [&_[data-previous]]:[transition:opacity_calc(var(--animation-duration)/2)_var(--easing)] data-[activation-direction~=left]:[&_[data-current][data-starting-style]]:opacity-0 data-[activation-direction~=left]:[&_[data-previous][data-ending-style]]:opacity-0 data-[activation-direction~=right]:[&_[data-current][data-starting-style]]:opacity-0 data-[activation-direction~=right]:[&_[data-previous][data-ending-style]]:opacity-0 motion-safe:[&_[data-current]]:[transition:translate_var(--animation-duration)_var(--easing),opacity_calc(var(--animation-duration)/2)_var(--easing)] motion-safe:[&_[data-previous]]:[transition:translate_var(--animation-duration)_var(--easing),opacity_calc(var(--animation-duration)/2)_var(--easing)] motion-safe:data-[activation-direction~=left]:[&_[data-current][data-starting-style]]:[translate:-30%_0] motion-safe:data-[activation-direction~=left]:[&_[data-previous][data-ending-style]]:[translate:30%_0] motion-safe:data-[activation-direction~=right]:[&_[data-current][data-starting-style]]:[translate:30%_0] motion-safe:data-[activation-direction~=right]:[&_[data-previous][data-ending-style]]:[translate:-30%_0]",
        className
      )}
      {...props}
    />
  )
}

export {
  PreviewCard,
  PreviewCard as HoverCard,
  PreviewCardTrigger,
  PreviewCardTrigger as HoverCardTrigger,
  PreviewCardPopup,
  PreviewCardPopup as HoverCardContent,
  PreviewCardArrow,
  PreviewCardArrow as HoverCardArrow,
  PreviewCardViewport,
  PreviewCardViewport as HoverCardViewport,
}

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  PreviewCard,
  PreviewCardPopup,
  PreviewCardTrigger,
} from "@/components/ui/preview-card";
```

```tsx
<PreviewCard>
  <PreviewCardTrigger href="/projects/atlas">Atlas</PreviewCardTrigger>
  <PreviewCardPopup>
    Status, owner, and recent activity for Atlas
  </PreviewCardPopup>
</PreviewCard>
```

Give the trigger a real `href`; the preview is decoration on top of working navigation. `HoverCard`, `HoverCardTrigger`, and `HoverCardContent` remain available as aliases during migration.

## Don't do this [#dont-do-this]

### Hiding essential actions inside the popup [#hiding-essential-actions-inside-the-popup]

```tsx
// Bad
<PreviewCardPopup>
  <p>Aurora website</p>
  <Button onClick={deploy}>Deploy</Button>
</PreviewCardPopup>
```

```tsx
// Good
<PreviewCardPopup>
  <p>Last deployed 2 hours ago · production</p>
</PreviewCardPopup>
```

Touch users can't hover, hasty users click through before the popup renders, and the card closes while they're mid-task — so a Deploy button that lives only here will be missed, half-seen, or fired accidentally. Put facts and actions where the task lives; the popup carries context only.

### A preview trigger that doesn't navigate [#a-preview-trigger-that-doesnt-navigate]

```tsx
// Bad
<PreviewCardTrigger onClick={(e) => e.preventDefault()}>
  Atlas project
</PreviewCardTrigger>
```

```tsx
// Good
<PreviewCardTrigger href="/projects/atlas">
  Atlas project
</PreviewCardTrigger>
```

Once the trigger stops being a link, the whole contract breaks: keyboard users open a popup and then have nowhere to go, middle-click and copy-link stop working, and screen readers announce a dead end. If there's no destination worth linking, you wanted a Tooltip or Popover, not a Preview Card.

### Removing the hover delay in dense lists [#removing-the-hover-delay-in-dense-lists]

```tsx
// Bad
{rows.map((row) => (
  <PreviewCardTrigger key={row.id} href={row.href} delay={0}>
    {row.name}
  </PreviewCardTrigger>
))}
```

```tsx
// Good
<PreviewCardTrigger href={row.href} delay={600}>
  {row.name}
</PreviewCardTrigger>
```

Scanning a list means the pointer passes over many triggers. With no delay, every pass fires a popup and the page flickers through content nobody asked for — visual noise for pointer users and a barrage of announcements for screen-reader users sweeping the list. Keep the default delay wherever multiple previews sit near each other.

## Examples [#examples]

### Repository preview [#repository-preview]

Name, one-line description, and star count — enough to decide whether to open the repo.

```tsx
import { Star as StarIcon } from "honestui/icons"

import { Button } from "@/components/honest-ui/ui/button"
import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "@/components/honest-ui/ui/preview-card"

export function PreviewCardRepository() {
  return (
    <PreviewCard>
      <PreviewCardTrigger render={<Button variant="link" />}>openai/codex</PreviewCardTrigger>
      <PreviewCardPopup>
        <div className="grid gap-2">
          <div className="font-medium">openai/codex</div>
          <p className="text-sm text-muted-foreground">A coding agent that works in your terminal.</p>
          <div className="flex items-center gap-1 text-xs text-muted-foreground"><StarIcon className="size-3" /> 42k stars</div>
        </div>
      </PreviewCardPopup>
    </PreviewCard>
  )
}

```

### Profile preview [#profile-preview]

An avatar and role attached to a person's name anywhere it appears.

```tsx
import { Avatar, AvatarFallback, AvatarImage } from "@/components/honest-ui/ui/avatar"
import { Button } from "@/components/honest-ui/ui/button"
import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "@/components/honest-ui/ui/preview-card"

export function PreviewCardProfile() {
  return (
    <PreviewCard>
      <PreviewCardTrigger render={<Button variant="ghost" />}>Connor Love</PreviewCardTrigger>
      <PreviewCardPopup>
        <div className="flex gap-3">
          <Avatar><AvatarImage src="https://github.com/loveconnor" alt="Connor" /><AvatarFallback>CL</AvatarFallback></Avatar>
          <div><div className="font-medium">Connor Love</div><p className="text-sm text-muted-foreground">Designer & Developer</p></div>
        </div>
      </PreviewCardPopup>
    </PreviewCard>
  )
}

```

### Product preview [#product-preview]

Plan details surfaced next to the link that leads to billing.

```tsx
import { Button } from "@/components/honest-ui/ui/button"
import { Badge } from "@/components/honest-ui/ui/badge"
import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "@/components/honest-ui/ui/preview-card"

export function PreviewCardProduct() {
  return (
    <PreviewCard>
      <PreviewCardTrigger render={<Button variant="secondary" />}>View plan</PreviewCardTrigger>
      <PreviewCardPopup>
        <div className="grid gap-2">
          <Badge className="w-fit">Pro</Badge>
          <div className="font-medium">Automation plan</div>
          <p className="text-sm text-muted-foreground">Unlimited workflows with 30-day history.</p>
        </div>
      </PreviewCardPopup>
    </PreviewCard>
  )
}

```

## API reference [#api-reference]

| Part                  | Renders                       | Notes                                                                                          |
| --------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------- |
| `PreviewCard`         | none (context root)           | Base UI Root props: `open`/`defaultOpen`, `onOpenChange`, `actionsRef`, `handle`               |
| `PreviewCardTrigger`  | `<a>`                         | `href` plus native anchor props; `delay` (600 ms) and `closeDelay` (300 ms)                    |
| `PreviewCardPopup`    | positioned `<div>` via portal | Adds `side` (`"bottom"`), `align` (`"center"`), `sideOffset` (`4`) forwarded to the positioner |
| `PreviewCardArrow`    | SVG arrow                     | Optional; mirrors for logical sides                                                            |
| `PreviewCardViewport` | `<div>`                       | Only for multiple triggers sharing one animated popup                                          |

The trigger's payload prop and the root's render-function children let one shared popup render different summaries per trigger — pair that with `PreviewCardViewport`. `HoverCard*` names are aliases for compatibility.

See the [Base UI Preview Card API](https://base-ui.com/react/components/preview-card#api-reference).
