# Expand Card: 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/expand-card-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/expand-card-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/expand-card-demo-light-1be32a69a105f542.png
- Registry item: https://keyframeui.com/r/expand-card-demo.json
- Categories: card, gesture, motion
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

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

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/expand-card.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/expand-card#expand-card-demo

## Files

### `registry/demos/expand-card-demo.tsx`

```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>
  );
}
```
