# Sticky Deck: How It Works

> An AI agent's four steps, each card with a piece of real product UI. The step list beside the deck follows the front card through `onActiveChange`, and clicking a step scrolls there through `apiRef`. Hover an edge that peeks out above the front card and it lifts; click it to bring that card back.

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

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/sticky-deck-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/sticky-deck-demo-light-accf8b6abb714958.png
- Registry item: https://keyframeui.com/r/sticky-deck-demo.json
- Categories: scroll, cards, sticky
- Listed: 2026-10-15, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/sticky-deck-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/sticky-deck.json`

## Notes from the registry

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

## Files

### `registry/demos/sticky-deck-demo.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { StickyDeck, StickyDeckCard, type StickyDeckApi } from "@/components/ui/sticky-deck";

const steps = [
  {
    title: "Connect",
    heading: "Plug in the tools you already use",
    body: "Sign in with GitHub and pick the repos. Slack, Linear and your database connect in a click, read-only until you say otherwise.",
    art: <ConnectArt />,
  },
  {
    title: "Configure",
    heading: "Tell it how your team works",
    body: "Set how far the agent goes on its own, who reviews what, and where it reports. Plain toggles, no YAML.",
    art: <ConfigureArt />,
  },
  {
    title: "Ship",
    heading: "Watch it open the pull request",
    body: "It writes the fix, runs your tests and deploys a preview. You get a PR with the reasoning attached.",
    art: <ShipArt />,
  },
  {
    title: "Learn",
    heading: "It gets faster every week",
    body: "Every review teaches it your conventions. Most teams see time to merge drop within the first month.",
    art: <LearnArt />,
  },
];

// For a full-page section, drop the scroll box and `containerRef`: the deck
// then pins against the window.
export function StickyDeckDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const deckRef = React.useRef<StickyDeckApi>(null);
  const [active, setActive] = React.useState(0);

  return (
    <div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
      <div
        ref={scrollRef}
        className="@container relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain"
      >
        <div className="mx-auto max-w-4xl px-4 @lg:px-8">
          <header className="pt-14 pb-12 @lg:pt-20">
            <p className="font-mono text-[11px] tracking-widest text-zinc-500 uppercase">How it works</p>
            <h2 className="mt-3 max-w-md text-3xl/tight font-semibold tracking-tight text-balance text-zinc-900 dark:text-zinc-50">
              From a connected repo to a merged fix in four steps
            </h2>
            <p className="mt-3 max-w-md text-sm/6 text-pretty text-zinc-500">
              Scroll through the deck. Click an edge that peeks out above the front card to go back to it.
            </p>
          </header>

          <div className="grid gap-8 @3xl:grid-cols-[10rem_1fr]">
            <nav aria-label="Steps" className="sticky top-24 hidden self-start @3xl:block">
              <ol className="space-y-1">
                {steps.map((step, index) => (
                  <li key={step.title}>
                    <button
                      type="button"
                      onClick={() => deckRef.current?.scrollTo(index)}
                      aria-current={active === index ? "step" : undefined}
                      className={cn(
                        "group flex w-full items-center gap-3 rounded-lg py-1.5 text-left text-sm transition-colors duration-200",
                        active === index
                          ? "text-zinc-900 dark:text-zinc-50"
                          : "text-zinc-400 hover:text-zinc-600 dark:text-zinc-600 dark:hover:text-zinc-400",
                      )}
                    >
                      <span className="font-mono text-[11px] tabular-nums">{String(index + 1).padStart(2, "0")}</span>
                      <span className="font-medium">{step.title}</span>
                      <span
                        className={cn(
                          "ml-auto h-px bg-current transition-[width] duration-300 ease-out",
                          active === index ? "w-6" : "w-0",
                        )}
                      />
                    </button>
                  </li>
                ))}
              </ol>
            </nav>

            <StickyDeck containerRef={scrollRef} apiRef={deckRef} onActiveChange={setActive} top={96}>
              {steps.map((step, index) => (
                <StickyDeckCard
                  key={step.title}
                  className="flex h-[min(16rem,calc(var(--demo-height,36rem)-8.5rem))] flex-col @md:h-[min(22rem,calc(var(--demo-height,36rem)-8.5rem))] gap-6 overflow-hidden rounded-2xl bg-white p-6 shadow-[0_-12px_32px_-16px_rgb(0_0_0/0.25)] ring-1 ring-zinc-900/[0.07] @2xl:flex-row @2xl:items-stretch @2xl:p-8 dark:bg-zinc-900 dark:shadow-[0_-12px_32px_-12px_rgb(0_0_0/0.8)] dark:ring-white/10"
                >
                  <div className="flex min-w-0 flex-1 flex-col">
                    <p className="flex items-center gap-2 font-mono text-[11px] tracking-widest text-zinc-500 uppercase">
                      <span className="tabular-nums">{String(index + 1).padStart(2, "0")}</span>
                      <span className="h-px w-4 bg-zinc-300 dark:bg-zinc-700" />
                      {step.title}
                    </p>
                    <h3 className="mt-4 text-xl/snug font-semibold tracking-tight text-balance text-zinc-900 @2xl:text-2xl/snug dark:text-zinc-50">
                      {step.heading}
                    </h3>
                    <p className="mt-2 max-w-sm text-sm/6 text-pretty text-zinc-500">{step.body}</p>
                  </div>
                  <div className="hidden min-h-0 flex-1 @md:flex @2xl:max-w-[18rem]">{step.art}</div>
                </StickyDeckCard>
              ))}
            </StickyDeck>
          </div>

          <footer className="flex h-[calc(var(--demo-height,36rem)-6rem)] flex-col items-center justify-center text-center">
            <h2 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
              Your first PR is ten minutes away
            </h2>
            <a
              href="#"
              className="mt-5 rounded-full bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              Connect a repo
            </a>
          </footer>
        </div>
      </div>
    </div>
  );
}

