# Expand Card

> A card that opens into a full article, growing from its place in the page to fill the screen, and shrinks back into its slot when you pull it down, like the App Store's Today tab.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/expand-card
- Tags: Card, Gesture, Motion
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/expand-card.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/expand-card
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/expand-card.json
```

## Usage

The main demo shows typical usage.

### Today Feed

Three stories in different styles: a full-bleed photo, a top-five list and a bold card on a gradient. Tap one, then pull it down from the top, or press Esc, and it lands back in its slot.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/expand-card-demo
```

```tsx title="components/expand-card-demo.tsx"
import { ExpandCard } from "@/components/ui/expand-card";

const image = (id: string, width: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=max&q=80&w=${width}`;

const article = "bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50";

export function ExpandCardDemo() {
  return (
    <div className="w-full max-w-3xl">
      <header className="mb-5 flex items-end justify-between gap-4 px-1">
        <div>
          <p className="text-[13px] font-semibold tracking-wide text-zinc-500 uppercase">
            Thursday, October 9
          </p>
          <h2 className="mt-0.5 text-[32px] leading-none font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
            Today
          </h2>
        </div>
        <span
          aria-hidden
          className="size-9 rounded-full bg-linear-to-br from-sky-400 to-indigo-600 ring-2 ring-white dark:ring-zinc-900"
        />
      </header>

      <div className="grid gap-5 sm:auto-rows-[19rem] sm:grid-cols-2">
        <ExpandCard
          className="h-[28rem] rounded-[28px] shadow-[0_12px_32px_-12px_rgb(0_0_0/0.45)] sm:row-span-2 sm:h-auto"
          articleClassName={article}
          cover={<StoryCover />}
        >
          <StoryBody />
        </ExpandCard>
        <ExpandCard
          className="h-[19rem] rounded-[28px] bg-white shadow-[0_12px_32px_-16px_rgb(0_0_0/0.25)] ring-1 ring-zinc-900/5 sm:h-auto dark:bg-zinc-900 dark:ring-white/10"
          articleClassName={article}
          cover={<FocusCover />}
        >
          <FocusBody />
        </ExpandCard>
        <ExpandCard
          className="h-[19rem] rounded-[28px] shadow-[0_12px_32px_-12px_rgb(225_29_72/0.45)] sm:h-auto"
          articleClassName={article}
          cover={<TipsCover />}
        >
          <TipsBody />
        </ExpandCard>
      </div>
    </div>
  );
}

// ── App of the day: a full-bleed photo story ──────────────

function StoryCover() {
  return (
    <div className="relative size-full bg-zinc-950 text-white">
      <img
        src={image("1519681393784-d120267933ba", 1600)}
        alt=""
        draggable={false}
        className="absolute inset-0 size-full object-cover"
      />
      <div aria-hidden className="absolute inset-x-0 top-0 h-1/2 bg-linear-to-b from-black/60 to-transparent" />
      <div className="relative p-6">
        <p className="text-[13px] font-semibold tracking-wide text-white/70 uppercase">App of the day</p>
        <h3 className="mt-1 max-w-[16.5rem] text-[28px] leading-[1.1] font-bold tracking-tight">
          Nocturne: the night camera pros keep in their pocket
        </h3>
      </div>
      <div className="absolute inset-x-0 bottom-0 flex items-center gap-3 bg-black/30 px-5 py-4 backdrop-blur-xl">
        <AppIcon className="from-indigo-500 to-slate-900" path={MOON} />
        <div className="min-w-0 flex-1">
          <p className="truncate text-[15px] font-semibold">Nocturne</p>
          <p className="truncate text-[13px] text-white/65">Long exposures, handheld</p>
        </div>
        <span className="rounded-full bg-white/20 px-5 py-1.5 text-[15px] font-bold">Get</span>
      </div>
    </div>
  );
}

function StoryBody() {
  return (
    <div className="mx-auto max-w-[36rem] px-6 pt-8 pb-14 text-[17px] leading-relaxed text-zinc-600 sm:px-8 dark:text-zinc-400">
      <p className="text-zinc-900 dark:text-zinc-100">
        Most phone cameras give up when the sun goes down. Nocturne was built for exactly that
        moment: it holds the shutter open for up to thirty seconds, steadies every frame against
        your hands, and stacks them into one clean exposure.
      </p>
      <h4 className="mt-9 text-[22px] leading-tight font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
        Thirty seconds, handheld
      </h4>
      <p className="mt-3">
        Point it at the sky and Nocturne quietly takes a few hundred short frames. It lines them
        up on the stars rather than the horizon, so the Milky Way comes out sharp even as the
        ground drifts. It all happens on the phone, in about the time it takes to lower your arms.
      </p>
      <dl className="my-9 grid grid-cols-3 divide-x divide-zinc-200 rounded-2xl bg-zinc-100 py-4 text-center dark:divide-zinc-800 dark:bg-zinc-900">
        {[
          ["30s", "Longest exposure"],
          ["RAW", "Every frame kept"],
          ["4.9", "12K ratings"],
        ].map(([value, label]) => (
          <div key={label} className="px-2">
            <dt className="sr-only">{label}</dt>
            <dd className="text-[22px] font-bold text-zinc-900 dark:text-zinc-50">{value}</dd>
            <dd className="text-[12px] text-zinc-500">{label}</dd>
          </div>
        ))}
      </dl>
      <h4 className="text-[22px] leading-tight font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
        Made for the walk home
      </h4>
      <p className="mt-3">
        The interface gets out of the way after dark. Controls dim to a soft red that won&apos;t
        ruin your night vision, the shutter is a single long press anywhere on the screen, and a
        quiet tick counts down the exposure so you never have to look.
      </p>
      <p className="mt-4">
        Back home, every shot keeps its full raw data. Pull the shadows up, cool the street
        lights down, and the sky still holds together.
      </p>
      <div className="mt-10 flex items-center gap-3 rounded-2xl bg-zinc-100 p-4 dark:bg-zinc-900">
        <AppIcon className="from-indigo-500 to-slate-900" path={MOON} />
        <div className="min-w-0 flex-1">
          <p className="truncate text-[15px] font-semibold text-zinc-900 dark:text-zinc-50">Nocturne</p>
          <p className="truncate text-[13px] text-zinc-500">Photo & Video</p>
        </div>
        <GetButton />
      </div>
    </div>
  );
}

// ── Top 5: a list card ────────────────────────────────────

const focusApps = [
  { name: "Stillwater", tagline: "A timer that breathes with you", color: "from-teal-400 to-emerald-600", path: "M3 12c2-3 4-3 6 0s4 3 6 0 4-3 6 0M3 17c2-3 4-3 6 0s4 3 6 0 4-3 6 0", about: "Twenty-five minutes, then a slow tide of a break. The timer fills like water, so you can feel the session's shape without reading a number." },
  { name: "Margin", tagline: "Write with nothing else on screen", color: "from-amber-400 to-orange-600", path: "M4 20h4L19 9l-4-4L4 16zM14 6l4 4", about: "A blank page that hides every menu until you need it. Typewriter scrolling keeps the line you're on in the middle of the screen." },
  { name: "Lull", tagline: "Rain, cafés and brown noise", color: "from-slate-400 to-slate-700", path: "M4 10v4M8 7v10M12 4v16M16 7v10M20 10v4", about: "Hand-recorded soundscapes that loop without a seam. Mix your own, and Lull fades them out when your session ends." },
  { name: "Hourglass", tagline: "Plan the day in blocks", color: "from-rose-400 to-pink-600", path: "M7 3h10M7 21h10M8 3c0 5 8 5 8 9s-8 4-8 9M16 3c0 5-8 5-8 9", about: "Drag your tasks onto the day and it tells you, honestly, what fits. Anything that doesn't moves to tomorrow on its own." },
  { name: "Quiet Inbox", tagline: "Mail, twice a day", color: "from-sky-400 to-blue-600", path: "M3 13h5l2 3h4l2-3h5M5 5h14l2 8v6H3v-6z", about: "Holds your email and delivers it in two calm batches. Anything from the people you choose still comes straight through." },
];

