# Preview Card

Preview content in a card layout, built on Base UI.

```tsx title="components/ui/preview-card.tsx"
"use client";

import { PreviewCard } from "@base-ui/react/preview-card";
import type { CSSProperties, ReactNode } from "react";
import { merge } from "yummacss/merge";

type Shape = "rounded" | "square" | "squircle";
type Shadow = "none" | "inset" | "outset";

const FOCUS = "fv:os:s fv:ow:3 fv:oo:0 fv:oc:slate-4/60 fv:bc:slate-6";

const SHAPES: Record<Shape, string> = {
  rounded: "br:lg",
  square: "",
  squircle: "br:xxl cs:s",
};

const SHADOWS: Record<Exclude<Shadow, "none">, string> = {
  inset: "bs-i:3xl",
  outset: "bs-o:sm",
};

const ARROW_PLACEMENT: Record<string, CSSProperties> = {
  top: { bottom: -6, rotate: "180deg" },
  bottom: { top: -6 },
  left: { right: -9, rotate: "90deg" },
  right: { left: -9, rotate: "-90deg" },
  "inline-start": { right: -9, rotate: "90deg" },
  "inline-end": { left: -9, rotate: "-90deg" },
};

const ARROW_HEIGHT = 6;

export interface PreviewCardProps {
  /**
   * Where the popup is rendered. Defaults to `document.body`, which is right
   * almost always; pass an element to portal somewhere else, such as inside a
   * frame or a container that owns its own stacking context.
   */
  container?: HTMLElement | null;
  /**
   * A pointer notched into the card's edge, aimed back at the trigger. It
   * re-aims itself when the card flips to fit.
   */
  arrow?: boolean;
  /** The inline content that opens the card on hover or focus. */
  trigger: ReactNode;
  /** The card's contents. */
  children: ReactNode;
  /**
   * Starting state when you are not controlling it. Pair `open` with
   * `onOpenChange` instead if you are.
   */
  defaultOpen?: boolean;
  /** Controlled state. */
  open?: boolean;
  /** Called with the new state. Required for a controlled preview card. */
  onOpenChange?: (open: boolean) => void;
  /**
   * Corner radius on the card. `squircle` uses `corner-shape`, which degrades
   * to a rounded square where that is unsupported.
   */
  shape?: Shape;
  /**
   * Depth on the card. `inset` reads as a well, `outset` as a raised control.
   */
  shadow?: Shadow;
  /**
   * The card's fade in/out. Turn it off for an instant appearance, or when the
   * user has asked for reduced motion.
   */
  animated?: boolean;
  /**
   * Extra classes. `merge` folds them in last, so one here replaces the
   * component's own class for the same utility.
   */
  className?: string;
  /**
   * The focus outline. `true` draws it, `false` removes it along with the
   * danger, error and success tints that ride with it, and a string of Yumma
   * CSS utilities restyles it on every focusable part of the component, which
   * is more than `className` reaches. Removing it outright and putting nothing
   * back fails WCAG 2.4.7.
   */
  focus?: boolean | string;
}

/** A hover-triggered card, in three shapes with an optional shadow. */
export default function PreviewCardBase({
  arrow = true,
  trigger,
  children,
  defaultOpen,
  open,
  onOpenChange,
  shape = "rounded",
  shadow = "none",
  animated = true,
  className,
  focus = true,
  container,
}: PreviewCardProps) {
  const popupClasses = [
    "d:f fd:c g:3 w:64 p:3 bg:white bc:silver-2 bw:1 c:slate-10 fs:sm",
    SHAPES[shape],
    shadow === "inset" || shadow === "outset" ? SHADOWS[shadow] : "",
    arrow ? "p:r" : "",
  ]
    .filter(Boolean)
    .join(" ");

  const outline = focus ? merge(FOCUS, focus === true ? "" : focus) : "";

  return (
    <PreviewCard.Root
      defaultOpen={defaultOpen}
      open={open}
      onOpenChange={onOpenChange}
    >
      <PreviewCard.Trigger
        className={(state) =>
          merge(
            outline,
            "c:blue c:p fw:500 td:none h:td:u",
            state.open ? "td:u" : "",
            className,
          )
        }
      >
        {trigger}
      </PreviewCard.Trigger>

      <PreviewCard.Portal container={container}>
        <PreviewCard.Positioner sideOffset={8 + (arrow ? ARROW_HEIGHT : 0)}>
          <PreviewCard.Popup
            className={`${popupClasses} ${animated ? "tp:o tdu:150 ttf:eo opening:o:0 closing:o:0 @prm:tp:none" : ""}`}
          >
            {arrow && (
              <PreviewCard.Arrow
                className="d:f"
                style={(state) => ARROW_PLACEMENT[state.side]}
              >
                <svg viewBox="0 0 10 5" width="12" height="6">
                  <title>Arrow</title>
                  <path
                    d="M0 5 L5 0 L10 5"
                    strokeWidth="1"
                    className="f:white s:silver-2"
                  />
                </svg>
              </PreviewCard.Arrow>
            )}
            {children}
          </PreviewCard.Popup>
        </PreviewCard.Positioner>
      </PreviewCard.Portal>
    </PreviewCard.Root>
  );
}
```

A hover-triggered card, in three shapes with an optional shadow.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `arrow` | `boolean` | `true` | A pointer notched into the card's edge, aimed back at the trigger. It re-aims itself when the card flips to fit. |
| `trigger` | `ReactNode` | - | The inline content that opens the card on hover or focus. |
| `defaultOpen` | `boolean` | `false` | Starting state when you are not controlling it. Pair `open` with `onOpenChange` instead if you are. |
| `open` | `boolean` | - | Controlled state. |
| `onOpenChange` | `(open: boolean) => void` | - | Called with the new state. Required for a controlled preview card. |
| `shape` | `"rounded"` \| `"square"` \| `"squircle"` | `"rounded"` | Corner radius on the card. `squircle` uses `corner-shape`, which degrades to a rounded square where that is unsupported. |
| `shadow` | `"none"` \| `"inset"` \| `"outset"` | `"none"` | Depth on the card. `inset` reads as a well, `outset` as a raised control. |
| `animated` | `boolean` | `true` | The card's fade in/out. Turn it off for an instant appearance, or when the user has asked for reduced motion. |
| `focus` | `boolean \| string` | `true` | The focus outline. `true` draws it, `false` removes it along with the danger, error and success tints that ride with it, and a string of Yumma CSS utilities restyles it on every focusable part of the component, which is more than `className` reaches. Removing it outright and putting nothing back fails WCAG 2.4.7. |
| `container` | `HTMLElement \| null` | - | Where the popup is rendered. Defaults to `document.body`, which is right almost always; pass an element to portal somewhere else, such as inside a frame or a container that owns its own stacking context. |