/* ── Card art: small pieces of product UI, not screenshots ── */

const panel =
  "flex w-full flex-col rounded-xl bg-zinc-50 p-3 ring-1 ring-zinc-900/[0.06] dark:bg-zinc-950 dark:ring-white/[0.06]";

function ConnectArt() {
  const tools = [
    { name: "GitHub", mark: "bg-zinc-900 dark:bg-zinc-100", on: true },
    { name: "Slack", mark: "bg-violet-500", on: true },
    { name: "Linear", mark: "bg-indigo-500", on: true },
    { name: "Postgres", mark: "bg-sky-600", on: false },
    { name: "Sentry", mark: "bg-rose-500", on: false },
  ];
  return (
    <div className={panel}>
      <p className="px-1 pb-2 text-[11px] font-medium text-zinc-500">Integrations</p>
      <ul className="flex flex-1 flex-col justify-between gap-1">
        {tools.map((tool) => (
          <li
            key={tool.name}
            className="flex items-center gap-2.5 rounded-lg bg-white px-2.5 py-2 text-xs ring-1 ring-zinc-900/[0.05] dark:bg-zinc-900 dark:ring-white/[0.05]"
          >
            <span className={cn("size-4 rounded-[5px]", tool.mark)} />
            <span className="font-medium text-zinc-800 dark:text-zinc-200">{tool.name}</span>
            {tool.on ? (
              <span className="ml-auto flex items-center gap-1.5 text-zinc-500">
                <span className="size-1.5 rounded-full bg-emerald-500" />
                Connected
              </span>
            ) : (
              <span className="ml-auto rounded-md bg-zinc-100 px-2 py-0.5 font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
                Connect
              </span>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

function ConfigureArt() {
  const rules = [
    { label: "Draft PRs for small fixes", on: true },
    { label: "Ask before touching migrations", on: true },
    { label: "Post summaries to #eng", on: false },
  ];
  return (
    <div className={cn(panel, "justify-between gap-3")}>
      <div>
        <p className="px-1 pb-2 text-[11px] font-medium text-zinc-500">Autonomy</p>
        <div className="grid grid-cols-3 rounded-lg bg-zinc-200/70 p-0.5 text-center text-[11px] font-medium dark:bg-zinc-800">
          <span className="py-1 text-zinc-500">Suggest</span>
          <span className="rounded-md bg-white py-1 text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-zinc-50">
            Draft
          </span>
          <span className="py-1 text-zinc-500">Merge</span>
        </div>
      </div>
      <ul className="space-y-1">
        {rules.map((rule) => (
          <li
            key={rule.label}
            className="flex items-center justify-between gap-3 rounded-lg bg-white px-2.5 py-2 text-xs text-zinc-700 ring-1 ring-zinc-900/[0.05] dark:bg-zinc-900 dark:text-zinc-300 dark:ring-white/[0.05]"
          >
            {rule.label}
            <span
              className={cn(
                "flex h-4 w-7 shrink-0 items-center rounded-full p-0.5",
                rule.on ? "justify-end bg-emerald-500" : "bg-zinc-300 dark:bg-zinc-700",
              )}
            >
              <span className="size-3 rounded-full bg-white shadow-sm" />
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

function ShipArt() {
  const lines = [
    { mark: "✓", text: "Reproduced #1184 in a test", tone: "text-emerald-500" },
    { mark: "✓", text: "Patched src/billing/proration.ts", tone: "text-emerald-500" },
    { mark: "✓", text: "312 / 312 tests passed", tone: "text-emerald-500" },
    { mark: "✓", text: "Preview deployed", tone: "text-emerald-500" },
    { mark: "→", text: "Opened PR #482 for review", tone: "text-sky-500" },
  ];
  return (
    <div className="flex w-full flex-col justify-between rounded-xl bg-zinc-950 p-3 font-mono text-[11px] text-zinc-300 ring-1 ring-white/10">
      <p className="flex items-center gap-1.5 pb-2 text-zinc-500">
        <span className="size-1.5 rounded-full bg-emerald-500" />
        agent · fix/proration-rounding
      </p>
      <ul className="space-y-1.5">
        {lines.map((line) => (
          <li key={line.text} className="flex gap-2">
            <span className={line.tone}>{line.mark}</span>
            <span className="truncate">{line.text}</span>
          </li>
        ))}
      </ul>
      <div className="mt-3 h-1 overflow-hidden rounded-full bg-white/10">
        <div className="h-full w-full rounded-full bg-emerald-500" />
      </div>
    </div>
  );
}

function LearnArt() {
  const weeks = [62, 58, 51, 47, 40, 36, 31, 27];
  return (
    <div className={cn(panel, "justify-between")}>
      <div className="px-1">
        <p className="text-[11px] font-medium text-zinc-500">Median time to merge</p>
        <p className="mt-1 flex items-baseline gap-2">
          <span className="text-2xl font-semibold tracking-tight text-zinc-900 tabular-nums dark:text-zinc-50">
            2.7h
          </span>
          <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">−56%</span>
        </p>
      </div>
      <div className="flex h-24 items-end gap-1.5 px-1">
        {weeks.map((value, index) => (
          <span
            key={index}
            style={{ height: `${value * 1.4}%` }}
            className={cn(
              "flex-1 rounded-t-[4px]",
              index === weeks.length - 1 ? "bg-emerald-500" : "bg-zinc-200 dark:bg-zinc-800",
            )}
          />
        ))}
      </div>
    </div>
  );
}
```