function FocusCover() {
  return (
    <div className="flex size-full flex-col p-6">
      <p className="text-[13px] font-semibold tracking-wide text-sky-600 uppercase dark:text-sky-400">Top 5</p>
      <h3 className="mt-1 max-w-[16rem] text-[26px] leading-[1.1] font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
        Apps that guard your focus
      </h3>
      <ul className="mt-auto divide-y divide-zinc-200/80 dark:divide-zinc-800">
        {focusApps.slice(0, 3).map((app) => (
          <li key={app.name} className="flex items-center gap-3 py-2">
            <AppIcon className={app.color} path={app.path} small />
            <div className="min-w-0 flex-1">
              <p className="truncate text-[14px] font-semibold text-zinc-900 dark:text-zinc-50">{app.name}</p>
              <p className="truncate text-[12px] text-zinc-500">{app.tagline}</p>
            </div>
            <span className="rounded-full bg-zinc-100 px-4 py-1 text-[13px] font-bold text-sky-600 dark:bg-zinc-800 dark:text-sky-400">
              Get
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

function FocusBody() {
  return (
    <div className="mx-auto max-w-[36rem] px-6 pt-6 pb-14 sm:px-8">
      <p className="text-[17px] leading-relaxed text-zinc-600 dark:text-zinc-400">
        Five small apps that each do one thing well: keep the rest of your phone quiet while you
        get something done.
      </p>
      <ol className="mt-8 space-y-8">
        {focusApps.map((app, index) => (
          <li key={app.name}>
            <div className="flex items-center gap-3">
              <AppIcon className={app.color} path={app.path} />
              <div className="min-w-0 flex-1">
                <p className="text-[12px] font-semibold text-zinc-400 tabular-nums">{index + 1}</p>
                <p className="truncate text-[17px] font-semibold text-zinc-900 dark:text-zinc-50">{app.name}</p>
                <p className="truncate text-[13px] text-zinc-500">{app.tagline}</p>
              </div>
              <GetButton />
            </div>
            <p className="mt-3 text-[16px] leading-relaxed text-zinc-600 dark:text-zinc-400">{app.about}</p>
          </li>
        ))}
      </ol>
    </div>
  );
}

// ── Tips: a text-only card on a gradient ──────────────────

const tips = [
  ["Undo with a shake", "Shake the phone after typing to take back the last edit."],
  ["Drag the cursor", "Hold the space bar and slide to move the cursor anywhere."],
  ["Back to the top", "Tap the clock at the top of any list to jump to its start."],
  ["Quick calculator", "Swipe down on the home screen and type a sum."],
  ["Silence one thread", "Swipe left on a conversation and tap the bell."],
  ["Measure anything", "Open Measure, point it at a doorframe and read its height."],
  ["Scan a document", "Long-press Notes, choose Scan, and let it find the edges."],
  ["Find a photo by text", "Search Photos for a word that appears in the picture."],
  ["Full-page screenshots", "In Safari, take a screenshot and switch to Full Page."],
  ["Type with one hand", "Hold the emoji key and pick the keyboard that hugs one side."],
  ["Hide the keyboard", "Swipe down on any list to tuck the keyboard away."],
  ["Faster brightness", "Hold the brightness slider for night shift and true tone."],
];

function TipsCover() {
  return (
    <div className="relative flex size-full flex-col justify-between overflow-hidden bg-linear-to-br from-orange-400 via-rose-500 to-fuchsia-600 p-6 text-white">
      <span
        aria-hidden
        className="absolute -top-8 -right-3 text-[11rem] leading-none font-black tracking-tighter text-white/15"
      >
        12
      </span>
      <p className="relative text-[13px] font-semibold tracking-wide text-white/80 uppercase">Tips & tricks</p>
      <h3 className="relative max-w-[15.5rem] text-[30px] leading-[1.05] font-black tracking-tight">
        Twelve tricks your phone never told you about
      </h3>
    </div>
  );
}

function TipsBody() {
  return (
    <div className="mx-auto max-w-[36rem] px-6 pt-8 pb-14 sm:px-8">
      <p className="text-[17px] leading-relaxed text-zinc-600 dark:text-zinc-400">
        You&apos;ve been holding it for years. Here&apos;s what it&apos;s been waiting for you to try.
      </p>
      <ol className="mt-8 grid gap-3 sm:grid-cols-2">
        {tips.map(([title, text], index) => (
          <li key={title} className="rounded-2xl bg-zinc-100 p-4 dark:bg-zinc-900">
            <p className="bg-linear-to-br from-orange-500 to-fuchsia-600 bg-clip-text text-[13px] font-black text-transparent tabular-nums">
              {String(index + 1).padStart(2, "0")}
            </p>
            <p className="mt-1 text-[15px] font-semibold text-zinc-900 dark:text-zinc-50">{title}</p>
            <p className="mt-1 text-[14px] leading-snug text-zinc-600 dark:text-zinc-400">{text}</p>
          </li>
        ))}
      </ol>
    </div>
  );
}

// ── Pieces ────────────────────────────────────────────────

const MOON = "M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5ZM17 4v4M15 6h4";

function AppIcon({ className, path, small }: { className: string; path: string; small?: boolean }) {
  return (
    <span
      aria-hidden
      className={`grid shrink-0 place-items-center bg-linear-to-br text-white shadow-sm ring-1 ring-black/5 ${
        small ? "size-9 rounded-[10px]" : "size-12 rounded-[13px]"
      } ${className}`}
    >
      <svg
        viewBox="0 0 24 24"
        className={small ? "size-[18px]" : "size-6"}
        fill="none"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <path d={path} />
      </svg>
    </span>
  );
}

function GetButton() {
  return (
    <button
      type="button"
      className="rounded-full bg-zinc-200/70 px-5 py-1.5 text-[15px] font-bold text-sky-600 transition-[background-color,scale] duration-150 ease-out hover:bg-zinc-200 active:scale-95 dark:bg-zinc-800 dark:text-sky-400 dark:hover:bg-zinc-700"
    >
      Get
    </button>
  );
}
```

## Examples

Finished sections built with Expand Card. Each one installs whole, with the component included.

### Changelog With Links

Each post has its own link. Opening one updates the URL, the back button closes it, and loading a post's link opens it directly. The notes load while the cover grows.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/expand-card-changelog-demo
```

```tsx title="components/expand-card-changelog-demo.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";

import { ExpandCard } from "@/components/ui/expand-card";

type Release = {
  slug: string;
  version: string;
  date: string;
  title: string;
  summary: string;
  tone: string;
  glyph: string;
  intro: string;
  notes: { heading: string; items: string[] }[];
};

const releases: Release[] = [
  {
    slug: "relay-2-4",
    version: "2.4",
    date: "Oct 2, 2026",
    title: "Shared inboxes",
    summary: "One address, the whole team, nobody answering twice.",
    tone: "from-zinc-600 to-zinc-900",
    glyph: "M3 13h5l2 3h4l2-3h5M5 5h14l2 8v6H3v-6z",
    intro:
      "Support, sales and billing can now share one inbox. Everyone sees the same threads, and Relay shows who's replying so two people never answer the same customer.",
    notes: [
      { heading: "New", items: ["Shared inboxes for any address on your domain", "Live “typing” indicator on threads someone else has open", "Assign a thread with a single keystroke: A"] },
      { heading: "Improved", items: ["Threads load twice as fast on large inboxes", "Mentions now notify on every device you're signed in on"] },
      { heading: "Fixed", items: ["Attachments over 20 MB no longer fail silently"] },
    ],
  },
  {
    slug: "relay-2-3",
    version: "2.3",
    date: "Sep 4, 2026",
    title: "Snooze, rethought",
    summary: "Put a thread away until it's actually useful again.",
    tone: "from-amber-400 to-orange-600",
    glyph: "M12 7v5l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z",
    intro:
      "Snooze now understands more than times. Bring a thread back when someone replies, when a date in it arrives, or the next time you open your laptop.",
    notes: [
      { heading: "New", items: ["Snooze until a reply, a date, or your next session", "A Later view that shows everything waiting to come back"] },
      { heading: "Improved", items: ["Natural-language times: “Monday morning”, “in 3 days”", "Snoozed threads keep their place in shared inboxes"] },
    ],
  },
  {
    slug: "relay-2-2",
    version: "2.2",
    date: "Aug 7, 2026",
    title: "Keyboard everything",
    summary: "Every action, two keys away. Press ? to see them all.",
    tone: "from-emerald-400 to-teal-700",
    glyph: "M3 6h18v12H3zM7 10h.01M11 10h.01M15 10h.01M7 14h10",
    intro:
      "Everything you can click, you can now reach from the keyboard. Shortcuts are listed in a sheet you open with ?, and you can rebind any of them.",
    notes: [
      { heading: "New", items: ["A shortcut for every action, listed under ?", "Rebind any shortcut in Settings → Keyboard", "Vim-style J and K to move between threads"] },
      { heading: "Fixed", items: ["Escape now closes the composer without losing the draft"] },
    ],
  },
  {
    slug: "relay-2-1",
    version: "2.1",
    date: "Jul 10, 2026",
    title: "Search in a blink",
    summary: "Results as you type, across every inbox you can see.",
    tone: "from-sky-400 to-blue-700",
    glyph: "M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14ZM20 20l-4-4",
    intro:
      "Search was rebuilt from the index up. Results arrive as you type, filters stack, and attachments are searched by their contents, not just their names.",
    notes: [
      { heading: "New", items: ["Filters: from:, has:attachment, before: and after:", "Search inside PDFs and documents"] },
      { heading: "Improved", items: ["First results in under 50 ms on most inboxes"] },
    ],
  },
];

// Stands in for fetching each post's notes. The cover animates straight away,
// since it's already on the card; the notes arrive while it grows.
const loaded = new Map<string, Promise<Release>>();
function load(release: Release) {
  let promise = loaded.get(release.slug);
  if (!promise) {
    promise = new Promise((resolve) => setTimeout(() => resolve(release), 650));
    loaded.set(release.slug, promise);
  }
  return promise;
}

export function ExpandCardChangelogDemo() {
  const [current, setCurrent] = React.useState<string | null>(null);

  return (
    <div className="w-full overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
      <div className="h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain [scroll-padding-top:3.5rem]">
        <AddressBar hash={current ? `#${current}` : ""} />

        <div className="mx-auto max-w-2xl px-5 pt-10 pb-16 sm:px-8">
          <p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">Changelog</p>
          <h2 className="mt-2 text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
            What&apos;s new in Relay
          </h2>
          <p className="mt-2 text-sm text-pretty text-zinc-500">
            Every post has its own link. Open one, then use the back button to close it.
          </p>

          <ol className="mt-10 space-y-8">
            {releases.map((release) => (
              <li key={release.slug} className="grid gap-3 sm:grid-cols-[6.5rem_minmax(0,1fr)]">
                <time className="pt-1 font-mono text-xs text-zinc-500 tabular-nums">{release.date}</time>
                <ExpandCard
                  id={release.slug}
                  width={680}
                  className="h-44 rounded-2xl shadow-[0_10px_30px_-14px_rgb(0_0_0/0.45)]"
                  articleClassName="bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50"
                  onOpenChange={(open) =>
                    setCurrent((slug) => (open ? release.slug : slug === release.slug ? null : slug))
                  }
                  cover={<ReleaseCover release={release} />}
                >
                  <ReleaseNotes release={release} />
                </ExpandCard>
              </li>
            ))}
          </ol>
        </div>
      </div>
    </div>
  );
}

function AddressBar({ hash }: { hash: string }) {
  return (
    <div className="sticky top-0 z-10 flex items-center gap-3 border-b border-zinc-200/80 bg-white/80 px-4 py-2.5 backdrop-blur-md dark:border-zinc-800/80 dark:bg-zinc-950/80">
      <div aria-hidden className="flex gap-1.5">
        {[0, 1, 2].map((dot) => (
          <span key={dot} className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800" />
        ))}
      </div>
      <p className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg bg-zinc-100 px-3 py-1.5 font-mono text-xs text-zinc-500 dark:bg-zinc-900">
        <svg aria-hidden viewBox="0 0 24 24" className="size-3 shrink-0" fill="none" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round">
          <path d="M6 11h12v9H6zM8.5 11V8a3.5 3.5 0 0 1 7 0v3" />
        </svg>
        <span className="truncate">
          relay.app/changelog
          <AnimatePresence initial={false} mode="popLayout">
            {hash ? (
              <motion.span
                key={hash}
                initial={{ opacity: 0, x: -4 }}
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.2, ease: "easeOut" }}
                className="inline-block text-zinc-900 dark:text-zinc-100"
              >
                {hash}
              </motion.span>
            ) : null}
          </AnimatePresence>
        </span>
      </p>
    </div>
  );
}

function ReleaseCover({ release }: { release: Release }) {
  return (
    <div className={`relative flex size-full flex-col justify-between overflow-hidden bg-linear-to-br p-5 text-white ${release.tone}`}>
      <svg
        aria-hidden
        viewBox="0 0 24 24"
        className="absolute -right-6 -bottom-8 size-44 text-white/15"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.5}
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <path d={release.glyph} />
      </svg>
      <p className="relative w-fit rounded-full bg-white/20 px-2.5 py-0.5 font-mono text-xs font-medium backdrop-blur-sm">
        Relay {release.version}
      </p>
      <div className="relative">
        <h3 className="text-[22px] leading-tight font-semibold tracking-tight">{release.title}</h3>
        <p className="mt-1 max-w-[19rem] text-sm leading-snug text-white/80">{release.summary}</p>
      </div>
    </div>
  );
}

