Sticky Deck
Cards that pin one after another as you scroll. Each new card slides over the last while the deck behind it settles back a layer, scaling down and dimming, so the earlier cards' edges peek out above the front one. Click an edge to bring that card back.
- Scroll
- Cards
- Sticky
Uses motion, clsx, tailwind-merge
How it works
From a connected repo to a merged fix in four steps
Scroll through the deck. Click an edge that peeks out above the front card to go back to it.
01Connect
Plug in the tools you already use
Sign in with GitHub and pick the repos. Slack, Linear and your database connect in a click, read-only until you say otherwise.
Integrations
- GitHubConnected
- SlackConnected
- LinearConnected
- PostgresConnect
- SentryConnect
02Configure
Tell it how your team works
Set how far the agent goes on its own, who reviews what, and where it reports. Plain toggles, no YAML.
Autonomy
- Draft PRs for small fixes
- Ask before touching migrations
- Post summaries to #eng
03Ship
Watch it open the pull request
It writes the fix, runs your tests and deploys a preview. You get a PR with the reasoning attached.
agent · fix/proration-rounding
- ✓Reproduced #1184 in a test
- ✓Patched src/billing/proration.ts
- ✓312 / 312 tests passed
- ✓Preview deployed
- →Opened PR #482 for review
04Learn
It gets faster every week
Every review teaches it your conventions. Most teams see time to merge drop within the first month.
Median time to merge
2.7h−56%
"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>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/sticky-deck.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/sticky-deckInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/sticky-deck.tsx"use client"; import * as React from "react"; import { motion, useMotionValue, useMotionValueEvent, useReducedMotion, useScroll, useSpring, useTransform, type MotionValue, } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * STICKY DECK STORYBOARD (driven by scroll, not time) * * arrive a card scrolls up with the page, 1:1, and pins at * `top`. Its travel is native `position: sticky`, so * it never lags the finger or the wheel * cover the next card's top edge reaches this card's bottom * edge, and this card starts settling back one layer * in step with the next card's travel: * rises `offsetStep`, so its top edge peeks out * scales toward `scaleTo` from that top edge * dims toward `dimTo` brightness * it lands back there the moment the next card pins * deeper every card already in the deck settles one more * layer at the same time; scale and dim compound * past beyond `depth` layers a card slides under the one * depth in front of it and fades out * end the last card pins and the whole deck holds for * `hold`, then scrolls away together * * pointer over a buried card's edge: it lifts half a step. * Click it and the deck scrolls back to that card * keyboard focus inside a card scrolls the deck to it * reduced no rise, scale or lift; cards stay sticky and dim * ───────────────────────────────────────────────────────── */ const DECK = { top: 96, // px from the top of the scrollport to the front card offsetStep: 16, // px each buried card peeks out above the one in front scaleTo: 0.94, // scale one layer back; compounds per layer dimTo: 0.86, // brightness one layer back; compounds per layer depth: 3, // layers that stay visible behind the front card hold: "20vh", // scroll the finished deck stays pinned before it moves on lift: 0.5, // share of offsetStep a buried card rises under the pointer buried: 0.5, // layers of cover from which a card's edge is clickable }; const LIFT_SPRING = { visualDuration: 0.3, bounce: 0.25 }; /** Lets you drive the deck from outside, e.g. a list of steps beside it. */ export interface StickyDeckApi { /** Scrolls until this card is at the front of the deck. */ scrollTo: (index: number) => void; } type Geometry = { /** Each card's resting (unpinned) top edge, from the deck's border edge, in px. */ offsets: number[]; heights: number[]; /** The cards' `top`, resolved to px. */ top: number; }; type Deck = { /** * Where the deck is, in cards: -1 before the first card arrives, i when card * i has just pinned, i + 0.5 when the next card covers half of it. */ position: MotionValue<number>; top: number | string; offsetStep: number; scaleTo: number; dimTo: number; depth: number; reduced: boolean; register: (index: number, element: HTMLElement | null) => () => void; scrollTo: (index: number) => void; }; type Card = { index: number; /** 0 → 1 as the card travels into place; 1 once it pins. */ progress: MotionValue<number>; /** How many cards cover it: 0 at the front, 1 one layer back, fractional in between. */ depth: MotionValue<number>; }; const DeckContext = React.createContext<Deck | null>(null); const IndexContext = React.createContext(-1); const CardContext = React.createContext<Card | null>(null); function useDeck() { const deck = React.useContext(DeckContext); if (!deck) throw new Error("<StickyDeckCard> must be rendered inside <StickyDeck>."); return deck; } /* ── StickyDeck ─────────────────────────────────────────── */ export interface StickyDeckProps extends React.HTMLAttributes<HTMLDivElement> { /** * Where the front card pins, from the top of the scrollport. A number is in * px; a string is any CSS length. Leave room above it for the edges that * peek out: `depth * offsetStep`. */ top?: number | string; /** How far each buried card's top edge peeks out above the card in front, in px. */ offsetStep?: number; /** Scale of the card one layer back. Each further layer multiplies it again. */ scaleTo?: number; /** Brightness of the card one layer back, from 0 to 1. Each further layer multiplies it again. */ dimTo?: number; /** How many cards stay visible behind the front one. Deeper cards fade out. */ depth?: number; /** * How much more scrolling the finished deck stays pinned for, after the last * card lands and the deck's gap passes, before it moves on with the page. A * number is in px; a string is any CSS length. */ hold?: number | string; /** * Scroll container to track. Defaults to the window. Pass it when the deck * scrolls inside an `overflow-y-auto` box. */ containerRef?: React.RefObject<HTMLElement | null>; /** Called with the index of the front card whenever it changes. */ onActiveChange?: (index: number) => void; /** Receives `{ scrollTo(index) }`, to bring a card to the front from outside the deck. */ apiRef?: React.Ref<StickyDeckApi>; } /** * A column of cards that pin one after another as you scroll. Each new card * slides over the last while the deck behind it settles back a layer, so the * buried cards' top edges peek out above the front one. Give the cards the * same height. */ export function StickyDeck({ top = DECK.top, offsetStep = DECK.offsetStep, scaleTo = DECK.scaleTo, dimTo = DECK.dimTo, depth = DECK.depth, hold = DECK.hold, containerRef, onActiveChange, apiRef, className, children, ...props }: StickyDeckProps) { const rootRef = React.useRef<HTMLDivElement>(null); const cardsRef = React.useRef<(HTMLElement | null)[]>([]); const geometryRef = React.useRef<Geometry>({ offsets: [], heights: [], top: 0 }); const observerRef = React.useRef<ResizeObserver | null>(null); const reduced = useReducedMotion() ?? false; const position = useMotionValue(-1); const { scrollY } = useScroll({ container: containerRef }); const cards = React.Children.toArray(children); const count = cards.length; /** The top of the scrollport, in viewport px. Null until the container mounts. */ const viewTop = React.useCallback(() => { if (!containerRef) return 0; const container = containerRef.current; return container ? container.getBoundingClientRect().top + container.clientTop : null; }, [containerRef]); // Reads the deck's live position, so content shifting above it never leaves // the cards out of step. Called on every scroll frame: one or two rect reads. const update = React.useCallback(() => { const root = rootRef.current; const view = viewTop(); const { offsets, heights, top } = geometryRef.current; if (!root || view === null || !offsets.length) return; const deckTop = root.getBoundingClientRect().top - view; // Card i arrives over the distance of the card it covers: it starts when // its top edge meets that card's bottom edge and ends when it pins. let next = -1; for (let i = 0; i < offsets.length; i++) { const span = Math.max(heights[i - 1] ?? heights[i], 1); next += clamp((top + span - (deckTop + offsets[i])) / span, 0, 1); } position.set(next); }, [position, viewTop]); // Resting positions come from the flex column's own arithmetic. A pinned // card's rect (and offsetTop) already includes its sticky shift. const measure = React.useCallback(() => { const root = rootRef.current; const elements = cardsRef.current.filter((element): element is HTMLElement => !!element); if (!root || !elements.length) return; const deckStyle = getComputedStyle(root); const gap = px(deckStyle.rowGap); let y = px(deckStyle.borderTopWidth) + px(deckStyle.paddingTop); const offsets: number[] = []; const heights: number[] = []; elements.forEach((element, i) => { const style = getComputedStyle(element); y += (i ? gap : 0) + px(style.marginTop); offsets.push(y); heights.push(element.offsetHeight); y += element.offsetHeight + px(style.marginBottom); }); geometryRef.current = { offsets, heights, top: px(getComputedStyle(elements[0]).top) }; update(); }, [update]); const register = React.useCallback( (index: number, element: HTMLElement | null) => { if (!element) return () => {}; cardsRef.current[index] = element; // Observing reports the size once, which measures the deck again. observerRef.current?.observe(element); return () => { if (cardsRef.current[index] === element) cardsRef.current[index] = null; observerRef.current?.unobserve(element); }; }, [], ); const scrollTo = React.useCallback( (index: number) => { const root = rootRef.current; const view = viewTop(); const { offsets, top } = geometryRef.current; if (!root || view === null || offsets[index] === undefined) return; // The card is at the front when its resting top edge sits at `top`. const delta = root.getBoundingClientRect().top - view + offsets[index] - top; if (Math.abs(delta) < 1) return; const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"; const container = containerRef?.current; if (container) container.scrollTo({ top: container.scrollTop + delta, behavior }); else window.scrollTo({ top: window.scrollY + delta, behavior }); }, [containerRef, viewTop], ); React.useImperativeHandle(apiRef, () => ({ scrollTo }), [scrollTo]); // Passive effect: by now a parent's containerRef is attached too. React.useEffect(() => { const root = rootRef.current; if (!root) return; const observer = new ResizeObserver(() => measure()); observer.observe(root); cardsRef.current.forEach((element) => element && observer.observe(element)); observerRef.current = observer; window.addEventListener("resize", measure); measure(); return () => { observer.disconnect(); observerRef.current = null; window.removeEventListener("resize", measure); }; }, [measure]); useMotionValueEvent(scrollY, "change", update); const activeRef = React.useRef(0); useMotionValueEvent(position, "change", (value) => { const index = clamp(Math.round(value), 0, Math.max(count - 1, 0)); if (index === activeRef.current) return; activeRef.current = index; onActiveChange?.(index); }); const deck = React.useMemo<Deck>( () => ({ position, top, offsetStep, scaleTo, dimTo, depth, reduced, register, scrollTo }), [position, top, offsetStep, scaleTo, dimTo, depth, reduced, register, scrollTo], ); return ( <DeckContext.Provider value={deck}> <div ref={rootRef} role="list" className={cn("isolate flex flex-col gap-8", className)} {...props}> {cards.map((card, index) => ( <IndexContext.Provider key={React.isValidElement(card) ? card.key : index} value={index}> {card} </IndexContext.Provider> ))} {/* Sticky cards stay pinned until the deck's content box ends. Padding doesn't extend it and a card's own margin counts against that card, so the hold is a spacer. */} {hold ? <div aria-hidden className="shrink-0" style={{ height: hold }} /> : null} </div> </DeckContext.Provider> ); } /* ── StickyDeckCard ─────────────────────────────────────── */ // The card is a motion.div, whose drag and animation handlers differ from the DOM's. export type StickyDeckCardProps = Omit< React.HTMLAttributes<HTMLDivElement>, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" >; /** * One card in the deck. Style it like any card: background, rounding, padding * and a fixed height. The dimming layer follows its rounded corners. */ export function StickyDeckCard({ className, style, children, onClickCapture, onFocus, onPointerEnter, onPointerMove, onPointerLeave, ...props }: StickyDeckCardProps) { const deck = useDeck(); const index = React.useContext(IndexContext); const ref = React.useRef<HTMLDivElement>(null); const { position, top, offsetStep, scaleTo, dimTo, depth: layers, reduced, register, scrollTo } = deck; React.useLayoutEffect(() => register(index, ref.current), [register, index]); const progress = useTransform(position, (p) => clamp(p - index + 1, 0, 1)); const depth = useTransform(position, (p) => Math.max(0, p - index)); // 0 → 1 while a mouse is over the card's peeking edge. Sinks back as the // card comes to the front again. const lift = useSpring(0, LIFT_SPRING); // Only an edge the pointer meets while the card is buried lifts. A resting // pointer that the deck scrolls under would otherwise nudge every card it // passes. const raise = (event: React.PointerEvent) => { if (event.pointerType === "mouse" && depth.get() >= DECK.buried) lift.set(1); }; const transform = useTransform([depth, lift], ([d, l]: number[]) => { if (reduced || (d === 0 && l === 0)) return "none"; const rise = (Math.min(d, layers) + l * Math.min(d, 1) * DECK.lift) * offsetStep; const scale = scaleTo ** Math.min(d, layers + 1); return `translateY(${-rise}px) scale(${scale})`; }); const opacity = useTransform(depth, (d) => clamp(layers + 1 - d, 0, 1)); const pointerEvents = useTransform(opacity, (o) => (o > 0 ? "auto" : "none")); const shade = useTransform(depth, (d) => 1 - dimTo ** Math.min(d, layers + 1)); // A buried card's peeking edge is a handle back to it. useMotionValueEvent(depth, "change", (d) => { const element = ref.current; const buried = d >= DECK.buried && d < layers + 1; if (element && element.hasAttribute("data-buried") !== buried) { element.toggleAttribute("data-buried", buried); } }); const card = React.useMemo<Card>(() => ({ index, progress, depth }), [index, progress, depth]); return ( <CardContext.Provider value={card}> <motion.div ref={ref} role="listitem" className={cn( "sticky origin-top will-change-transform data-buried:cursor-pointer", className, )} style={{ ...style, top, transform, opacity, pointerEvents }} onClickCapture={(event) => { // A real click (not Enter on a focused link) on a buried card brings it back. if (event.detail > 0 && depth.get() >= DECK.buried) { event.preventDefault(); event.stopPropagation(); scrollTo(index); } onClickCapture?.(event); }} onFocus={(event) => { // Tabbing into a card the deck hides, or hasn't dealt yet, brings it forward. if (event.target.matches(":focus-visible") && Math.abs(position.get() - index) > 0.02) { scrollTo(index); } onFocus?.(event); }} onPointerEnter={(event) => { raise(event); onPointerEnter?.(event); }} onPointerMove={(event) => { raise(event); onPointerMove?.(event); }} onPointerLeave={(event) => { lift.set(0); onPointerLeave?.(event); }} {...props} > {children} <motion.span aria-hidden className="pointer-events-none absolute inset-0 z-10 m-0 block rounded-[inherit] bg-black" style={{ opacity: shade }} /> </motion.div> </CardContext.Provider> ); } /* ── useStickyDeckCard ──────────────────────────────────── */ /** * The card a component sits in, for effects inside it: `progress` runs 0 → 1 * as the card travels into place, and `depth` counts the cards covering it. */ export function useStickyDeckCard() { const card = React.useContext(CardContext); if (!card) throw new Error("useStickyDeckCard() must be called inside <StickyDeckCard>."); return card; } /* ── helpers ────────────────────────────────────────────── */ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function px(value: string) { return parseFloat(value) || 0; }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Sticky Deck. Each one installs whole, with the component included.
Case Studies
A studio portfolio on photo cards, with tighter edges and a deeper dim for a dark page. Each photo settles from a slight zoom as its card lands, driven by `useStickyDeckCard()`, and the counter at the top rolls to the front card's number.
Five launches, start to finish
Brand, product and launch work for teams who ship. Tap a photo's edge to go back to it.
Identity · Shop
A coffee brand that sells out every Tuesday
Launch · 3D
The launch site for a carbon racer
Product · iOS
Booking a Kyoto ryokan in three taps
Commerce · AR
Furniture you can sit in before it ships
Identity · Motion
A brand system that bends with the light
"use client";
import * as React from "react";
import { AnimatePresence, easeOut, motion, useReducedMotion, useTransform } from "motion/react";
import { StickyDeck, StickyDeckCard, useStickyDeckCard } from "@/components/ui/sticky-deck";
const projects = [
{
client: "Lumen Roasters",
headline: "A coffee brand that sells out every Tuesday",
services: "Identity · Shop",
year: 2026,
stat: "+62% repeat orders",
photo: "1511920170033-f8396924c348",
alt: "An espresso being pulled into a small glass",
},
{
client: "Atlas Run",
headline: "The launch site for a carbon racer",
services: "Launch · 3D",
year: 2025,
stat: "1.2M visits in launch week",
photo: "1542291026-7eec264c27ff",
alt: "A red running shoe on a red background",
},
{
client: "Kōen Travel",
headline: "Booking a Kyoto ryokan in three taps",
services: "Product · iOS",
year: 2025,
stat: "4.9 on the App Store",
photo: "1545569341-9eb8b30979d9",
alt: "A pagoda above the rooftops of Kyoto at dusk",
},
{
client: "Moss & Velvet",
headline: "Furniture you can sit in before it ships",
services: "Commerce · AR",
year: 2024,
stat: "31% fewer returns",
photo: "1555041469-a586c61ea9bc",
alt: "A green velvet sofa on a pale wooden floor",
},
{
client: "Prism Labs",
headline: "A brand system that bends with the light",
services: "Identity · Motion",
year: 2024,
stat: "212 brand assets",
photo: "1557672172-298e090bd0f1",
alt: "Swirls of liquid marble in blue and pink",
},
];
const photo = (id: string) => `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=1400&h=900&q=80`;
export function StickyDeckCaseStudiesDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(0);
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950 text-white">
<div ref={scrollRef} className="relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
<div className="sticky top-0 z-10 flex items-center justify-between bg-linear-to-b from-zinc-950 from-60% to-transparent px-5 pt-4 pb-6 font-mono text-[11px] tracking-widest text-white/50 uppercase sm:px-8">
<span>Selected work</span>
<span className="flex tabular-nums">
<span className="relative inline-flex h-4 w-[2ch] overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={active}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ type: "spring", visualDuration: 0.35, bounce: 0 }}
className="text-white"
>
{String(active + 1).padStart(2, "0")}
</motion.span>
</AnimatePresence>
</span>
/ {String(projects.length).padStart(2, "0")}
</span>
</div>
<div className="mx-auto max-w-5xl px-4 sm:px-8">
<header className="pt-10 pb-14 sm:pt-16">
<h2 className="max-w-lg text-4xl/[1.05] font-semibold tracking-tight text-balance sm:text-5xl/[1.05]">
Five launches, start to finish
</h2>
<p className="mt-4 max-w-sm text-sm/6 text-pretty text-white/55">
Brand, product and launch work for teams who ship. Tap a photo's edge to go back to it.
</p>
</header>
<StickyDeck
containerRef={scrollRef}
onActiveChange={setActive}
top={112}
offsetStep={14}
scaleTo={0.92}
dimTo={0.62}
hold={120}
className="gap-10"
>
{projects.map((project) => (
<StickyDeckCard
key={project.client}
className="h-[min(26rem,calc(var(--demo-height,36rem)-9.5rem))] overflow-hidden rounded-3xl bg-zinc-800 shadow-[0_-16px_40px_-12px_rgb(0_0_0/0.7)] ring-1 ring-white/10"
>
<ProjectPhoto src={photo(project.photo)} alt={project.alt} />
<div className="absolute inset-0 bg-linear-to-t from-black/85 via-black/25 to-black/10" />
<div className="relative flex h-full flex-col justify-between p-5 sm:p-8">
<div className="flex items-center justify-between gap-3 text-xs text-white/75">
<span className="rounded-full bg-black/30 px-2.5 py-1 font-medium backdrop-blur-md">
{project.client}
</span>
<span className="font-mono tabular-nums">{project.year}</span>
</div>
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<div className="max-w-md">
<p className="font-mono text-[11px] tracking-widest text-white/60 uppercase">
{project.services}
</p>
<h3 className="mt-2 text-2xl/tight font-semibold tracking-tight text-balance sm:text-3xl/tight">
{project.headline}
</h3>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-white/70">{project.stat}</span>
<a
href="#"
className="group flex items-center gap-1.5 rounded-full bg-white px-3.5 py-1.5 text-sm font-medium text-zinc-900 transition-colors hover:bg-white/85"
>
Case study
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden
className="size-3.5 transition-transform duration-200 ease-out group-hover:translate-x-0.5"
>
<path d="M3 8h10m0 0L9 4m4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
</div>
</div>
</div>
</StickyDeckCard>
))}
</StickyDeck>
<footer className="flex h-[calc(var(--demo-height,36rem)-4rem)] flex-col items-center justify-center text-center">
<p className="font-mono text-[11px] tracking-widest text-white/50 uppercase">Next up</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight">Yours?</h2>
<a
href="#"
className="mt-5 rounded-full border border-white/20 px-4 py-2 text-sm font-medium transition-colors hover:bg-white/10"
>
Start a project
</a>
</footer>
</div>
</div>
</div>
);
}
/** The photo settles from a slight zoom as its card travels into place. */
function ProjectPhoto({ src, alt }: { src: string; alt: string }) {
const { progress } = useStickyDeckCard();
const reduce = useReducedMotion();
const scale = useTransform(progress, [0, 1], reduce ? [1, 1] : [1.18, 1], { ease: easeOut });
return (
<motion.img
src={src}
alt={alt}
draggable={false}
style={{ scale }}
className="absolute inset-0 size-full object-cover"
/>
);
}
Pricing
Plans dealt in order, each one reading "Everything in Pro, plus" as it lands on the plan it builds on. Taller edges keep every tier's color showing at the top of the deck.
Pricing
Start small. Every plan builds on the last.
Starter
$0per month
For trying it out on a side project.
ProMost popular
$24per seat / month
For developers who ship every day.
Everything in Starter, plus
- Unlimited repositories
- 1,000 agent runs a month
- Draft PRs with previews
- Priority queue
Team
$48per seat / month
For teams that review code together.
Everything in Pro, plus
- Shared rules and conventions
- Review routing by code owner
- Usage analytics
- SSO with Google
Enterprise
Custombilled yearly
For organizations with security reviews.
Everything in Team, plus
- SAML SSO and SCIM
- Self-hosted runners
- Audit log export
- 99.9% uptime SLA
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { StickyDeck, StickyDeckCard } from "@/components/ui/sticky-deck";
// Each plan sits on top of the one before it, the way it builds on it.
const plans = [
{
name: "Starter",
price: "$0",
unit: "per month",
blurb: "For trying it out on a side project.",
includes: "What you get",
features: ["1 repository", "50 agent runs a month", "Pull request summaries", "Community support"],
cta: "Start building",
tone: "bg-white text-zinc-900 ring-zinc-900/10 dark:bg-zinc-900 dark:text-zinc-50 dark:ring-white/10",
muted: "text-zinc-500",
check: "text-zinc-900 dark:text-zinc-50",
button: "bg-zinc-900 text-white hover:bg-zinc-700 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200",
},
{
name: "Pro",
price: "$24",
unit: "per seat / month",
blurb: "For developers who ship every day.",
includes: "Everything in Starter, plus",
features: ["Unlimited repositories", "1,000 agent runs a month", "Draft PRs with previews", "Priority queue"],
cta: "Upgrade to Pro",
badge: "Most popular",
tone: "bg-indigo-600 text-white ring-white/10",
muted: "text-indigo-100/75",
check: "text-white",
button: "bg-white text-indigo-700 hover:bg-indigo-50",
},
{
name: "Team",
price: "$48",
unit: "per seat / month",
blurb: "For teams that review code together.",
includes: "Everything in Pro, plus",
features: ["Shared rules and conventions", "Review routing by code owner", "Usage analytics", "SSO with Google"],
cta: "Start a team trial",
tone: "bg-zinc-900 text-white ring-white/10 dark:bg-zinc-800",
muted: "text-zinc-400",
check: "text-emerald-400",
button: "bg-emerald-400 text-zinc-950 hover:bg-emerald-300",
},
{
name: "Enterprise",
price: "Custom",
unit: "billed yearly",
blurb: "For organizations with security reviews.",
includes: "Everything in Team, plus",
features: ["SAML SSO and SCIM", "Self-hosted runners", "Audit log export", "99.9% uptime SLA"],
cta: "Talk to sales",
tone: "bg-linear-to-br from-amber-100 to-orange-200 text-zinc-900 ring-zinc-900/10",
muted: "text-zinc-700/80",
check: "text-orange-700",
button: "bg-zinc-900 text-white hover:bg-zinc-700",
},
];
export function StickyDeckPricingDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-100 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-2xl px-4 sm:px-6">
<header className="pt-14 pb-12 text-center sm:pt-20">
<p className="font-mono text-[11px] tracking-widest text-zinc-500 uppercase">Pricing</p>
<h2 className="mt-3 text-3xl/tight font-semibold tracking-tight text-balance text-zinc-900 dark:text-zinc-50">
Start small. Every plan builds on the last.
</h2>
</header>
<StickyDeck containerRef={scrollRef} top={88} offsetStep={18} scaleTo={0.95} hold={96} className="gap-6">
{plans.map((plan) => (
<StickyDeckCard
key={plan.name}
className={cn(
"grid h-[min(21rem,calc(var(--demo-height,36rem)-7.5rem))] grid-rows-[auto_1fr] gap-x-8 gap-y-5 rounded-[1.75rem] p-6 shadow-[0_-14px_36px_-14px_rgb(0_0_0/0.3)] ring-1 @lg:grid-cols-[1fr_1.1fr] @lg:grid-rows-1 @lg:p-8",
plan.tone,
)}
>
<div className="flex flex-col">
<p className="flex items-center gap-2 text-sm font-medium">
{plan.name}
{plan.badge ? (
<span className="rounded-full bg-white/15 px-2 py-0.5 text-[11px] font-medium ring-1 ring-white/25">
{plan.badge}
</span>
) : null}
</p>
<p className="mt-3 flex items-baseline gap-2">
<span className="text-4xl font-semibold tracking-tight tabular-nums">{plan.price}</span>
<span className={cn("text-sm", plan.muted)}>{plan.unit}</span>
</p>
<p className={cn("mt-2 text-sm/6 text-pretty", plan.muted)}>{plan.blurb}</p>
<a
href="#"
className={cn(
"mt-auto hidden w-fit rounded-full px-4 py-2 text-sm font-medium transition-colors @lg:inline-flex",
plan.button,
)}
>
{plan.cta}
</a>
</div>
<div className="flex min-h-0 flex-col">
<p className={cn("text-xs font-medium", plan.muted)}>{plan.includes}</p>
<ul className="mt-3 space-y-2.5 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
className={cn("mt-0.5 size-4 shrink-0", plan.check)}
>
<path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{feature}
</li>
))}
</ul>
<a
href="#"
className={cn(
"mt-auto inline-flex w-fit rounded-full px-4 py-2 text-sm font-medium transition-colors @lg:hidden",
plan.button,
)}
>
{plan.cta}
</a>
</div>
</StickyDeckCard>
))}
</StickyDeck>
<footer className="flex h-[calc(var(--demo-height,36rem)-6rem)] flex-col items-center justify-center text-center">
<p className="max-w-xs text-sm/6 text-pretty text-zinc-500">
Every plan starts with a 14-day trial of Pro. Change plans any time.
</p>
<a href="#" className="mt-3 text-sm font-medium text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50">
Compare every feature →
</a>
</footer>
</div>
</div>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | None | One StickyDeckCard per card, in order. Give them the same height: a taller card further back would show below the front one. |
top | number | string | 96 | Where the front card pins, from the top of the scrollport. A number is in px; a string is any CSS length, like "calc(var(--header) + 3rem)". Leave room above it for the peeking edges: depth * offsetStep. |
offsetStep | number | 16 | How far each buried card's top edge peeks out above the card in front of it, in px. |
scaleTo | number | 0.94 | Scale of the card one layer back, from its top edge. Each further layer multiplies it again, so the deck recedes. Not applied with reduced motion. |
dimTo | number | 0.86 | Brightness of the card one layer back, from 0 to 1. Each further layer multiplies it again. 1 turns dimming off. |
depth | number | 3 | How many cards stay visible behind the front one. A card pushed further back slides under its neighbor and fades out. |
hold | number | string | "20vh" | How much more scrolling the finished deck stays pinned for once the last card lands, before the whole deck moves on with the page. A number is in px; a string is any CSS length. 0 lets it go straight away. |
containerRef | React.RefObject<HTMLElement | null> | None | The scroll container, when the deck scrolls inside an overflow-y-auto box. Leave it out to pin against the window. |
onActiveChange | (index: number) => void | None | Called with the front card's index whenever it changes: once the next card covers half of it. Use it for a step list, a counter or a progress bar. |
apiRef | React.Ref<StickyDeckApi> | None | Receives { scrollTo(index) }, which scrolls the page (smoothly, or instantly with reduced motion) until that card is at the front. For a step list or a table of contents beside the deck. |
className | string | None | Classes for the deck, a flex column. The gap between cards (gap-8 by default) is also how long each card rests at the front before the next one starts covering it. |
card.className | string | None | StickyDeckCard: classes for the card itself: background, rounding, padding and a fixed height. An upward shadow (shadow-[0_-12px_32px_-16px_rgb(0_0_0/0.25)]) falls on the card behind as it slides over. The dimming layer follows the rounding. |
card.children | React.ReactNode | None | StickyDeckCard: the card's content. Tabbing into a link or button in a hidden card scrolls the deck to it, and clicking a buried card's edge brings it to the front instead of following a link there. |
useStickyDeckCard() | () => { index, progress, depth } | None | Call it in a component inside a StickyDeckCard to tie effects to the deck. progress is a MotionValue that runs 0 → 1 as the card travels into place; depth counts the cards covering it, fractionally while one slides over. |
childrenType
React.ReactNodeOne
StickyDeckCardper card, in order. Give them the same height: a taller card further back would show below the front one.topType
number | stringDefault96Where the front card pins, from the top of the scrollport. A number is in px; a string is any CSS length, like
"calc(var(--header) + 3rem)". Leave room above it for the peeking edges:depth * offsetStep.offsetStepType
numberDefault16How far each buried card's top edge peeks out above the card in front of it, in px.
scaleToType
numberDefault0.94Scale of the card one layer back, from its top edge. Each further layer multiplies it again, so the deck recedes. Not applied with reduced motion.
dimToType
numberDefault0.86Brightness of the card one layer back, from 0 to 1. Each further layer multiplies it again.
1turns dimming off.depthType
numberDefault3How many cards stay visible behind the front one. A card pushed further back slides under its neighbor and fades out.
holdType
number | stringDefault"20vh"How much more scrolling the finished deck stays pinned for once the last card lands, before the whole deck moves on with the page. A number is in px; a string is any CSS length.
0lets it go straight away.containerRefType
React.RefObject<HTMLElement | null>The scroll container, when the deck scrolls inside an
overflow-y-autobox. Leave it out to pin against the window.onActiveChangeType
(index: number) => voidCalled with the front card's index whenever it changes: once the next card covers half of it. Use it for a step list, a counter or a progress bar.
apiRefType
React.Ref<StickyDeckApi>Receives
{ scrollTo(index) }, which scrolls the page (smoothly, or instantly with reduced motion) until that card is at the front. For a step list or a table of contents beside the deck.classNameType
stringClasses for the deck, a flex column. The gap between cards (
gap-8by default) is also how long each card rests at the front before the next one starts covering it.card.classNameType
stringStickyDeckCard: classes for the card itself: background, rounding, padding and a fixed height. An upward shadow (shadow-[0_-12px_32px_-16px_rgb(0_0_0/0.25)]) falls on the card behind as it slides over. The dimming layer follows the rounding.card.childrenType
React.ReactNodeStickyDeckCard: the card's content. Tabbing into a link or button in a hidden card scrolls the deck to it, and clicking a buried card's edge brings it to the front instead of following a link there.useStickyDeckCard()Type
() => { index, progress, depth }Call it in a component inside a
StickyDeckCardto tie effects to the deck.progressis a MotionValue that runs 0 → 1 as the card travels into place;depthcounts the cards covering it, fractionally while one slides over.