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.
@keyframeui
registry/demos/sticky-deck-demo.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>
);
}
Live preview, examples and props: https://keyframeui.com/components/sticky-deck#sticky-deck-demo




