function ReleaseNotes({ release }: { release: Release }) {
  const { intro, notes, date } = React.use(load(release));

  return (
    <div className="mx-auto max-w-[34rem] px-6 pt-6 pb-12 sm:px-8">
      <div className="flex items-center justify-between gap-4">
        <time className="font-mono text-xs text-zinc-500 tabular-nums">{date}</time>
        <CopyLink />
      </div>
      <p className="mt-4 text-[16px] leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">{intro}</p>
      {notes.map((section) => (
        <section key={section.heading} className="mt-8">
          <h4 className="font-mono text-xs tracking-widest text-zinc-500 uppercase">{section.heading}</h4>
          <ul className="mt-3 space-y-2.5">
            {section.items.map((item) => (
              <li key={item} className="flex gap-3 text-[15px] leading-snug text-zinc-800 dark:text-zinc-200">
                <span aria-hidden className="mt-[7px] size-1.5 shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-700" />
                {item}
              </li>
            ))}
          </ul>
        </section>
      ))}
    </div>
  );
}

function CopyLink() {
  const [copied, setCopied] = React.useState(false);

  React.useEffect(() => {
    if (!copied) return;
    const timer = window.setTimeout(() => setCopied(false), 1600);
    return () => window.clearTimeout(timer);
  }, [copied]);

  return (
    <button
      type="button"
      onClick={() => {
        navigator.clipboard?.writeText(window.location.href).then(() => setCopied(true), () => {});
      }}
      className="flex items-center gap-1.5 rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700 transition-[background-color,scale] duration-150 ease-out hover:bg-zinc-200 active:scale-95 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
    >
      <svg aria-hidden viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
        <path d={copied ? "M5 12l5 5L20 7" : "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"} />
      </svg>
      <span aria-live="polite">{copied ? "Copied" : "Copy link"}</span>
    </button>
  );
}
```

### Shop

Product cards that open into a product page with close-ups and a size picker. Add something to the bag, then pull the page down to watch the count behind it change.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/expand-card-shop-demo
```

```tsx title="components/expand-card-shop-demo.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import { ExpandCard } from "@/components/ui/expand-card";

type Product = {
  id: string;
  name: string;
  color: string;
  price: number;
  about: string;
  soldOut: string[];
  // Focal points for the detail shots, as fractions of the photo.
  details: [number, number][];
};

const products: Product[] = [
  { id: "1542291026-7eec264c27ff", name: "Ember Runner", color: "Crimson", price: 140, about: "A daily trainer with a springy foam midsole and a knit upper that stretches where your foot does.", soldOut: ["7", "12"], details: [[0.42, 0.45], [0.62, 0.62], [0.3, 0.6]] },
  { id: "1606107557195-0e29a4b5b4aa", name: "Volt Trainer", color: "Lime / Black", price: 120, about: "Built for the gym floor: a flat, grippy sole, a wide toe box and a heel that locks in for lifts.", soldOut: ["8.5"], details: [[0.5, 0.42], [0.4, 0.6], [0.62, 0.55]] },
  { id: "1595950653106-6c9ebd614d3a", name: "Pastel Court", color: "Lilac / Mint", price: 110, about: "A retro court shoe in soft suede, with a cupsole that's comfortable from the first wear.", soldOut: ["10", "10.5"], details: [[0.5, 0.45], [0.45, 0.62], [0.55, 0.35]] },
  { id: "1600185365483-26d7a4cc7519", name: "Coral Air", color: "Sail / Coral", price: 150, about: "A visible air unit under the heel and a mesh upper that keeps long days cool.", soldOut: ["7.5"], details: [[0.5, 0.5], [0.35, 0.55], [0.66, 0.5]] },
  { id: "1549298916-b41d501d3772", name: "Desert Low", color: "Wheat", price: 130, about: "Waxed nubuck that shrugs off rain, on a lugged sole made for gravel paths and cobbles.", soldOut: ["9", "12"], details: [[0.5, 0.5], [0.38, 0.62], [0.62, 0.42]] },
  { id: "1608231387042-66d1773070a5", name: "Night Court", color: "White / Black", price: 95, about: "The classic high-contrast low-top, cut from smooth leather that creases in all the right places.", soldOut: [], details: [[0.48, 0.5], [0.35, 0.58], [0.65, 0.46]] },
];

const SIZES = ["7", "7.5", "8", "8.5", "9", "9.5", "10", "10.5", "11", "12"];

const photo = (id: string, width: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=max&q=80&w=${width}`;
const detail = (id: string, [x, y]: [number, number]) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&crop=focalpoint&fp-x=${x}&fp-y=${y}&fp-z=2.2&w=480&h=480&q=80`;

export function ExpandCardShopDemo() {
  const [bag, setBag] = React.useState(0);

  return (
    <section className="w-full max-w-3xl">
      <div className="mb-6 flex items-end justify-between gap-4">
        <div>
          <p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">Spring drop</p>
          <h3 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
            New arrivals
          </h3>
        </div>
        <Bag count={bag} />
      </div>

      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4">
        {products.map((product) => (
          <ExpandCard
            key={product.id}
            width={760}
            coverHeight={460}
            className="aspect-[4/5] w-full rounded-2xl bg-zinc-100 dark:bg-zinc-900"
            articleClassName="bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50"
            cover={<ProductCover product={product} />}
          >
            <ProductPage product={product} onAdd={() => setBag((count) => count + 1)} />
          </ExpandCard>
        ))}
      </div>
    </section>
  );
}

function ProductCover({ product }: { product: Product }) {
  return (
    <div className="relative size-full">
      <img
        src={photo(product.id, 1400)}
        alt=""
        draggable={false}
        className="absolute inset-0 size-full object-cover"
      />
      <div className="absolute bottom-2 left-2 max-w-[calc(100%-1rem)] rounded-xl bg-white/85 px-3 py-2 shadow-sm backdrop-blur-md dark:bg-zinc-900/80">
        <p className="truncate text-[13px] font-semibold text-zinc-900 dark:text-zinc-50">{product.name}</p>
        <p className="truncate text-xs text-zinc-500 tabular-nums">
          {product.color} · ${product.price}
        </p>
      </div>
    </div>
  );
}

function ProductPage({ product, onAdd }: { product: Product; onAdd: () => void }) {
  const [size, setSize] = React.useState<string | null>(null);
  const [added, setAdded] = React.useState(false);
  const sizes = React.useId();

  React.useEffect(() => {
    if (!added) return;
    const timer = window.setTimeout(() => setAdded(false), 1800);
    return () => window.clearTimeout(timer);
  }, [added]);

  return (
    <div className="px-5 pt-5 pb-10 sm:px-8">
      {/* Gallery: swipe for close-ups. */}
      <div className="-mx-5 flex snap-x snap-mandatory gap-2 overflow-x-auto px-5 [scrollbar-width:none] sm:-mx-8 sm:px-8">
        {product.details.map((point, index) => (
          <img
            key={index}
            src={detail(product.id, point)}
            alt={`${product.name}, close-up ${index + 1}`}
            draggable={false}
            className="size-28 shrink-0 snap-start rounded-xl bg-zinc-100 object-cover dark:bg-zinc-900"
          />
        ))}
      </div>

      <div className="mt-6 flex items-start justify-between gap-4">
        <div>
          <h4 className="text-2xl font-semibold tracking-tight">{product.name}</h4>
          <p className="mt-0.5 text-sm text-zinc-500">{product.color}</p>
        </div>
        <p className="text-xl font-semibold tabular-nums">${product.price}</p>
      </div>
      <p className="mt-4 max-w-prose text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-400">
        {product.about}
      </p>

      <fieldset className="mt-7">
        <div className="flex items-baseline justify-between">
          <legend id={sizes} className="text-sm font-medium">
            Size <span className="text-zinc-500">(US)</span>
          </legend>
          <p className="text-xs text-zinc-500">{size ? `US ${size} selected` : "Pick a size"}</p>
        </div>
        <div aria-labelledby={sizes} className="mt-3 grid grid-cols-5 gap-2">
          {SIZES.map((value) => {
            const soldOut = product.soldOut.includes(value);
            return (
              <label
                key={value}
                className={
                  "relative grid h-11 cursor-pointer place-items-center rounded-xl text-sm font-medium tabular-nums ring-1 ring-zinc-200 transition-[background-color,color,box-shadow,scale] duration-150 ease-out select-none active:scale-95 dark:ring-zinc-800 " +
                  "has-checked:bg-zinc-900 has-checked:text-white has-checked:ring-zinc-900 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-zinc-900 " +
                  "dark:has-checked:bg-white dark:has-checked:text-zinc-900 dark:has-checked:ring-white dark:has-focus-visible:outline-white " +
                  (soldOut ? "cursor-not-allowed text-zinc-300 line-through active:scale-100 dark:text-zinc-700" : "hover:ring-zinc-400 dark:hover:ring-zinc-600")
                }
              >
                <input
                  type="radio"
                  name={`${product.id}-size`}
                  value={value}
                  disabled={soldOut}
                  checked={size === value}
                  onChange={() => setSize(value)}
                  className="sr-only"
                />
                {value}
              </label>
            );
          })}
        </div>
      </fieldset>

      <button
        type="button"
        disabled={!size}
        onClick={() => {
          onAdd();
          setAdded(true);
        }}
        className="relative mt-6 flex h-12 w-full items-center justify-center overflow-hidden rounded-full bg-zinc-900 text-[15px] font-semibold text-white transition-[background-color,opacity,scale] duration-150 ease-out hover:bg-zinc-800 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
      >
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            key={added ? "added" : "add"}
            initial={{ opacity: 0, y: 12 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -12 }}
            transition={{ type: "spring", visualDuration: 0.25, bounce: 0.2 }}
            className="flex items-center gap-2"
          >
            {added ? (
              <>
                <svg aria-hidden viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
                  <path d="M5 12l5 5L20 7" />
                </svg>
                Added to bag
              </>
            ) : (
              "Add to bag"
            )}
          </motion.span>
        </AnimatePresence>
      </button>
      <p className="mt-3 text-center text-xs text-zinc-500">Free returns within 30 days</p>

      <dl className="mt-8 divide-y divide-zinc-200 border-y border-zinc-200 text-sm dark:divide-zinc-800 dark:border-zinc-800">
        {[
          ["Fit", "True to size. Between sizes? Go up half."],
          ["Upper", "Engineered knit with a suede heel"],
          ["Sole", "Rubber outsole, recycled foam midsole"],
          ["Shipping", "Free over $100. Arrives in 2–4 days."],
        ].map(([term, value]) => (
          <div key={term} className="flex gap-4 py-3">
            <dt className="w-20 shrink-0 text-zinc-500">{term}</dt>
            <dd className="text-zinc-800 dark:text-zinc-200">{value}</dd>
          </div>
        ))}
      </dl>
    </div>
  );
}

function Bag({ count }: { count: number }) {
  const reduceMotion = useReducedMotion();
  const pop = count > 0 && !reduceMotion;

  return (
    <div className="relative grid size-10 place-items-center rounded-full bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-50">
      <motion.svg
        key={count}
        aria-hidden
        viewBox="0 0 24 24"
        className="size-[18px]"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.75}
        strokeLinecap="round"
        strokeLinejoin="round"
        initial={pop ? { scale: 1.25, rotate: -8 } : false}
        animate={{ scale: 1, rotate: 0 }}
        transition={{ type: "spring", visualDuration: 0.4, bounce: 0.5 }}
      >
        <path d="M6 8h12l-1 12H7zM9 8V6a3 3 0 0 1 6 0v2" />
      </motion.svg>
      <span role="status" className="sr-only">
        {count} {count === 1 ? "item" : "items"} in your bag
      </span>
      <AnimatePresence>
        {count > 0 ? (
          <motion.span
            aria-hidden
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.15 }}
            className="absolute -top-1 -right-1"
          >
            {/* Remounts on every change, so each new item pops the badge. */}
            <motion.span
              key={count}
              initial={pop ? { scale: count === 1 ? 0.3 : 1.35 } : false}
              animate={{ scale: 1 }}
              transition={{ type: "spring", visualDuration: 0.35, bounce: 0.45 }}
              className="grid h-5 min-w-5 place-items-center rounded-full bg-rose-500 px-1 text-[11px] font-bold text-white tabular-nums ring-2 ring-white dark:ring-zinc-950"
            >
              {count}
            </motion.span>
          </motion.span>
        ) : null}
      </AnimatePresence>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cover` (required) | `React.ReactNode` |  | The card's face, and the top of the open article. It fills the card, then the article's header, at its own size: nothing is scaled, so it uncrops as it widens. Give its text a max width so it wraps the same in both. |
| `children` | `React.ReactNode` |  | The article's body, revealed under the cover. Only rendered while open. If it suspends, the cover still animates straight away and `fallback` shows until the body is ready. |
| `id` | `string` |  | Gives the article a link of its own, `#id`. Opening it adds a history entry, so the back button closes it, and loading the link opens it. Also the card's DOM id; with it the card is a link, without it a button. |
| `open / onOpenChange` | `boolean / (open: boolean) => void` |  | Control it yourself. `onOpenChange` is also called when the reader opens or closes it. |
| `width` | `number` | `720` | Width of the open article on screens 640px and wider, in px, shown over the dimmed page. Narrower screens get the whole screen. |
| `coverHeight` | `number` |  | Height of the cover in the open article, in px. Defaults to the card's height. Taller suits portrait photos, like products. |
| `className` | `string` |  | Classes for the card: size, corners, background. Its corners, background and text styles carry into the open article's cover. Other props pass through to the card, a `button` (or an `a` with `id`). |
| `articleClassName` | `string` | `"bg-background text-foreground"` | Classes for the open article, such as its background and text color. |
| `fallback` | `React.ReactNode` |  | Shown in place of the body while it suspends. Defaults to a few placeholder lines. |

## Source

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

import * as React from "react";
import {
  animate,
  motion,
  useMotionValue,
  useTransform,
  type AnimationPlaybackControlsWithThen,
} from "motion/react";

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

/* ─────────────────────────────────────────────────────────
 * EXPAND CARD STORYBOARD
 *
 *  press   the card sinks a little under the finger
 *  open    it lifts out of the page and grows to fill the
 *          screen on a spring. Nothing is scaled up: the card
 *          is the article's own header seen through a smaller
 *          window, so the window just widens. Corners square
 *          off, the image uncrops, the title rides along at
 *          its own size, and the page dims behind
 *  reveal  the body fades up under the cover as it lands
 *  read    the article scrolls on its own; the page stays put
 *  lift    at the top, pull down (or swipe in from the left
 *          edge) and the article comes loose under the finger,
 *          shrinking and rounding as the page shows through
 *  home    let go moving down and it flies back into its slot
 *          at the finger's speed, scrolling the page so the
 *          slot is in view; let go moving up and it springs
 *          back to full screen
 *  catch   grab it while it's moving, either way, to take it
 *          back
 *  close   ×, Esc, a click on the page around it, or the back
 *          button
 * ───────────────────────────────────────────────────────── */

const EXPAND = {
  open: { type: "spring", visualDuration: 0.5, bounce: 0.14 },
  home: { type: "spring", visualDuration: 0.42, bounce: 0.12 },
  settle: { type: "spring", visualDuration: 0.36, bounce: 0.2 }, // a cancelled drag springs back
  scroll: { type: "spring", visualDuration: 0.42, bounce: 0 }, // the page brings the slot into view
  reveal: { duration: 0.45, ease: [0.22, 1, 0.36, 1], delay: 0.16 },
  fadeIn: { duration: 0.28, ease: "easeOut" },
  fadeOut: { duration: 0.16, ease: "easeOut" },
  quick: { duration: 0.2, ease: "easeOut" }, // reduced motion: snaps and fades instead of springs
  narrow: 640, // px; narrower screens give the article the whole screen
  margin: 40, // px kept around the article on wider screens, at most
  radius: 24, // px; corners of the open article on wider screens, and of a lifted one
  edge: 24, // px from the article's left edge where a swipe back can start
  lock: 6, // px a mouse press travels before it drags
  minScale: 0.6, // a lifted article shrinks no smaller than this
  shrink: 1.4, // drag distance, in screen heights, to reach minScale
  fade: 0.5, // drag distance, in screen heights, to bring the page back into view
  dismiss: 0.12, // projected drag, in screen heights, that sends it home
  flick: 400, // px/s; a faster release counts as a flick
} as const;

type Spring = { type: "spring"; visualDuration: number; bounce: number };
type Rect = { x: number; y: number; width: number; height: number };
type Viewport = { width: number; height: number };
type Frame = { rect: Rect; radius: number };
/** The card on screen: its layout box, the scale it shows at (pressed, or in a scaled parent), its corners in screen px. */
type Pose = { rect: Rect; scale: number; radius: number };
type Scroll = { element: Element; axis: "top" | "left"; to: number };
type Phase = "opening" | "open" | "lifting" | "closing";

type Session = {
  key: number;
  /** Where the card is. Null when it isn't on screen: the article fades in instead. */
  origin: Pose | null;
  /** The cover's height in the open article. */
  cover: number;
  cardWidth: number;
  /** The card's own look, so the cover sits on the same ground in the article. */
  skin: React.CSSProperties;
  viewport: Viewport;
  reduceMotion: boolean;
};

type Drag = {
  source: "touch" | "mouse";
  pointer: number;
  mode: "pending" | "lift" | "native";
  axis: "x" | "y";
  pull: boolean; // the article is at its top, so pulling down lifts it
  edge: boolean; // started at the left edge, so swiping right lifts it
  block: boolean; // nothing to scroll, so keep the gesture from the page behind
  startX: number;
  startY: number;
  samples: { x: number; y: number; t: number }[];
  // lift: the article's center, the point under the finger in its own px, and where it started
  baseX: number;
  baseY: number;
  grabX: number;
  grabY: number;
  scaleFrom: number;
  backdropFrom: number;
  radiusFrom: number;
};

type ArticleHandle = { close: () => void; reopen: () => void };

export interface ExpandCardProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
  /**
   * The card's face, and the top of the open article. It fills the card, then
   * the article's header, at its own size: nothing is scaled. Give its text a
   * max width so it wraps the same in both.
   */
  cover: React.ReactNode;
  /** The article's body, revealed under the cover. Rendered only while open. */
  children?: React.ReactNode;
  /**
   * Gives the article a link of its own, `#id`: opening it adds a history
   * entry (so the back button closes it), and loading the link opens it. Also
   * the card's DOM id.
   */
  id?: string;
  /** Opens or closes the article. Leave unset to let the card manage it. */
  open?: boolean;
  /** Called when the reader opens or closes the article. */
  onOpenChange?: (open: boolean) => void;
  /** Width of the open article on screens 640px and wider, in px. Narrower screens get the whole screen. */
  width?: number;
  /** Height of the cover in the open article, in px. Defaults to the card's height. */
  coverHeight?: number;
  /** Classes for the open article. Defaults to `bg-background text-foreground`. */
  articleClassName?: string;
  /** Shown in place of the body while it suspends. Defaults to a few placeholder lines. */
  fallback?: React.ReactNode;
}

/**
 * A card that opens into a full article: it grows from its place in the page
 * to fill the screen, and shrinks back into it when it's pulled down.
 */
export function ExpandCard({
  cover,
  children,
  id,
  open,
  onOpenChange,
  width = 720,
  coverHeight,
  articleClassName,
  fallback,
  className,
  onClick,
  onDragStart,
  ...props
}: ExpandCardProps) {
  const cardRef = React.useRef<HTMLElement | null>(null);
  const articleRef = React.useRef<ArticleHandle | null>(null);
  const [session, setSession] = React.useState<Session | null>(null);
  const live = React.useRef({
    sessions: 0,
    open: false,
    pushed: false,
    deepLinked: false,
    restoration: null as ScrollRestoration | null,
  });

  // Is the article the one the URL points at?
  const linked = () => id !== undefined && readHash() === id;

  // Give the article its own history entry, so the back button closes it.
  const push = (slug: string) => {
    const state = live.current;
    // Going back to the page would restore its scroll from when the article
    // opened, undoing the scroll that brings the card's slot into view. Scroll
    // restoration is set per entry, so it's turned off on the page's entry
    // before the article's goes on top, and turned back on when we return.
    state.restoration ??= window.history.scrollRestoration;
    window.history.scrollRestoration = "manual";
    window.history.pushState(window.history.state, "", `#${encodeURIComponent(slug)}`);
    state.pushed = true;
  };

  const link = () => {
    if (id !== undefined && !linked()) push(id);
  };

  // Closed: take the entry back off, or drop the link from the URL.
  const unlink = () => {
    const state = live.current;
    const pushed = state.pushed;
    state.pushed = false;
    if (!linked()) return;
    if (pushed) window.history.back();
    else window.history.replaceState(window.history.state, "", location.pathname + location.search);
  };

  const commit = (next: boolean, fromProp = false) => {
    const state = live.current;
    if (state.open === next) return;
    state.open = next;
    if (next) link();
    if (!fromProp) onOpenChange?.(next);
  };

  const show = (instant = false, fromProp = false) => {
    if (articleRef.current) {
      // Still on its way home: catch it.
      articleRef.current.reopen();
      return;
    }
    const card = cardRef.current;
    if (!card) return;
    const viewport = readViewport();
    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const next: Session = {
      key: ++live.current.sessions,
      origin: instant || reduceMotion ? null : poseOf(card, viewport),
      cover: coverHeight ?? card.offsetHeight,
      cardWidth: card.offsetWidth,
      skin: skinOf(card),
      viewport,
      reduceMotion,
    };
    setSession((current) => current ?? next);
    commit(true, fromProp);
  };

  // Listeners that outlive a render call the latest versions of these.
  const actions = React.useRef({ show, push });
  React.useLayoutEffect(() => {
    actions.current = { show, push };
  });

  // The back and forward buttons, and links to `#id` elsewhere on the page.
  React.useEffect(() => {
    if (id === undefined) return;
    const sync = () => {
      const state = live.current;
      const wanted = readHash() === id;
      if (!wanted && state.restoration) {
        // Back on the page's own entry: give it its scroll restoration back,
        // once the browser is done with this traversal (it restores, or
        // doesn't, after popstate).
        const restoration = state.restoration;
        state.restoration = null;
        window.setTimeout(() => (window.history.scrollRestoration = restoration));
      }
      if (wanted && !state.open) {
        state.pushed = true; // the entry before it is this page
        actions.current.show();
      } else if (!wanted && state.open) {
        state.pushed = false;
        articleRef.current?.close();
      }
    };
    window.addEventListener("popstate", sync);
    return () => window.removeEventListener("popstate", sync);
  }, [id]);

  // A link straight to the article opens it. The page goes under it in
  // history, so the back button closes the article rather than leaving.
  React.useEffect(() => {
    const state = live.current;
    if (id === undefined || state.deepLinked || readHash() !== id) return;
    state.deepLinked = true;
    window.history.replaceState(window.history.state, "", location.pathname + location.search);
    actions.current.push(id);
    actions.current.show(true);
  }, [id]);

  // Controlled: follow `open`.
  React.useEffect(() => {
    if (open === undefined || open === live.current.open) return;
    if (open) actions.current.show(false, true);
    else {
      live.current.open = false;
      articleRef.current?.close();
    }
  }, [open]);

  const Card = (id === undefined ? "button" : "a") as React.ElementType;

  return (
    <>
      <Card
        {...props}
        ref={cardRef}
        id={id}
        {...(id === undefined ? { type: "button" } : { href: `#${encodeURIComponent(id)}` })}
        aria-haspopup="dialog"
        className={cn(
          "relative block cursor-pointer overflow-hidden text-start outline-none select-none",
          "[-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none]",
          "transition-[scale] duration-300 ease-out active:scale-[0.97] active:duration-150",
          "motion-reduce:transition-none motion-reduce:active:scale-100",
          "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground",
          className,
        )}
        onClick={(event: React.MouseEvent<HTMLElement>) => {
          onClick?.(event);
          if (event.defaultPrevented) return;
          // A link opened in a new tab or window opens straight into the article there.
          const elsewhere = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
          if (id !== undefined && (elsewhere || event.button !== 0)) return;
          event.preventDefault();
          show();
        }}
        onDragStart={(event: React.DragEvent<HTMLElement>) => {
          onDragStart?.(event);
          event.preventDefault();
        }}
      >
        <div className="relative size-full">{cover}</div>
      </Card>

      {session ? (
        <Article
          key={session.key}
          session={session}
          card={cardRef}
          handleRef={articleRef}
          cover={cover}
          width={width}
          className={articleClassName}
          fallback={fallback}
          onIntent={(next) => commit(next)}
          onClosed={() => {
            setSession(null);
            unlink();
          }}
        >
          {children}
        </Article>
      ) : null}
    </>
  );
}

function Article({
  session,
  card,
  handleRef,
  cover,
  children,
  width,
  className,
  fallback,
  onIntent,
  onClosed,
}: {
  session: Session;
  card: React.RefObject<HTMLElement | null>;
  handleRef: React.RefObject<ArticleHandle | null>;
  cover: React.ReactNode;
  children: React.ReactNode;
  width: number;
  className?: string;
  fallback?: React.ReactNode;
  onIntent: (open: boolean) => void;
  onClosed: () => void;
}) {
  const { origin, reduceMotion } = session;
  const [viewport, setViewport] = React.useState(session.viewport);
  const frame = frameOf(viewport, Math.max(width, session.cardWidth));
  const titleId = React.useId();

  const dialogRef = React.useRef<HTMLDialogElement>(null);
  const shellRef = React.useRef<HTMLDivElement>(null);
  const scrollerRef = React.useRef<HTMLDivElement>(null);
  const coverRef = React.useRef<HTMLDivElement>(null);
  const live = React.useRef({
    phase: "opening" as Phase,
    generation: 0,
    done: false,
    hidden: null as HTMLElement | null,
    drag: null as Drag | null,
    runs: [] as AnimationPlaybackControlsWithThen[],
    frame,
    viewport,
  });

  // The article's box (its layout size, so the cover lays out at real size),
  // plus a drag offset and scale on top.
  const start = origin?.rect ?? frame.rect;
  const left = useMotionValue(start.x);
  const top = useMotionValue(start.y);
  const boxWidth = useMotionValue(start.width);
  const boxHeight = useMotionValue(start.height);
  const coverHeight = useMotionValue(origin ? origin.rect.height : session.cover);
  const dx = useMotionValue(0);
  const dy = useMotionValue(0);
  const scale = useMotionValue(origin?.scale ?? (reduceMotion ? 1 : 0.96));
  const radius = useMotionValue(origin?.radius ?? frame.radius); // screen px
  const opacity = useMotionValue(origin ? 1 : 0);
  const backdrop = useMotionValue(0);
  const reveal = useMotionValue(origin ? 0 : 1); // the body and the close button
  const x = useTransform(() => left.get() + dx.get());
  const y = useTransform(() => top.get() + dy.get());
  const borderRadius = useTransform(() => radius.get() / Math.max(scale.get(), 0.01));
  const bodyY = useTransform(() => (1 - reveal.get()) * 20);
  const shadow = useTransform(
    () => `0 24px 64px -16px rgb(0 0 0 / ${(0.4 * backdrop.get()).toFixed(3)})`,
  );

  const stop = () => {
    const state = live.current;
    for (const value of [left, top, boxWidth, boxHeight, coverHeight, dx, dy, scale, radius, opacity, backdrop, reveal]) {
      value.stop();
    }
    for (const run of state.runs) run.stop();
    state.runs = [];
  };

  const setScrollable = (scrollable: boolean) => {
    const scroller = scrollerRef.current;
    if (scroller) scroller.style.overflowY = scrollable ? "auto" : "hidden";
  };

  // Grow into the open article: on open, after a cancelled drag, or when
  // caught on its way home.
  const expand = (velocity = { x: 0, y: 0 }, spring: Spring = EXPAND.open) => {
    const state = live.current;
    stop(); // including the page scrolling the slot into view
    state.phase = "opening";
    const generation = ++state.generation;
    setScrollable(true);
    const { rect, radius: corners } = state.frame;
    const move = reduceMotion ? EXPAND.quick : spring;
    const runs = [
      animate(left, rect.x, move),
      animate(top, rect.y, move),
      animate(boxWidth, rect.width, move),
      animate(boxHeight, rect.height, move),
      animate(coverHeight, session.cover, move),
      animate(dx, 0, { ...move, velocity: velocity.x }),
      animate(dy, 0, { ...move, velocity: velocity.y }),
      animate(scale, 1, { ...move, restDelta: 0.001 }),
      animate(radius, corners, move),
      animate(opacity, 1, EXPAND.fadeIn),
      animate(backdrop, 1, EXPAND.fadeIn),
      animate(reveal, 1, reveal.get() > 0 ? EXPAND.fadeIn : EXPAND.reveal),
    ];
    state.runs = runs;
    Promise.all(runs).then(() => {
      if (state.generation === generation) state.phase = "open";
    });
  };

  // Landed: show the card, close the dialog and hand focus back, all in one
  // task so no frame shows both or neither. Also runs if the browser closes
  // the dialog on its own.
  const finish = () => {
    const state = live.current;
    if (state.done) return;
    state.done = true;
    stop();
    revealCard(state);
    dialogRef.current?.close();
    card.current?.focus({ preventScroll: true });
    onClosed();
  };

  const close = (velocity = { x: 0, y: 0 }) => {
    const state = live.current;
    if (state.done || state.phase === "closing") return;
    stop();
    state.drag = null;
    state.phase = "closing";
    const generation = ++state.generation;
    onIntent(false);
    setScrollable(false);

    const scroller = scrollerRef.current;
    const home = reduceMotion ? null : landing(card.current, readViewport());
    const runs = [
      animate(backdrop, 0, { duration: 0.34, ease: "easeOut" }),
      animate(reveal, 0, EXPAND.fadeOut),
    ];
    if (home) {
      // Back to the top, so the cover is what lands.
      if (scroller && scroller.scrollTop > 0) runs.push(scrollTo(scroller, "top", 0));
      runs.push(
        animate(left, home.rect.x, EXPAND.home),
        animate(top, home.rect.y, EXPAND.home),
        animate(boxWidth, home.rect.width, EXPAND.home),
        animate(boxHeight, home.rect.height, EXPAND.home),
        animate(coverHeight, home.rect.height, EXPAND.home),
        animate(dx, 0, { ...EXPAND.home, velocity: velocity.x }),
        animate(dy, 0, { ...EXPAND.home, velocity: velocity.y }),
        animate(scale, home.scale, { ...EXPAND.home, restDelta: 0.001 }),
        animate(radius, home.radius, EXPAND.home),
        animate(opacity, 1, EXPAND.fadeIn),
        // The page scrolls the slot into view as the article shrinks.
        ...home.scrolls.map((scroll) => scrollTo(scroll.element, scroll.axis, scroll.to)),
      );
    } else {
      // Nowhere on screen to land: fade out where it is.
      runs.push(animate(opacity, 0, EXPAND.fadeOut));
      if (!reduceMotion) runs.push(animate(scale, scale.get() * 0.96, EXPAND.fadeOut));
    }
    state.runs = runs;
    Promise.all(runs).then(() => {
      if (live.current.generation === generation) finish();
    });
  };

  const settle = (velocity = { x: 0, y: 0 }) => {
    onIntent(true);
    expand(velocity, EXPAND.settle);
  };

  const reopen = () => {
    const state = live.current;
    if (state.done || state.phase !== "closing") return;
    settle();
  };

  /** Still visibly on its way in or out, so a press grabs it. */
  const flying = () => {
    const state = live.current;
    if (state.phase === "closing") return true;
    if (state.phase !== "opening" || reduceMotion) return false;
    const { rect } = state.frame;
    const off =
      Math.abs(left.get() + dx.get() - rect.x) +
      Math.abs(top.get() + dy.get() - rect.y) +
      Math.abs(boxWidth.get() - rect.width) +
      Math.abs(boxHeight.get() - rect.height);
    return off > 12 || Math.abs(scale.get() - 1) > 0.02;
  };

  // ── Lift ────────────────────────────────────────────────

  const beginLift = (drag: Drag, axis: "x" | "y") => {
    const state = live.current;
    stop();
    state.generation++; // whatever was about to land, won't
    state.phase = "lifting";
    setScrollable(false);
    drag.mode = "lift";
    drag.axis = axis;
    // The point under the finger, in the article's own px from its center, so
    // it stays under the finger however much the article shrinks.
    const s = scale.get();
    drag.baseX = left.get() + boxWidth.get() / 2;
    drag.baseY = top.get() + boxHeight.get() / 2;
    drag.grabX = (drag.startX - drag.baseX - dx.get()) / s;
    drag.grabY = (drag.startY - drag.baseY - dy.get()) / s;
    drag.scaleFrom = s;
    drag.backdropFrom = backdrop.get();
    drag.radiusFrom = radius.get();
    animate(opacity, 1, EXPAND.fadeIn);
  };

  const moveLift = (drag: Drag, pointerX: number, pointerY: number) => {
    const { width: vw, height: vh } = live.current.viewport;
    const along = drag.axis === "y" ? pointerY - drag.startY : pointerX - drag.startX;
    const pull = Math.max(along, 0);
    const size = drag.scaleFrom * Math.max(EXPAND.minScale, 1 - pull / (vh * EXPAND.shrink));
    // The wrong way gives a little, then resists.
    let toX = pointerX;
    let toY = pointerY;
    if (along < 0 && drag.axis === "y") toY = drag.startY - rubberband(-along, vh);
    if (along < 0 && drag.axis === "x") toX = drag.startX - rubberband(-along, vw);
    dx.set(toX - drag.grabX * size - drag.baseX);
    dy.set(toY - drag.grabY * size - drag.baseY);
    scale.set(size);
    const lifted = Math.max(drag.radiusFrom, EXPAND.radius);
    radius.set(drag.radiusFrom + (lifted - drag.radiusFrom) * Math.min(pull / 80, 1));
    backdrop.set(drag.backdropFrom * (1 - 0.7 * Math.min(pull / (vh * EXPAND.fade), 1)));
  };

  const endLift = (drag: Drag, pointerX: number, pointerY: number, time: number, cancelled: boolean) => {
    const velocity = cancelled ? { x: 0, y: 0 } : velocityOf(drag.samples, time);
    const { width: vw, height: vh } = live.current.viewport;
    const along = drag.axis === "y" ? pointerY - drag.startY : pointerX - drag.startX;
    const speed = drag.axis === "y" ? velocity.y : velocity.x;
    const threshold = Math.max(80, (drag.axis === "y" ? vh : vw) * EXPAND.dismiss);
    // Decide by where the throw was heading, not where the finger stopped.
    const home = !cancelled && speed > -50 && (speed > EXPAND.flick || along + project(speed) > threshold);
    if (home) close(velocity);
    else settle(velocity);
  };

  // Touch: decided on the first move. At the top, pulling down lifts the
  // article; anything else is left to the browser to scroll. iOS only lets a
  // gesture be taken from scrolling on its first move.
  const onTouchStart = (event: TouchEvent) => {
    const state = live.current;
    if (state.done) return;
    if (event.touches.length > 1) {
      // A second finger: let go of whatever the first one was doing.
      const drag = state.drag;
      state.drag = null;
      if (drag?.mode === "lift") settle();
      return;
    }
    const touch = event.changedTouches[0];
    const drag = createDrag("touch", touch.identifier, touch.clientX, touch.clientY, event.timeStamp);
    state.drag = drag;
    if (flying()) {
      // Caught on its way: it's the finger's now.
      event.preventDefault();
      beginLift(drag, "y");
      return;
    }
    const scroller = scrollerRef.current;
    const shell = shellRef.current?.getBoundingClientRect();
    drag.pull = !scroller || scroller.scrollTop <= 1;
    drag.edge = touch.clientX - (shell?.left ?? 0) <= EXPAND.edge;
  };

  const onTouchMove = (event: TouchEvent) => {
    const drag = live.current.drag;
    if (!drag || drag.source !== "touch") return;
    const touch = findTouch(event.changedTouches, drag.pointer);
    if (!touch) return;
    record(drag, touch.clientX, touch.clientY, event.timeStamp);

    if (drag.mode === "native") {
      if (drag.block && event.cancelable) event.preventDefault();
      return;
    }
    if (drag.mode === "pending") {
      const moveX = touch.clientX - drag.startX;
      const moveY = touch.clientY - drag.startY;
      if (!moveX && !moveY) return;
      if (!event.cancelable) {
        drag.mode = "native"; // the browser is already scrolling
        return;
      }
      const axis =
        drag.pull && moveY > 0 && moveY >= Math.abs(moveX)
          ? "y"
          : drag.edge && moveX > 0 && moveX > Math.abs(moveY)
            ? "x"
            : null;
      if (!axis) {
        drag.mode = "native";
        // Nothing to scroll: don't let the page behind take it.
        const scroller = scrollerRef.current;
        drag.block = !scroller || scroller.scrollHeight <= scroller.clientHeight;
        if (drag.block) event.preventDefault();
        return;
      }
      beginLift(drag, axis);
    }
    if (event.cancelable) event.preventDefault();
    moveLift(drag, touch.clientX, touch.clientY);
  };

  const onTouchEnd = (event: TouchEvent) => {
    const state = live.current;
    const drag = state.drag;
    if (!drag || drag.source !== "touch") return;
    const touch = findTouch(event.changedTouches, drag.pointer);
    if (!touch) return;
    state.drag = null;
    if (drag.mode !== "lift") return;
    const cancelled = event.type === "touchcancel";
    if (!cancelled) record(drag, touch.clientX, touch.clientY, event.timeStamp);
    endLift(drag, touch.clientX, touch.clientY, event.timeStamp, cancelled);
  };

  // Mouse and pen: drag the cover down, or grab the article mid-flight.
  // The body is left alone, so its text can be selected.
  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.pointerType === "touch" || event.button !== 0) return;
    const state = live.current;
    if (state.done || state.drag) return;
    const target = event.target as Element;
    const caught = flying();
    const onCover = coverRef.current?.contains(target) && !target.closest(INTERACTIVE);
    if (!caught && !onCover) return;
    const drag = createDrag("mouse", event.pointerId, event.clientX, event.clientY, event.timeStamp);
    state.drag = drag;
    event.currentTarget.setPointerCapture(event.pointerId);
    if (caught) {
      event.preventDefault();
      beginLift(drag, "y");
    }
  };

  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    const drag = live.current.drag;
    if (!drag || drag.source !== "mouse" || drag.pointer !== event.pointerId) return;
    record(drag, event.clientX, event.clientY, event.timeStamp);
    if (drag.mode === "pending") {
      const moveX = event.clientX - drag.startX;
      const moveY = event.clientY - drag.startY;
      if (Math.hypot(moveX, moveY) < EXPAND.lock) return;
      if (moveY <= Math.abs(moveX)) {
        drag.mode = "native";
        return;
      }
      beginLift(drag, "y");
    }
    if (drag.mode === "lift") moveLift(drag, event.clientX, event.clientY);
  };

  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
    const state = live.current;
    const drag = state.drag;
    if (!drag || drag.source !== "mouse" || drag.pointer !== event.pointerId) return;
    state.drag = null;
    if (drag.mode !== "lift") return;
    const cancelled = event.type === "pointercancel";
    if (!cancelled) record(drag, event.clientX, event.clientY, event.timeStamp);
    endLift(drag, event.clientX, event.clientY, event.timeStamp, cancelled);
  };

  const onKeyDown = (event: React.KeyboardEvent<HTMLDialogElement>) => {
    if (event.key === "Escape") {
      // Handled here rather than by the dialog, which can skip the animation.
      event.preventDefault();
      close();
      return;
    }
    const scroller = scrollerRef.current;
    if (!scroller || !SCROLL_KEYS.has(event.key) || event.altKey || event.metaKey || event.ctrlKey) return;
    const target = event.target as Element;
    if (target.closest("input, textarea, select, [contenteditable]")) return;
    if (event.key === " " && target.closest("a, button, summary, [role='button']")) return;
    const down =
      event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey);
    if (live.current.phase !== "closing" && scroller.contains(target)) {
      // The article scrolls itself. At its ends, keep the page behind still.
      const end = down
        ? scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1
        : scroller.scrollTop <= 0;
      if (end) event.preventDefault();
      return;
    }
    // Focus is on the close button: scroll the article, never the page.
    event.preventDefault();
    if (live.current.phase === "closing") return;
    const page = scroller.clientHeight * 0.85;
    const step: Record<string, number> = {
      ArrowDown: 64,
      ArrowUp: -64,
      PageDown: page,
      PageUp: -page,
      " ": event.shiftKey ? -page : page,
      Home: -scroller.scrollHeight,
      End: scroller.scrollHeight,
    };
    scroller.scrollBy({ top: step[event.key], behavior: reduceMotion ? "instant" : "smooth" });
  };

  // Native listeners call the latest versions of these.
  const actions = React.useRef({ expand, close, reopen, onTouchStart, onTouchMove, onTouchEnd });
  React.useLayoutEffect(() => {
    actions.current = { expand, close, reopen, onTouchStart, onTouchMove, onTouchEnd };
  });

  React.useLayoutEffect(() => {
    handleRef.current = {
      close: () => actions.current.close(),
      reopen: () => actions.current.reopen(),
    };
    return () => {
      handleRef.current = null;
    };
  }, [handleRef]);

  // Open: lift the dialog into the top layer, hide the card the article came
  // from, and grow it in.
  React.useLayoutEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    if (!dialog.open) dialog.showModal();
    focusTitle(coverRef.current, dialog, titleId);
    const state = live.current;
    // With reduced motion the article fades instead of flying, so the card stays.
    if (!reduceMotion) hideCard(state, card.current);
    actions.current.expand();
    return () => revealCard(state);
  }, [card, reduceMotion, titleId]);

  // Follow the window size.
  React.useLayoutEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    const observer = new ResizeObserver(() => {
      const next = { width: dialog.clientWidth, height: dialog.clientHeight };
      if (!next.width || !next.height) return;
      live.current.viewport = next;
      setViewport((prev) => (prev.width === next.width && prev.height === next.height ? prev : next));
    });
    observer.observe(dialog);
    return () => observer.disconnect();
  }, []);

  const { x: fx, y: fy, width: fw, height: fh } = frame.rect;
  React.useLayoutEffect(() => {
    const state = live.current;
    const next = { rect: { x: fx, y: fy, width: fw, height: fh }, radius: frame.radius };
    if (sameFrame(state.frame, next)) return;
    state.frame = next;
    if (state.phase === "open") {
      left.jump(fx);
      top.jump(fy);
      boxWidth.jump(fw);
      boxHeight.jump(fh);
      radius.jump(next.radius);
    } else if (state.phase === "opening") {
      actions.current.expand();
    }
  }, [fx, fy, fw, fh, frame.radius, left, top, boxWidth, boxHeight, radius]);

  React.useLayoutEffect(() => {
    const shell = shellRef.current;
    const dialog = dialogRef.current;
    if (!shell || !dialog) return;
    const start = (event: TouchEvent) => actions.current.onTouchStart(event);
    const move = (event: TouchEvent) => actions.current.onTouchMove(event);
    const end = (event: TouchEvent) => actions.current.onTouchEnd(event);
    // The wheel scrolls the article, and never the page behind it.
    const wheel = (event: WheelEvent) => {
      const state = live.current;
      const inside = scrollerRef.current?.contains(event.target as Node);
      if (!inside || state.phase === "closing" || state.phase === "lifting") event.preventDefault();
    };
    shell.addEventListener("touchstart", start, { passive: false });
    shell.addEventListener("touchmove", move, { passive: false });
    shell.addEventListener("touchend", end);
    shell.addEventListener("touchcancel", end);
    dialog.addEventListener("wheel", wheel, { passive: false });
    return () => {
      shell.removeEventListener("touchstart", start);
      shell.removeEventListener("touchmove", move);
      shell.removeEventListener("touchend", end);
      shell.removeEventListener("touchcancel", end);
      dialog.removeEventListener("wheel", wheel);
    };
  }, []);

  return (
    <dialog
      ref={dialogRef}
      aria-labelledby={titleId}
      onCancel={(event) => {
        event.preventDefault();
        close();
      }}
      onClose={finish}
      onKeyDown={onKeyDown}
      className="fixed inset-0 m-0 size-full max-h-none max-w-none overflow-hidden border-0 bg-transparent p-0 outline-none backdrop:bg-transparent"
    >
      <motion.div
        aria-hidden
        className="absolute inset-0 touch-none bg-black/45 backdrop-blur-xs"
        style={{ opacity: backdrop }}
        onClick={() => close()}
      />

      <motion.div
        ref={shellRef}
        className={cn("absolute top-0 left-0 isolate overflow-hidden bg-background text-foreground", className)}
        style={{
          x,
          y,
          width: boxWidth,
          height: boxHeight,
          scale,
          borderRadius,
          opacity,
          boxShadow: shadow,
        }}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
      >
        <motion.button
          type="button"
          aria-label="Close"
          className={cn(
            "absolute top-[max(0.75rem,env(safe-area-inset-top))] right-3 z-10 grid size-9 place-items-center rounded-full",
            "bg-black/35 text-white ring-1 ring-white/15 backdrop-blur-md outline-none",
            "transition-[background-color,scale] duration-150 ease-out hover:bg-black/50 active:scale-90",
            "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
          )}
          style={{ opacity: reveal }}
          onClick={() => close()}
        >
          <svg
            aria-hidden
            viewBox="0 0 24 24"
            className="size-4"
            fill="none"
            stroke="currentColor"
            strokeWidth={2.25}
            strokeLinecap="round"
          >
            <path d="M6 6l12 12M18 6L6 18" />
          </svg>
        </motion.button>

        <div
          ref={scrollerRef}
          className="size-full overflow-x-hidden overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
        >
          <motion.div
            ref={coverRef}
            id={titleId}
            className="relative w-full cursor-grab overflow-hidden outline-none select-none active:cursor-grabbing [&_:focus]:outline-none"
            style={{ ...session.skin, height: coverHeight }}
          >
            <div className="relative size-full">{cover}</div>
          </motion.div>

          {/* Laid out at the open width from the start, so it never reflows mid-flight. */}
          <motion.div
            className="relative"
            style={{
              width: frame.rect.width,
              marginLeft: `calc(50% - ${frame.rect.width / 2}px)`,
              opacity: reveal,
              y: bodyY,
            }}
          >
            <React.Suspense fallback={fallback ?? <Placeholder />}>{children}</React.Suspense>
          </motion.div>
        </div>
      </motion.div>
    </dialog>
  );
}

function Placeholder() {
  return (
    <div role="status" aria-label="Loading" className="mx-auto max-w-prose animate-pulse space-y-3 px-6 py-8">
      <div className="h-3 w-3/4 rounded-full bg-current opacity-10" />
      <div className="h-3 rounded-full bg-current opacity-10" />
      <div className="h-3 w-5/6 rounded-full bg-current opacity-10" />
    </div>
  );
}

// ── Geometry ──────────────────────────────────────────────

const INTERACTIVE = "a, button, input, select, textarea, label, summary, [contenteditable]";
const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
const SCROLLS = /auto|scroll|overlay/;

// What the cover inherits from the card, so it sits on the same ground in
// the article: the card's background, text styles and padding.
const SKIN = [
  "color",
  "backgroundColor",
  "backgroundImage",
  "backgroundPosition",
  "backgroundSize",
  "backgroundRepeat",
  "fontFamily",
  "fontSize",
  "fontStyle",
  "fontWeight",
  "lineHeight",
  "letterSpacing",
  "textAlign",
  "textTransform",
  "paddingTop",
  "paddingRight",
  "paddingBottom",
  "paddingLeft",
] as const;

function skinOf(card: HTMLElement): React.CSSProperties {
  const style = getComputedStyle(card);
  return Object.fromEntries(SKIN.map((key) => [key, style[key]])) as React.CSSProperties;
}

function readViewport(): Viewport {
  return {
    width: document.documentElement.clientWidth,
    height: document.documentElement.clientHeight,
  };
}

function readHash() {
  try {
    return decodeURIComponent(window.location.hash.slice(1));
  } catch {
    return window.location.hash.slice(1);
  }
}

/** The open article: the whole screen when it's narrow, a centered column otherwise. */
function frameOf(viewport: Viewport, width: number): Frame {
  if (viewport.width < EXPAND.narrow) {
    return { rect: { x: 0, y: 0, width: viewport.width, height: viewport.height }, radius: 0 };
  }
  const margin = clamp(Math.round(viewport.height * 0.05), 16, EXPAND.margin);
  const columnWidth = Math.min(width, viewport.width - margin * 2);
  return {
    rect: {
      x: Math.round((viewport.width - columnWidth) / 2),
      y: margin,
      width: columnWidth,
      height: viewport.height - margin * 2,
    },
    radius: EXPAND.radius,
  };
}

function sameFrame(a: Frame, b: Frame) {
  return (
    a.radius === b.radius &&
    a.rect.x === b.rect.x &&
    a.rect.y === b.rect.y &&
    a.rect.width === b.rect.width &&
    a.rect.height === b.rect.height
  );
}

/**
 * The card's layout box, centered where it shows. Its rendered box may be
 * smaller while it's pressed; the article starts at that scale instead, so
 * the cover lays out at the card's real width and wraps the same.
 */
function poseOf(card: HTMLElement, viewport: Viewport): Pose | null {
  const width = card.offsetWidth;
  const height = card.offsetHeight;
  if (!width || !height) return null;
  const box = card.getBoundingClientRect();
  const visible =
    box.bottom > 0 && box.right > 0 && box.top < viewport.height && box.left < viewport.width;
  if (!visible) return null;
  const scale = box.width / width;
  const centerX = box.left + box.width / 2;
  const centerY = box.top + box.height / 2;
  return {
    rect: { x: centerX - width / 2, y: centerY - height / 2, width, height },
    scale,
    radius: cornerOf(card) * scale,
  };
}

/**
 * Where the article lands: the card's slot, after scrolling it into view.
 * Null when the card is gone, or clipped somewhere scrolling can't reach.
 */
function landing(card: HTMLElement | null, viewport: Viewport) {
  if (!card?.isConnected) return null;
  const width = card.offsetWidth;
  const height = card.offsetHeight;
  if (!width || !height) return null;
  const box = card.getBoundingClientRect();
  const { scrolls, shiftX, shiftY } = planScroll(card, box, viewport);
  const centerX = box.left + box.width / 2 - shiftX;
  const centerY = box.top + box.height / 2 - shiftY;
  const visible =
    centerY + box.height / 2 > 0 &&
    centerY - box.height / 2 < viewport.height &&
    centerX + box.width / 2 > 0 &&
    centerX - box.width / 2 < viewport.width;
  if (!visible) return null;
  const scale = box.width / width;
  return {
    rect: { x: centerX - width / 2, y: centerY - height / 2, width, height },
    scale,
    radius: cornerOf(card) * scale,
    scrolls,
  };
}

/**
 * The scrolls that bring the card into view, innermost container first, and
 * how far they move it on screen. Like `scrollIntoView({ block: "nearest" })`,
 * but planned rather than done, so the article can fly to where the card will
 * be while the page scrolls.
 */
function planScroll(card: HTMLElement, box: DOMRect, viewport: Viewport) {
  const scrolls: Scroll[] = [];
  let shiftX = 0;
  let shiftY = 0;

  const bring = (element: Element, axis: "top" | "left", viewStart: number, viewEnd: number) => {
    const vertical = axis === "top";
    // Respect scroll-padding, which is how a page says a sticky header covers its top.
    const style = getComputedStyle(element);
    viewStart += parseFloat(vertical ? style.scrollPaddingTop : style.scrollPaddingLeft) || 0;
    viewEnd -= parseFloat(vertical ? style.scrollPaddingBottom : style.scrollPaddingRight) || 0;
    const start = (vertical ? box.top : box.left) - (vertical ? shiftY : shiftX);
    const end = (vertical ? box.bottom : box.right) - (vertical ? shiftY : shiftX);
    const pad = Math.min(24, Math.max((viewEnd - viewStart - (end - start)) / 2, 0));
    let delta = 0;
    if (start < viewStart + pad) delta = start - (viewStart + pad);
    else if (end > viewEnd - pad) delta = Math.min(end - (viewEnd - pad), start - (viewStart + pad));
    if (!delta) return;
    const from = vertical ? element.scrollTop : element.scrollLeft;
    const max = vertical
      ? element.scrollHeight - element.clientHeight
      : element.scrollWidth - element.clientWidth;
    const to = clamp(from + delta, 0, Math.max(max, 0));
    if (Math.abs(to - from) < 1) return;
    scrolls.push({ element, axis, to });
    if (vertical) shiftY += to - from;
    else shiftX += to - from;
  };

  for (let element = card.parentElement; element; element = element.parentElement) {
    if (element === document.body || element === document.documentElement) break;
    const style = getComputedStyle(element);
    const scrollsY = SCROLLS.test(style.overflowY) && element.scrollHeight > element.clientHeight;
    const scrollsX = SCROLLS.test(style.overflowX) && element.scrollWidth > element.clientWidth;
    if (scrollsY || scrollsX) {
      const view = element.getBoundingClientRect();
      const top = view.top + element.clientTop;
      const left = view.left + element.clientLeft;
      if (scrollsY) bring(element, "top", top, top + element.clientHeight);
      if (scrollsX) bring(element, "left", left, left + element.clientWidth);
    }
    // A fixed container doesn't move with the page.
    if (style.position === "fixed") return { scrolls, shiftX, shiftY };
  }

  const page = document.scrollingElement;
  if (page) {
    bring(page, "top", 0, viewport.height);
    bring(page, "left", 0, viewport.width);
  }
  return { scrolls, shiftX, shiftY };
}

function cornerOf(element: HTMLElement) {
  return parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0;
}

// ── Helpers ───────────────────────────────────────────────

/** Moves focus to the cover's heading, which also names the dialog. */
function focusTitle(cover: HTMLElement | null, dialog: HTMLDialogElement, fallbackId: string) {
  if (!cover) return;
  const heading = cover.querySelector<HTMLElement>("h1, h2, h3, h4, h5, h6");
  const title = heading ?? cover;
  if (heading && !heading.id) heading.id = `${fallbackId}-title`;
  dialog.setAttribute("aria-labelledby", title.id);
  if (!title.hasAttribute("tabindex")) title.tabIndex = -1;
  title.focus({ preventScroll: true });
}

/** Hides the card while its article is out, so the two read as one object. */
function hideCard(state: { hidden: HTMLElement | null }, card: HTMLElement | null) {
  if (!card) return;
  card.style.visibility = "hidden";
  state.hidden = card;
}

function revealCard(state: { hidden: HTMLElement | null }) {
  if (state.hidden) state.hidden.style.visibility = "";
  state.hidden = null;
}

/** Animates a scroll position, ignoring any CSS `scroll-behavior`. */
function scrollTo(element: Element, axis: "top" | "left", to: number) {
  const from = axis === "top" ? element.scrollTop : element.scrollLeft;
  return animate(from, to, {
    ...EXPAND.scroll,
    onUpdate: (value) => element.scrollTo({ [axis]: value, behavior: "instant" }),
  });
}

function createDrag(source: Drag["source"], pointer: number, x: number, y: number, t: number): Drag {
  return {
    source,
    pointer,
    mode: "pending",
    axis: "y",
    pull: false,
    edge: false,
    block: false,
    startX: x,
    startY: y,
    samples: [{ x, y, t }],
    baseX: 0,
    baseY: 0,
    grabX: 0,
    grabY: 0,
    scaleFrom: 1,
    backdropFrom: 1,
    radiusFrom: 0,
  };
}

function findTouch(list: TouchList, id: number) {
  for (let i = 0; i < list.length; i++) if (list[i].identifier === id) return list[i];
  return null;
}

function record(drag: Drag, x: number, y: number, t: number) {
  drag.samples.push({ x, y, t });
  while (drag.samples.length > 2 && t - drag.samples[0].t > 100) drag.samples.shift();
}

/** Pointer velocity over the last 80ms, in px/s. Zero if the finger had stopped. */
function velocityOf(samples: Drag["samples"], now: number) {
  const last = samples[samples.length - 1];
  if (!last || now - last.t > 80) return { x: 0, y: 0 };
  const first = samples.find((sample) => last.t - sample.t <= 80) ?? last;
  const dt = (last.t - first.t) / 1000;
  if (dt <= 0) return { x: 0, y: 0 };
  const cap = (v: number) => clamp(v, -4000, 4000);
  return { x: cap((last.x - first.x) / dt), y: cap((last.y - first.y) / dt) };
}

/** How much further a flick travels before it comes to rest, like a scroll view. */
function project(velocity: number, rate = 0.99) {
  return ((velocity / 1000) * rate) / (1 - rate);
}

/** Past a boundary, follow less and less the further it's pulled. */
function rubberband(overshoot: number, dimension: number, constant = 0.55) {
  return (overshoot * dimension * constant) / (dimension + constant * overshoot);
}

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}
```
