Pricing Dock
Plan cards that fold into the comparison table's sticky header as you scroll. Each card folds straight down into its own column: the price shrinks in beside the name, the button narrows, the badge flattens into an accent line, and the cards close into one bar. Click a docked name to see its whole card again.
- Pricing
- Scroll
- Table
Uses motion, clsx, tailwind-merge
Pricing
Start free. Pay as your traffic grows.
Every plan gets the same models and the same speed. Higher tiers raise your limits and add the controls a security review asks for.
"use client";
import * as React from "react";
import { PricingDock, type PricingDockGroup, type PricingDockPlan } from "@/components/ui/pricing-dock";
const plans: PricingDockPlan[] = [
{
id: "free",
name: "Free",
price: 0,
caption: "No card needed",
pitch: "Prototype with every fast model.",
highlights: ["20 requests per minute", "1M tokens a month", "Fast and embedding models", "Community support"],
cta: { label: "Start building", href: "#" },
},
{
id: "pro",
name: "Pro",
price: { monthly: 40, yearly: 32 },
caption: { monthly: "Billed monthly", yearly: "Billed $384 yearly" },
pitch: "For apps with real users in production.",
highlights: ["500 requests per minute", "Reasoning models", "Batch API at half price", "5 team seats"],
featured: true,
badge: "Most popular",
cta: { label: "Start free trial", href: "#" },
},
{
id: "scale",
name: "Scale",
price: { monthly: 400, yearly: 320 },
caption: { monthly: "Billed monthly", yearly: "Billed $3,840 yearly" },
pitch: "High volume, tuned to your own data.",
highlights: ["5,000 requests per minute", "Fine-tuning and evals", "Zero data retention", "Priority support"],
cta: { label: "Upgrade to Scale", href: "#" },
},
{
id: "enterprise",
name: "Enterprise",
price: "Custom",
caption: "Annual contract",
pitch: "Private deployments and custom terms.",
highlights: ["Private model deployments", "SSO, SCIM and audit logs", "Pin data to a region", "99.99% uptime SLA"],
cta: { label: "Contact sales", href: "#" },
},
];
const groups: PricingDockGroup[] = [
{
label: "Usage",
rows: [
{
feature: "Requests per minute",
hint: "Sustained rate across all models. Short bursts up to 2× are allowed.",
values: { free: "20", pro: "500", scale: "5,000", enterprise: "Custom" },
},
{ feature: "Tokens per month", values: { free: "1M", pro: "50M", scale: "1B", enterprise: "Unlimited" } },
{
feature: "Context window",
hint: "The most tokens a single request can read, prompt and files included.",
values: { free: "32K", pro: "200K", scale: "1M", enterprise: "1M" },
},
{ feature: "Batch API", values: { free: false, pro: true, scale: true, enterprise: true } },
{ feature: "Prompt caching", values: { free: false, pro: true, scale: true, enterprise: true } },
],
},
{
label: "Models",
rows: [
{ feature: "Fast models", values: { free: true, pro: true, scale: true, enterprise: true } },
{ feature: "Reasoning models", values: { free: false, pro: true, scale: true, enterprise: true } },
{ feature: "Embeddings and rerank", values: { free: true, pro: true, scale: true, enterprise: true } },
{
feature: "Fine-tuning",
hint: "Train on your own examples and serve the result at the base model's price.",
values: { free: false, pro: false, scale: true, enterprise: true },
},
{ feature: "Private deployments", values: { free: false, pro: false, scale: false, enterprise: true } },
],
},
{
label: "Collaboration",
rows: [
{ feature: "Team seats", values: { free: "1", pro: "5", scale: "25", enterprise: "Unlimited" } },
{ feature: "Shared projects", values: { free: false, pro: true, scale: true, enterprise: true } },
{ feature: "Usage alerts", values: { free: true, pro: true, scale: true, enterprise: true } },
{ feature: "Role-based access", values: { free: false, pro: false, scale: true, enterprise: true } },
],
},
{
label: "Security",
rows: [
{ feature: "SSO (SAML, OIDC)", values: { free: false, pro: false, scale: "Add-on", enterprise: true } },
{
feature: "Data retention",
hint: "How long prompts and outputs are kept for abuse monitoring. Never used for training.",
values: { free: "30 days", pro: "30 days", scale: "Zero", enterprise: "Custom" },
},
{ feature: "SOC 2 Type II report", values: { free: false, pro: true, scale: true, enterprise: true } },
{ feature: "Audit logs", values: { free: false, pro: false, scale: true, enterprise: true } },
{ feature: "Region pinning", values: { free: false, pro: false, scale: "US or EU", enterprise: "Any region" } },
],
},
{
label: "Support",
rows: [
{ feature: "Support", values: { free: "Community", pro: "Email", scale: "Priority, 4h", enterprise: "Dedicated" } },
{ feature: "Uptime SLA", values: { free: false, pro: false, scale: "99.9%", enterprise: "99.99%" } },
],
},
];
// The page scrolls inside a box so the demo fits the docs. On a real page,
// drop the box and `containerRef`, and set `top` to your nav's height.
export function PricingDockDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
return (
<div className="relative isolate w-full overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div ref={scrollRef} className="h-[var(--demo-height,44rem)] overflow-y-auto overscroll-contain">
<nav className="sticky top-0 z-30 flex h-14 items-center gap-6 border-b border-zinc-900/[0.06] bg-white/80 px-5 backdrop-blur-md dark:border-white/[0.06] dark:bg-zinc-950/80">
<span className="flex items-center gap-2 text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
<svg viewBox="0 0 20 20" aria-hidden className="size-5">
<rect width="20" height="20" rx="5" className="fill-zinc-900 dark:fill-white" />
<path d="M5 14a5 5 0 0 1 10 0" fill="none" strokeWidth="2.25" strokeLinecap="round" className="stroke-violet-500" />
</svg>
Arclight
</span>
<span className="hidden gap-5 text-sm text-zinc-500 sm:flex">
<a href="#">Models</a>
<a href="#">Docs</a>
<a href="#" className="text-zinc-900 dark:text-zinc-50">
Pricing
</a>
</span>
<a
href="#"
className="ml-auto rounded-full bg-zinc-900 px-3.5 py-1.5 text-sm font-medium text-white dark:bg-white dark:text-zinc-900"
>
Get an API key
</a>
</nav>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<header className="max-w-xl pt-14 pb-10 sm:pt-20">
<p className="font-mono text-[11px] tracking-widest text-violet-600 uppercase dark:text-violet-400">
Pricing
</p>
<h2 className="mt-3 text-3xl/tight font-semibold tracking-tight text-balance text-zinc-900 sm:text-4xl/tight dark:text-zinc-50">
Start free. Pay as your traffic grows.
</h2>
<p className="mt-3 text-sm/6 text-pretty text-zinc-500">
Every plan gets the same models and the same speed. Higher tiers raise your limits and add the controls
a security review asks for.
</p>
</header>
<PricingDock
containerRef={scrollRef}
top={56}
plans={plans}
groups={groups}
billing={[
{ id: "monthly", label: "Monthly" },
{ id: "yearly", label: "Yearly", note: "−20%" },
]}
intro="Every plan includes the playground, SDKs in nine languages and live usage dashboards."
className="[--dock-accent:var(--color-violet-600)] [--dock-accent-foreground:white] [--dock-surface:white] dark:[--dock-accent:var(--color-violet-500)] dark:[--dock-surface:var(--color-zinc-900)]"
/>
<footer className="flex flex-col items-center py-24 text-center">
<h3 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Need more than 5,000 requests a minute?
</h3>
<p className="mt-2 max-w-sm text-sm/6 text-pretty text-zinc-500">
We set custom limits within a day and can run models in your own cloud.
</p>
<a
href="#"
className="mt-5 rounded-full bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-500"
>
Talk to sales
</a>
</footer>
</div>
</div>
</div>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/pricing-dock.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/pricing-dockInstall 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/pricing-dock.tsx"use client"; import * as React from "react"; import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useScroll, useSpring, useTransform, type MotionValue, type Variants, } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * PRICING DOCK STORYBOARD (driven by scroll, not time) * * rest plan cards sit in a row, each one exactly over its * own column of the comparison table below * dock the cards pin at `top` and fold over the scroll it * takes them to shrink to bar height, so the fold * moves exactly as fast as you scroll and unfolds in * reverse. Pinning is native sticky: nothing lags * the finger or the wheel * flow highlights scroll on with the page and fade as they * slide under the button, top one first * 10–40% the small print under the price fades * 15–45% the pitch lifts and fades * 25–70% the name rises to the bar's top row * 25–85% the price shrinks to 18px and lands beside the * name, its "/mo" tucked in after it * 20–60% the badge lifts and thins to a 2px accent line, * then stretches along the top of its cell * 40–100% the button narrows to a compact one. It is the * same element throughout, so focus never breaks * 0–100% gaps close and corners square off into one bar * 60–100% hairline dividers take over from card outlines * 85–100% the bar gets its background, shadow and border * table never moves: the cards' bottom edge is the bar's, * so it rises to meet the first row. The featured * tint runs down its column; group labels stick * under the bar until the next one pushes them out * peek click a docked name and that card unfolds again in * place, over the table, at its column's width. Esc, * outside or scroll folds it back * hover the column under the pointer glows; its head lifts * phone cards swipe and snap; plan pills pin over a table * that shows one plan, swipe it sideways * reduced no staged fold: the table slides up over the pinned * cards, the compact bar fades in at the dock point, * and peek opens instantly * ───────────────────────────────────────────────────────── */ const DOCK = { compact: 92, // px, height of the docked bar wideAt: 48, // rem of container width from which cards sit over their columns gap: 12, // px between cards before they dock; the `gap` prop radius: 20, // px, card corners before they dock; the `radius` prop nameTop: 14, // px from the bar's top to its first row row: 24, // px, height of that row buttonTop: 50, // px from the bar's top to the docked button buttonHeight: 28, // px buttonPad: 12, // px each side of the docked button's label labelScale: 13 / 14, // docked button label, relative to its full size priceSize: 18, // px, docked price periodSize: 12, // px, docked "/mo" minPriceSize: 13, // px; a long name squeezes the docked price down to this logoSize: 22, // px, docked logo toggleScale: 0.8, // docked billing toggle fade: 28, // px over which body text fades as it slides under the head lift: 2, // px a hovered column's head rises chevron: 44, // px kept clear at the right of the docked bar for the peek chevron }; const STAGE = { caption: [0.1, 0.4], pitch: [0.15, 0.45], name: [0.25, 0.7], price: [0.25, 0.85], badgeText: [0.2, 0.35], badge: [0.2, 0.6], button: [0.4, 1], toggle: [0.25, 0.85], dividers: [0.6, 1], bar: [0.85, 1], } satisfies Record<string, [number, number]>; const PEEK_SPRING = { type: "spring", visualDuration: 0.42, bounce: 0.1 } as const; const LIFT_SPRING = { visualDuration: 0.25, bounce: 0.2 }; const ROLL_SPRING = { type: "spring", visualDuration: 0.5, bounce: 0.08 } as const; const SLIDE_SPRING = { type: "spring", visualDuration: 0.38, bounce: 0.12 } as const; /* ── types ──────────────────────────────────────────────── */ export type PricingDockPrice = number | string; /** What a plan's button does: a link with `href`, or a button with `onClick`. */ export interface PricingDockCta { label: string; href?: string; onClick?: () => void; } export interface PricingDockPlan { /** Keys this plan's column in each row's `values`. */ id: string; name: string; /** * A number is formatted by `formatPrice` and rolls when billing changes. * Text ("Custom") shows as is, without a period. Give one per billing * option as `{ monthly: 24, yearly: 19 }`. */ price?: PricingDockPrice | Record<string, PricingDockPrice>; /** Overrides the period after the price, e.g. "/seat/mo". */ period?: string; /** Small print under the price, one for all billing options or one per option. */ caption?: React.ReactNode | Record<string, React.ReactNode>; /** Shown instead of a price, e.g. a product's logo. Shrinks in beside the name when docked. */ logo?: React.ReactNode; /** One line under the price. */ pitch?: React.ReactNode; /** Three to five points under the button. */ highlights?: React.ReactNode[]; /** Tints the card and runs the tint down its column. */ featured?: boolean; /** Label over the name, e.g. "Most popular". Flattens into an accent line when docked. */ badge?: React.ReactNode; cta?: PricingDockCta; } /** `true` is a check, `false` a dash; anything else renders as is. */ export type PricingDockValue = boolean | React.ReactNode; export interface PricingDockRow { feature: React.ReactNode; /** Opens from a small info icon beside the feature. */ hint?: React.ReactNode; /** One value per plan id. A missing one reads as `false`. */ values: Record<string, PricingDockValue>; } export interface PricingDockGroup { label: string; rows: PricingDockRow[]; } export interface PricingDockBilling { id: string; label: string; /** A short tag beside the label, e.g. "−20%". */ note?: string; /** Period after each price under this option. */ period?: string; } export interface PricingDockProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> { plans: PricingDockPlan[]; groups: PricingDockGroup[]; /** Billing options for the toggle. Leave out for a single price per plan. */ billing?: PricingDockBilling[]; /** The billing option selected at first. Defaults to the first one. */ defaultBilling?: string; onBillingChange?: (id: string) => void; /** Turns a number price into text. Defaults to whole US dollars. */ formatPrice?: (amount: number, plan: PricingDockPlan, billing: string | undefined) => string; /** Period after each number price, unless a billing option or plan sets its own. */ period?: string; /** Content in the top-left cell, under the toggle, before the cards dock. */ intro?: React.ReactNode; /** Label of the button that scrolls to where the cards have docked. `null` hides it. */ compareLabel?: string | null; /** Screen-reader header of the feature column. */ featureLabel?: string; /** Where the docked bar pins, in px from the top of the scrollport: your fixed nav's height, or 0. */ top?: number; /** Corner radius of the cards before they dock, in px. Squares off as they dock. */ radius?: number; /** Space between the cards before they dock, in px. Closes as they dock. */ gap?: number; /** Scroll container to track. Defaults to the window. Pass it when the dock scrolls inside a box. */ containerRef?: React.RefObject<HTMLElement | null>; } /* ── shared state ───────────────────────────────────────── */ type Geometry = { wide: boolean; /** Scroll it takes to dock: the cards' full height less the bar's. */ fold: number; /** The cards' full height. */ full: number; /** The tallest head (name to button). */ head: number; /** Each column's left edge and the row's right edge, from the row's left edge. */ cols: number[]; }; const NO_GEOMETRY: Geometry = { wide: false, fold: 0, full: 0, head: 0, cols: [] }; type Measurer = { /** Natural height of the head, read before anything is measured from it. */ head: () => number; measure: (wide: boolean) => void; }; type Dock = { geo: MotionValue<Geometry>; /** Scroll into the fold, in px: 0 at rest, `fold` once docked. */ u: MotionValue<number>; /** Fold progress, 0 → 1. Jumps between the two with reduced motion. */ t: MotionValue<number>; /** Fades the heads back in after a reduced-motion swap. */ swap: MotionValue<number>; hoverCol: MotionValue<number>; reduced: boolean; radius: number; gap: number; docked: boolean; peek: number | null; togglePeek: (index: number) => void; register: (key: string, measurer: Measurer) => () => void; observe: (elements: (Element | null)[]) => () => void; }; const DockContext = React.createContext<Dock | null>(null); function useDock() { const dock = React.useContext(DockContext); if (!dock) throw new Error("PricingDock parts must be rendered inside <PricingDock>."); return dock; } /** Fades body text by its distance to the line it slides under. */ type Column = { fade: (index: number) => number; deps: Dep[] }; const ColumnContext = React.createContext<Column>({ fade: () => 1, deps: [] }); const subscribe = () => () => {}; /** Any motion value, whatever it holds. */ type Dep = { get(): unknown }; /** * A motion value computed from others. motion only subscribes to the values a * computation reads, so this reads every dependency first: early returns can't * leave one out. */ function useLive<T>(deps: Dep[], compute: () => T) { return useTransform(() => { for (const dep of deps) dep.get(); return compute(); }); } /* ── PricingDock ────────────────────────────────────────── */ /** * Plan cards over a comparison table. As you scroll, the cards fold into the * table's sticky header, each one straight down into its own column, so the * plan names, prices and buttons stay in view all the way down. Click a docked * name to see its full card again. */ export function PricingDock({ plans, groups, billing, defaultBilling, onBillingChange, formatPrice = defaultFormatPrice, period = "/mo", intro, compareLabel = "Compare all features", featureLabel = "Feature", top = 0, radius = DOCK.radius, gap = DOCK.gap, containerRef, className, style, ...props }: PricingDockProps) { const rootRef = React.useRef<HTMLDivElement>(null); const headerRef = React.useRef<HTMLDivElement>(null); const rowRef = React.useRef<HTMLDivElement>(null); const measurers = React.useRef(new Map<string, Measurer>()); const frame = React.useRef(0); const observer = React.useRef<ResizeObserver | null>(null); const swapTarget = React.useRef(0); const dockedRef = React.useRef(false); const peekRef = React.useRef<{ index: number; scroll: number } | null>(null); const settle = React.useRef(0); const drag = React.useRef<{ id: number; x: number; y: number; from: number; on: boolean } | null>(null); const hydrated = React.useSyncExternalStore(subscribe, () => true, () => false); const reduced = (useReducedMotion() ?? false) && hydrated; const geo = useMotionValue<Geometry>(NO_GEOMETRY); const u = useMotionValue(0); const t = useMotionValue(0); const swap = useMotionValue(1); const hoverCol = useMotionValue(-1); const { scrollY } = useScroll({ container: containerRef }); const [billingId, setBillingId] = React.useState(defaultBilling ?? billing?.[0]?.id); const [docked, setDocked] = React.useState(false); const [peek, setPeek] = React.useState<number | null>(null); const featured = Math.max(0, plans.findIndex((plan) => plan.featured)); const [shown, setShown] = React.useState(featured); const strip = useMotionValue(featured); const stripX = useTransform(strip, (value) => `${-value * 100}%`); const option = billing?.find((item) => item.id === billingId); const hasBadge = plans.some((plan) => plan.badge); const hasCaption = plans.some((plan) => plan.caption); const hasFigure = plans.some((plan) => plan.price !== undefined || plan.logo); /** 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]); // One rect read per scroll frame: how far the header has scrolled past `top`. const update = React.useCallback(() => { const g = geo.get(); const header = headerRef.current; const view = viewTop(); if (!g.wide || !g.fold || !header || view === null) { u.set(0); t.set(0); return; } const next = clamp(top - (header.getBoundingClientRect().top - view), 0, g.fold); u.set(next); if (!reduced) { t.set(next / g.fold); return; } // No fold: the table slides up over the pinned cards, and at the dock // point the compact bar fades in. const target = next >= g.fold - 0.5 ? 1 : 0; if (target !== swapTarget.current) { swapTarget.current = target; t.set(target); animate(swap, [0, 1], { duration: 0.16, ease: "easeOut" }); } }, [geo, viewTop, top, reduced, u, t, swap]); const measure = React.useCallback(() => { frame.current = 0; const root = rootRef.current; const row = rowRef.current; if (!root || !row) return; const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; const wide = root.clientWidth >= DOCK.wideAt * rem; if (!wide) { for (const name of ["--dock-head", "--dock-head-h", "--dock-fold"]) root.style.removeProperty(name); measurers.current.forEach((measurer) => measurer.measure(false)); geo.set(NO_GEOMETRY); update(); return; } let head = 0; measurers.current.forEach((measurer) => (head = Math.max(head, measurer.head()))); // The heads pin inside their cells, so they may only take up the bar's // height; the bodies start where the heads' content ends. root.style.setProperty("--dock-head", `${head}px`); root.style.setProperty("--dock-head-h", `${DOCK.compact}px`); const full = row.offsetHeight; const fold = Math.max(0, full - DOCK.compact); root.style.setProperty("--dock-fold", `${fold}px`); const cells = Array.from(row.children) as HTMLElement[]; const cols = cells.map((cell) => cell.offsetLeft); cols.push(row.offsetWidth); geo.set({ wide, fold, full, head, cols }); measurers.current.forEach((measurer) => measurer.measure(true)); update(); }, [geo, update]); const requestMeasure = React.useCallback(() => { if (!frame.current) frame.current = requestAnimationFrame(measure); }, [measure]); const register = React.useCallback( (key: string, measurer: Measurer) => { measurers.current.set(key, measurer); requestMeasure(); return () => { if (measurers.current.get(key) === measurer) measurers.current.delete(key); }; }, [requestMeasure], ); const observe = React.useCallback((elements: (Element | null)[]) => { const live = elements.filter((element): element is Element => !!element); live.forEach((element) => observer.current?.observe(element)); return () => live.forEach((element) => observer.current?.unobserve(element)); }, []); // On a phone, start the swipe row on the plan the table shows. React.useLayoutEffect(() => { const row = rowRef.current; const card = row?.children[featured + 1] as HTMLElement | undefined; if (!row || !card || row.scrollWidth <= row.clientWidth) return; const rowRect = row.getBoundingClientRect(); const cardRect = card.getBoundingClientRect(); row.scrollLeft += cardRect.left + cardRect.width / 2 - rowRect.left - rowRect.width / 2; }, [featured]); React.useLayoutEffect(() => { const ro = new ResizeObserver(() => requestMeasure()); observer.current = ro; if (rootRef.current) ro.observe(rootRef.current); if (rowRef.current) ro.observe(rowRef.current); measure(); document.fonts?.ready.then(requestMeasure); return () => { ro.disconnect(); observer.current = null; cancelAnimationFrame(frame.current); frame.current = 0; }; }, [measure, requestMeasure]); useMotionValueEvent(scrollY, "change", (value) => { update(); const open = peekRef.current; if (open && Math.abs(value - open.scroll) > 6) setPeek(null); }); useMotionValueEvent(t, "change", (value) => { const next = dockedRef.current ? value > 0.9 : value > 0.985; if (next === dockedRef.current) return; dockedRef.current = next; setDocked(next); if (!next) setPeek(null); }); // Keep a ref of the open peek for the scroll handler, and close it on Esc or // a press anywhere else. React.useEffect(() => { peekRef.current = peek === null ? null : { index: peek, scroll: scrollY.get() }; if (peek === null) return; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setPeek(null); }; const onDown = (event: PointerEvent) => { const cell = rowRef.current?.children[peek + 1]; if (cell && !cell.contains(event.target as Node)) setPeek(null); }; document.addEventListener("keydown", onKey); document.addEventListener("pointerdown", onDown); return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onDown); }; }, [peek, scrollY]); const togglePeek = React.useCallback((index: number) => { setPeek((open) => (open === index ? null : index)); }, []); const chooseBilling = (id: string) => { setBillingId(id); onBillingChange?.(id); }; /** Scrolls to where the cards have just finished docking. */ const dockNow = () => { const header = headerRef.current; const view = viewTop(); if (!header || view === null) return; const delta = header.getBoundingClientRect().top - view - (top - geo.get().fold); scrollBy(containerRef?.current ?? null, delta); }; /* phone: one plan's column at a time */ const showPlan = (index: number, { scrollCards = true } = {}) => { const next = clamp(index, 0, plans.length - 1); setShown(next); animate(strip, next, reduced ? { duration: 0 } : SLIDE_SPRING); const row = rowRef.current; const card = row?.children[next + 1] as HTMLElement | undefined; if (!scrollCards || !row || !card || geo.get().wide) return; const rowRect = row.getBoundingClientRect(); const cardRect = card.getBoundingClientRect(); const left = row.scrollLeft + cardRect.left + cardRect.width / 2 - rowRect.left - rowRect.width / 2; row.scrollTo({ left, behavior: reduced ? "instant" : "smooth" }); }; // The card snapped to the middle of the swipe row is the plan the table shows. const onCardsScroll = () => { if (geo.get().wide) return; window.clearTimeout(settle.current); settle.current = window.setTimeout(() => { const row = rowRef.current; if (!row) return; const rowRect = row.getBoundingClientRect(); const middle = rowRect.left + rowRect.width / 2; let best = 0; let distance = Infinity; Array.from(row.children) .slice(1) .forEach((card, index) => { const rect = card.getBoundingClientRect(); const d = Math.abs(rect.left + rect.width / 2 - middle); if (d < distance) { distance = d; best = index; } }); if (best !== strip.get()) showPlan(best, { scrollCards: false }); }, 90); }; React.useEffect(() => () => window.clearTimeout(settle.current), []); const onBodyPointerDown = (event: React.PointerEvent) => { if (geo.get().wide || event.button !== 0) return; drag.current = { id: event.pointerId, x: event.clientX, y: event.clientY, from: strip.get(), on: false }; }; const onBodyPointerMove = (event: React.PointerEvent<HTMLDivElement>) => { const d = drag.current; if (!d || d.id !== event.pointerId) return; const dx = event.clientX - d.x; const dy = event.clientY - d.y; if (!d.on) { if (Math.abs(dx) < 10 || Math.abs(dx) < Math.abs(dy) * 1.2) return; d.on = true; event.currentTarget.setPointerCapture(event.pointerId); } const width = event.currentTarget.querySelector("[data-dock-values]")?.clientWidth || 1; strip.jump(clamp(d.from - dx / width, -0.2, plans.length - 0.8)); }; const onBodyPointerUp = (event: React.PointerEvent) => { const d = drag.current; drag.current = null; if (!d?.on || d.id !== event.pointerId) return; const moved = strip.get() - d.from; const next = Math.abs(moved) > 0.18 ? d.from + Math.sign(moved) : d.from; showPlan(Math.round(next)); }; /* column hover, wide only */ const onPointerMove = (event: React.PointerEvent) => { const g = geo.get(); const row = rowRef.current; if (!g.wide || !row || event.pointerType !== "mouse") return; const x = event.clientX - row.getBoundingClientRect().left; let col = -1; for (let i = 1; i < g.cols.length - 1; i++) { if (x >= g.cols[i] && x < g.cols[i + 1]) col = i - 1; } if (col !== hoverCol.get()) hoverCol.set(col); }; const dock = React.useMemo<Dock>( () => ({ geo, u, t, swap, hoverCol, reduced, radius, gap, docked, peek, togglePeek, register, observe }), [geo, u, t, swap, hoverCol, reduced, radius, gap, docked, peek, togglePeek, register, observe], ); const cols = `minmax(0,1.15fr) repeat(${plans.length}, minmax(0,1fr))`; return ( <DockContext.Provider value={dock}> <div ref={rootRef} role="table" aria-label="Compare plans" aria-colcount={plans.length + 1} className={cn( "@container/dock relative isolate w-full text-foreground", "[--dock-accent:var(--foreground)] [--dock-accent-foreground:var(--background)] [--dock-surface:var(--background)] [--dock-page:var(--background)] [--dock-border:color-mix(in_oklab,var(--foreground)_12%,transparent)]", className, )} style={ { "--dock-top": `${top}px`, "--dock-compact": `${DOCK.compact}px`, "--dock-half-gap": `${gap / 2}px`, "--dock-cols": cols, ...style, } as React.CSSProperties } onPointerMove={onPointerMove} onPointerLeave={() => hoverCol.set(-1)} {...props} > {/* Header: the cards. Sticks once it has scrolled the whole fold past `top`, so the table under it never moves. */} <div ref={headerRef} role="rowgroup" className={cn( "relative z-20 @3xl/dock:sticky @3xl/dock:top-[calc(var(--dock-top)-var(--dock-fold))] @3xl/dock:pt-0", billing && billing.length > 1 && "pt-14", )} > <Bar /> <div ref={rowRef} role="row" onScroll={onCardsScroll} className={cn( "flex snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain px-[calc((100cqi-min(80cqi,19rem))/2)] pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", "@3xl/dock:relative @3xl/dock:grid @3xl/dock:grid-cols-(--dock-cols) @3xl/dock:gap-0 @3xl/dock:overflow-visible @3xl/dock:px-0 @3xl/dock:pb-0", )} > <CornerCell label={featureLabel} billing={billing} billingId={billingId} onBillingChange={chooseBilling} intro={intro} compareLabel={compareLabel} onCompare={dockNow} /> {plans.map((plan, index) => ( <PlanCell key={plan.id} plan={plan} index={index} count={plans.length} billingId={billingId} period={plan.period ?? option?.period ?? period} formatPrice={formatPrice} hasBadge={hasBadge} hasCaption={hasCaption} hasFigure={hasFigure} /> ))} </div> </div> <PlanPills plans={plans} shown={shown} onShow={(index) => showPlan(index)} reduced={reduced} /> <div className="relative touch-pan-y touch-pinch-zoom @3xl/dock:mt-4 @3xl/dock:touch-auto" onPointerDown={onBodyPointerDown} onPointerMove={onBodyPointerMove} onPointerUp={onBodyPointerUp} onPointerCancel={() => (drag.current = null)} > {/* The featured column's tint and the hover glow, behind every row. */} <div aria-hidden className="pointer-events-none absolute inset-0 hidden grid-cols-(--dock-cols) @3xl/dock:grid" > <span /> {plans.map((plan, index) => ( <ColumnGlow key={plan.id} index={index} count={plans.length} featured={!!plan.featured} /> ))} </div> {groups.map((group) => ( <div key={group.label} role="rowgroup" className="relative"> <div role="row" className="sticky top-[calc(var(--dock-top)+3.25rem)] z-10 grid grid-cols-1 border-b border-(--dock-border) bg-(--dock-page) @3xl/dock:top-[calc(var(--dock-top)+var(--dock-compact))] @3xl/dock:grid-cols-(--dock-cols)" > <div role="cell" aria-colspan={plans.length + 1} className="px-4 pt-6 pb-2 text-xs font-medium tracking-wide text-muted-foreground" > {group.label} </div> {plans.map((plan, index) => ( <LabelTint key={plan.id} index={index} count={plans.length} featured={!!plan.featured} /> ))} </div> {group.rows.map((row, rowIndex) => ( <motion.div key={rowIndex} role="row" initial="hidden" whileInView="shown" viewport={{ once: true, amount: 0.6 }} className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] border-b border-(--dock-border)/60 transition-colors duration-150 hover:bg-foreground/[0.025] @3xl/dock:grid-cols-(--dock-cols)" > <div role="rowheader" className="flex items-center px-4 py-3 text-sm text-foreground/80"> <span> {row.feature} {row.hint ? <Hint>{row.hint}</Hint> : null} </span> </div> <div data-dock-values className="overflow-hidden @3xl/dock:contents"> <motion.div className="flex @3xl/dock:contents" style={{ x: stripX }}> {plans.map((plan, col) => ( <div key={plan.id} role="cell" className="flex w-full shrink-0 items-center justify-center px-4 py-3 text-sm @3xl/dock:w-auto @3xl/dock:justify-start @5xl/dock:px-5" > <Value value={row.values[plan.id]} col={col} featured={!!plan.featured} reduced={reduced} /> </div> ))} </motion.div> </div> </motion.div> ))} </div> ))} </div> </div> </DockContext.Provider> ); } const TINT = "bg-[color-mix(in_oklab,var(--dock-accent)_6%,var(--dock-surface))]"; const TINT_PAGE = "bg-[color-mix(in_oklab,var(--dock-accent)_6%,var(--dock-page))]"; /* ── Bar: the docked header's background ───────────────── */ function Bar() { const { t } = useDock(); const opacity = useLive([t], () => ease(stage(t.get(), STAGE.bar))); return ( <motion.div aria-hidden className="pointer-events-none absolute inset-0 hidden border-b border-(--dock-border) bg-(--dock-surface) shadow-[0_12px_24px_-16px_rgb(0_0_0/0.18)] @3xl/dock:block dark:shadow-[0_12px_24px_-12px_rgb(0_0_0/0.6)]" style={{ opacity }} /> ); } /* ── Corner: billing toggle and intro ───────────────────── */ type CornerMetrics = { ready: boolean; toggle: Box | null; /** Scale that fits the toggle in its column at rest; 1 unless its labels are long. */ fit: number; fades: number[]; }; /** The toggle shrinks in place and settles in the middle of the bar. */ function toggleAt(metrics: MotionValue<CornerMetrics>, t: MotionValue<number>) { const m = metrics.get(); const box = m.toggle; if (!m.ready || !box) return null; const e = ease(stage(t.get(), STAGE.toggle)); const docked = Math.min(m.fit, DOCK.toggleScale); const scale = mix(m.fit, docked, e); const dy = ((DOCK.compact - box.h * docked) / 2 - box.y) * e; return { box, scale, dy }; } function CornerCell({ label, billing, billingId, onBillingChange, intro, compareLabel, onCompare, }: { label: string; billing?: PricingDockBilling[]; billingId?: string; onBillingChange: (id: string) => void; intro?: React.ReactNode; compareLabel?: string | null; onCompare: () => void; }) { const { t, u, swap, register } = useDock(); const id = React.useId(); const cellRef = React.useRef<HTMLDivElement>(null); const innerRef = React.useRef<HTMLDivElement>(null); const toggleRef = React.useRef<HTMLDivElement>(null); const metrics = useMotionValue<CornerMetrics>({ ready: false, toggle: null, fit: 1, fades: [] }); React.useLayoutEffect( () => register("corner", { head: () => 0, measure: (wide) => { const cell = cellRef.current; const inner = innerRef.current; const toggle = toggleRef.current; if (!wide || !cell || !inner) return metrics.set({ ready: false, toggle: null, fit: 1, fades: [] }); const pad = parseFloat(getComputedStyle(inner).paddingLeft) || 0; const room = cell.clientWidth - pad * 2; metrics.set({ ready: true, toggle: toggle ? box(toggle, inner) : null, fit: toggle ? Math.min(1, room / Math.max(toggle.offsetWidth, 1)) : 1, fades: fadeTops(cell), }); }, }), [register, metrics], ); const toggleTransform = useLive([metrics, t], () => { const at = toggleAt(metrics, t); return at && (at.dy || at.scale !== 1) ? `translateY(${at.dy}px) scale(${at.scale})` : "none"; }); const column = React.useMemo<Column>( () => ({ fade: (index) => { const m = metrics.get(); const top = m.fades[index]; if (!m.ready || top === undefined) return 1; // Slides under the toggle, or under the top of the bar without one. const at = toggleAt(metrics, t); const line = at ? at.box.y + at.dy + at.box.h * at.scale + 8 : DOCK.nameTop; const rest = at ? top - (at.box.y + at.box.h + 8) : top - DOCK.nameTop; // Gone before the bar forms around it. return fadeBy(top - u.get() - line, rest) * (1 - stage(t.get(), [0.5, 0.8])); }, deps: [metrics, u, t], }), [metrics, u, t], ); return ( <div ref={cellRef} role="columnheader" aria-labelledby={id} className="absolute inset-x-0 top-0 flex justify-center @3xl/dock:relative @3xl/dock:flex-col @3xl/dock:justify-start" > <span id={id} className="sr-only"> {label} </span> <div className="relative z-10 @3xl/dock:sticky @3xl/dock:top-(--dock-top)"> <motion.div ref={innerRef} className="relative px-4 @3xl/dock:pt-5" style={{ opacity: swap }}> {billing && billing.length > 1 ? ( <motion.div ref={toggleRef} className="w-fit origin-top-left" style={{ transform: toggleTransform }}> <BillingToggle options={billing} value={billingId} onChange={onBillingChange} /> </motion.div> ) : null} </motion.div> </div> <ColumnContext.Provider value={column}> <div className="hidden flex-1 flex-col gap-4 px-4 pt-5 pb-6 @3xl/dock:flex"> {intro ? ( <FadeItem index={0} className="text-sm/6 text-pretty text-muted-foreground"> {intro} </FadeItem> ) : null} {compareLabel ? ( <FadeItem index={intro ? 1 : 0} className="mt-auto"> <button type="button" onClick={onCompare} className="group/compare inline-flex items-center gap-1.5 rounded-sm text-sm font-medium text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground" > {compareLabel} <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden className="size-3.5 transition-transform duration-200 group-hover/compare:translate-y-0.5" > <path d="M8 3v10m0 0 4-4m-4 4-4-4" strokeLinecap="round" strokeLinejoin="round" /> </svg> </button> </FadeItem> ) : null} </div> </ColumnContext.Provider> </div> ); } /* ── PlanCell: one card, folding into its header cell ──── */ type Part = { x: number; y: number; w: number; h: number }; /** * The price (or logo) shrinks toward a fixed point, (px, py), so it moves in a * straight line and never pokes out of its card on the way. */ type Figure = Part & { base: number; scale: number; px: number; py: number }; /** "/mo": follows the price's right edge and sits on its baseline. */ type Period = Part & { base: number; scale: number; gap: number }; type PlanMetrics = { ready: boolean; cellW: number; insetL: number; insetR: number; name: Part | null; nameTo: { dx: number; dy: number }; figure: Figure | null; period: Period | null; badge: Part | null; /** The button's slot, with its docked width and label scale. */ slot: (Part & { toW: number; labelTo: number }) | null; fades: number[]; }; const NO_METRICS: PlanMetrics = { ready: false, cellW: 0, insetL: 0, insetR: 0, name: null, nameTo: { dx: 0, dy: 0 }, figure: null, period: null, badge: null, slot: null, fades: [], }; function PlanCell({ plan, index, count, billingId, period, formatPrice, hasBadge, hasCaption, hasFigure, }: { plan: PricingDockPlan; index: number; count: number; billingId?: string; period: string; formatPrice: NonNullable<PricingDockProps["formatPrice"]>; hasBadge: boolean; hasCaption: boolean; hasFigure: boolean; }) { const { geo, u, t, swap, hoverCol, reduced, radius, gap: cardGap, docked, peek, togglePeek, register, observe } = useDock(); const nameId = React.useId(); const cellRef = React.useRef<HTMLDivElement>(null); const innerRef = React.useRef<HTMLDivElement>(null); const nameRef = React.useRef<HTMLSpanElement>(null); const badgeRef = React.useRef<HTMLSpanElement>(null); const figureRef = React.useRef<HTMLSpanElement>(null); const periodRef = React.useRef<HTMLSpanElement>(null); const slotRef = React.useRef<HTMLDivElement>(null); const labelRef = React.useRef<HTMLSpanElement>(null); const metrics = useMotionValue<PlanMetrics>(NO_METRICS); const open = peek === index; const p = useMotionValue(0); const lift = useSpring(0, LIFT_SPRING); React.useEffect(() => { if (reduced) p.jump(open ? 1 : 0); else animate(p, open ? 1 : 0, PEEK_SPRING); }, [open, reduced, p]); useMotionValueEvent(hoverCol, "change", (col) => lift.set(col === index ? 1 : 0)); const price = pick(plan.price, billingId); const amount = typeof price === "number" ? price : null; const priceText = price === undefined ? null : typeof price === "number" ? formatPrice(price, plan, billingId) : price; const caption = pick(plan.caption, billingId); React.useLayoutEffect( () => register(plan.id, { head: () => innerRef.current?.offsetHeight ?? 0, measure: (wide) => { const cell = cellRef.current; const inner = innerRef.current; if (!wide || !cell || !inner) return metrics.set(NO_METRICS); metrics.set( measurePlan({ cell, inner, name: nameRef.current, badge: badgeRef.current, figure: figureRef.current, period: periodRef.current, slot: slotRef.current, label: labelRef.current, logo: !!plan.logo && price === undefined, insetL: index > 0 ? cardGap / 2 : 0, insetR: index < count - 1 ? cardGap / 2 : 0, }), ); }, }), [register, metrics, plan.id, plan.logo, price, index, count, cardGap], ); React.useLayoutEffect( () => observe([innerRef.current, figureRef.current, labelRef.current]), [observe], ); const state = React.useMemo<PlanState>( () => ({ metrics, geo, u, t, p, reduced }), [metrics, geo, u, t, p, reduced], ); const live = React.useMemo<Dep[]>(() => [metrics, geo, u, t, p], [metrics, geo, u, t, p]); const column = React.useMemo<Column>(() => ({ fade: (k) => fadeOf(state, k), deps: live }), [state, live]); /* shell: gaps close, corners square, outline gives way to a divider */ const frame = useLive(live, () => ease(foldOf(state))); // Gaps follow the dock, not the peek: a peeked card keeps its column's width. const gap = useLive(live, () => 1 - ease(t.get())); const shellLeft = useLive(live, () => `calc(var(--dock-inset-l, 0px) * ${gap.get()})`); const shellRight = useLive(live, () => `calc(var(--dock-inset-r, 0px) * ${gap.get()})`); const shellRadius = useLive(live, () => radius * (1 - frame.get())); const shellBottom = useLive(live, () => -dropOf(state)); const ringOpacity = useLive(live, () => 1 - stage(foldOf(state), STAGE.dividers)); const dividerOpacity = useLive(live, () => (geo.get().wide ? stage(foldOf(state), STAGE.dividers) : 0)); const peekShadow = useLive(live, () => p.get() * t.get()); /* head */ const headY = useTransform(lift, (value) => -DOCK.lift * value); const badgeText = useLive(live, () => 1 - stage(foldOf(state), STAGE.badgeText)); const blobX = useLive(live, () => badgeOf(state)?.x ?? 0); const blobY = useLive(live, () => badgeOf(state)?.y ?? 0); const blobW = useLive(live, () => badgeOf(state)?.w ?? "100%"); const blobH = useLive(live, () => badgeOf(state)?.h ?? "100%"); const blobRadius = useLive(live, () => badgeOf(state)?.radius ?? 999); const nameTransform = useLive(live, () => { const m = metrics.get(); const e = ease(stage(foldOf(state), STAGE.name)); if (!m.ready || e === 0) return "none"; return `translate(${m.nameTo.dx * e}px, ${m.nameTo.dy * e}px)`; }); const figureTransform = useLive(live, () => figureOf(state)?.figure ?? "none"); const periodTransform = useLive(live, () => figureOf(state)?.period ?? "none"); const captionOpacity = useLive(live, () => 1 - ease(stage(foldOf(state), STAGE.caption))); const captionY = useLive(live, () => -4 * ease(stage(foldOf(state), STAGE.caption))); const pitchOpacity = useLive(live, () => 1 - ease(stage(foldOf(state), STAGE.pitch))); const pitchY = useLive(live, () => -6 * ease(stage(foldOf(state), STAGE.pitch))); const buttonWidth = useLive(live, () => { const b = buttonOf(state); return b && b.e > 0 ? b.w : "100%"; }); const buttonHeight = useLive(live, () => { const b = buttonOf(state); return b && (b.e > 0 || b.dy) ? b.h : "100%"; }); const buttonY = useLive(live, () => buttonOf(state)?.dy ?? 0); const labelScale = useLive(live, () => mix(1, metrics.get().slot?.labelTo ?? 1, buttonOf(state)?.e ?? 0)); const chevronOpacity = useLive(live, () => ease(stage(t.get(), STAGE.bar))); const chevronRotate = useLive(live, () => 180 * p.get()); /* body: scrolls on with the page; a peek drops it back under the head */ const bodyY = useLive(live, () => dropOf(state)); return ( <div ref={cellRef} role="columnheader" aria-labelledby={nameId} className={cn( "relative flex w-[min(80cqi,19rem)] shrink-0 snap-center flex-col @3xl/dock:w-auto", // The table slides over the pinned card instead. `clip` keeps sticky working. reduced && !open && "@3xl/dock:overflow-clip", index > 0 && "@3xl/dock:[--dock-inset-l:var(--dock-half-gap)]", index < count - 1 && "@3xl/dock:[--dock-inset-r:var(--dock-half-gap)]", )} > {/* shell: the card's surface, outline and divider */} <motion.div aria-hidden className={cn( "pointer-events-none absolute inset-y-0 bg-(--dock-surface)", plan.featured && TINT, )} style={{ left: shellLeft, right: shellRight, bottom: shellBottom, borderRadius: shellRadius }} > <motion.span className={cn( "absolute inset-0 rounded-[inherit] ring-1 ring-(--dock-border) ring-inset", plan.featured && "ring-[color-mix(in_oklab,var(--dock-accent)_35%,transparent)]", )} style={{ opacity: ringOpacity }} /> <motion.span className="absolute inset-y-0 left-0 w-px bg-(--dock-border)" style={{ opacity: dividerOpacity }} /> <motion.span className="absolute inset-0 rounded-[inherit] shadow-[0_24px_48px_-20px_rgb(0_0_0/0.35)]" style={{ opacity: peekShadow }} /> <motion.span className="absolute inset-0 rounded-[inherit] bg-foreground/[0.025]" style={{ opacity: lift }} /> </motion.div> {/* head: pins at the top while the cell scrolls past */} <div className="relative z-10 @3xl/dock:sticky @3xl/dock:top-(--dock-top) @3xl/dock:h-(--dock-head-h)"> <motion.div ref={innerRef} className="relative px-4 pt-5 @5xl/dock:px-5" style={{ y: headY, opacity: swap }} > {hasBadge ? ( <div className="mb-3 h-[22px]"> {plan.badge ? ( <span ref={badgeRef} className="relative inline-flex h-[22px] items-center px-2.5 text-[11px] font-medium whitespace-nowrap text-(--dock-accent-foreground)" > <motion.span aria-hidden className="absolute top-0 left-0 bg-(--dock-accent)" style={{ x: blobX, y: blobY, width: blobW, height: blobH, borderRadius: blobRadius }} /> <motion.span className="relative" style={{ opacity: badgeText }}> {plan.badge} </motion.span> </span> ) : null} </div> ) : null} <div className={cn("flex h-6 items-center", docked && "cursor-pointer")} onClick={docked ? () => togglePeek(index) : undefined} > <motion.span ref={nameRef} id={nameId} className="truncate text-[15px] font-semibold tracking-tight" style={{ transform: nameTransform }} > {plan.name} <Baseline /> </motion.span> </div> {hasFigure ? ( <div className="mt-3 flex h-[clamp(1.925rem,4.62cqi,3.3rem)] items-baseline gap-1 whitespace-nowrap"> {priceText !== null ? ( <> <motion.span ref={figureRef} className="relative inline-flex origin-top-left text-[clamp(1.75rem,4.2cqi,3rem)] leading-[1.1] font-semibold tracking-tight tabular-nums" style={{ transform: figureTransform }} > <RollingPrice text={priceText} amount={amount} reduced={reduced} /> <span className="sr-only"> {priceText} {amount !== null && period ? ` ${period}` : ""} </span> </motion.span> {amount !== null && period ? ( <motion.span ref={periodRef} aria-hidden className="origin-top-left text-sm text-muted-foreground" style={{ transform: periodTransform }} > {period} <Baseline /> </motion.span> ) : null} </> ) : plan.logo ? ( <motion.span ref={figureRef} className="inline-flex origin-top-left self-center" style={{ transform: figureTransform }} > {plan.logo} </motion.span> ) : null} </div> ) : null} {hasCaption ? ( <motion.p className="mt-1 h-4 truncate text-xs text-muted-foreground" style={{ opacity: captionOpacity, y: captionY }} > {caption} </motion.p> ) : null} {plan.pitch ? ( <motion.p className="mt-3 line-clamp-2 min-h-10 text-sm/5 text-pretty text-muted-foreground" style={{ opacity: pitchOpacity, y: pitchY }} > {plan.pitch} </motion.p> ) : ( <div className="mt-3 min-h-10" /> )} <div ref={slotRef} className="relative mt-5 h-10"> {plan.cta ? ( <PlanButton cta={plan.cta} featured={!!plan.featured} style={{ width: buttonWidth, height: buttonHeight, y: buttonY }} > <motion.span ref={labelRef} className="inline-block" style={{ scale: labelScale }}> {plan.cta.label} </motion.span> </PlanButton> ) : null} </div> <motion.button type="button" aria-label={`${plan.name} details`} aria-expanded={open} aria-hidden={!docked} tabIndex={docked ? 0 : -1} onClick={() => togglePeek(index)} className={cn( "absolute top-[53px] right-3 hidden size-[22px] items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-2 focus-visible:outline-foreground @3xl/dock:flex @5xl/dock:right-4", !docked && "pointer-events-none", )} style={{ opacity: chevronOpacity }} > <motion.svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden className="size-3.5" style={{ rotate: chevronRotate }} > <path d="m4 6 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" /> </motion.svg> </motion.button> </motion.div> </div> {/* body: highlights */} <ColumnContext.Provider value={column}> <motion.div className="relative flex-1 px-4 pt-6 pb-6 @3xl/dock:mt-[calc(var(--dock-head)-var(--dock-compact))] @5xl/dock:px-5" style={{ y: bodyY }} > {plan.highlights?.length ? ( <ul className="space-y-2.5 text-sm/5"> {plan.highlights.map((item, k) => ( <FadeItem key={k} index={k} as="li" className="flex gap-2.5 text-foreground/80"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden className={cn( "mt-0.5 size-4 shrink-0", plan.featured ? "text-(--dock-accent)" : "text-foreground/50", )} > <path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" /> </svg> <span>{item}</span> </FadeItem> ))} </ul> ) : null} </motion.div> </ColumnContext.Provider> </div> ); } type PlanState = { metrics: MotionValue<PlanMetrics>; geo: MotionValue<Geometry>; u: MotionValue<number>; t: MotionValue<number>; /** 0 → 1 as a peek unfolds this card again. */ p: MotionValue<number>; reduced: boolean; }; /** This card's fold: the dock's, less whatever a peek undoes. */ function foldOf(s: PlanState) { return s.t.get() * (1 - s.p.get()); } /** How far a peek drops the card below the bar, in px. */ function dropOf(s: PlanState) { return s.geo.get().fold * s.t.get() * s.p.get(); } function buttonOf(s: PlanState) { const m = s.metrics.get(); const slot = m.slot; if (!m.ready || !slot) return null; const e = ease(stage(foldOf(s), STAGE.button)); const w = mix(slot.w, slot.toW, e); const h = mix(slot.h, DOCK.buttonHeight, e); // The card's bottom edge, in head px. The button rides it up if its own // move hasn't lifted it clear yet. const edge = s.geo.get().full - s.u.get() + dropOf(s); const y = s.reduced ? mix(slot.y, DOCK.buttonTop, e) : Math.min(mix(slot.y, DOCK.buttonTop, e), edge - 14 - h); return { e, w, h, dy: y - slot.y }; } /** The badge's fill, flattening into a line along the top of the cell. */ function badgeOf(s: PlanState) { const m = s.metrics.get(); const from = m.badge; const e = stage(foldOf(s), STAGE.badge); if (!m.ready || !from || e === 0) return null; // Lifts and thins first, clearing the way for the name, then stretches. const up = easeOut(e); const out = ease(e); const open = 1 - ease(s.t.get()); const x = m.insetL * open; const w = m.cellW - (m.insetL + m.insetR) * open; return { x: (x - from.x) * out, y: -from.y * up, w: mix(from.w, w, out), h: mix(from.h, 2, up), radius: (from.h / 2) * (1 - up), }; } /** Where the price (or logo) and its period are, partway through the fold. */ function figureOf(s: PlanState) { const m = s.metrics.get(); const f = m.figure; const raw = stage(foldOf(s), STAGE.price); if (!m.ready || !f || raw === 0) return null; // Shrinks and slides over first, then rises into the name's row, so it // passes beside a long name instead of through it. const e = ease(stage(raw, [0, 0.85])); const rise = ease(stage(raw, [0.2, 1])); const k = mix(1, f.scale, e); const still = Math.abs(1 - f.scale) < 0.01; const dx = still ? (f.px - f.x) * e : (f.px - f.x) * (1 - k); const dy = still ? (f.py - f.y) * rise : (f.py - f.y) * (1 - mix(1, f.scale, rise)); const figure = `translate(${dx}px, ${dy}px) scale(${k})`; const p = m.period; if (!p) return { figure, period: "none" }; // Glued to the price: just after its right edge, on its baseline. const kp = mix(1, p.scale, e); const left = f.x + dx + f.w * k + mix(p.gap, 2, e); const base = f.y + dy + (f.base - f.y) * k; const period = `translate(${left - p.x}px, ${base - p.y - (p.base - p.y) * kp}px) scale(${kp})`; return { figure, period }; } /** Body text fades by its distance to the button it slides under. */ function fadeOf(s: PlanState, k: number) { const m = s.metrics.get(); const top = m.fades[k]; if (!m.ready || top === undefined) return 1; const b = buttonOf(s); if (!b || !m.slot) return 1; const line = m.slot.y + b.dy + b.h + 6; const fold = foldOf(s); // Whatever hasn't slid under yet is gone before the bar forms. return fadeBy(top - s.u.get() + dropOf(s) - line, top - (m.slot.y + m.slot.h + 6)) * (1 - stage(fold, [0.35, 0.65])); } /** * Opacity of text `distance` px below the line it slides under. Text that * starts closer than the fade distance starts opaque and fades over what it has. */ function fadeBy(distance: number, rest: number) { return clamp(distance / clamp(rest, 4, DOCK.fade), 0, 1); } function PlanButton({ cta, featured, style, children, }: { cta: PricingDockCta; featured: boolean; style: React.ComponentProps<typeof motion.a>["style"]; children: React.ReactNode; }) { const className = cn( "absolute top-0 left-0 flex h-full w-full items-center justify-center overflow-hidden rounded-full text-sm font-medium whitespace-nowrap transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground", featured ? "bg-(--dock-accent) text-(--dock-accent-foreground) hover:bg-[color-mix(in_oklab,var(--dock-accent)_88%,var(--dock-surface))]" : "bg-foreground/[0.06] text-foreground ring-1 ring-(--dock-border) ring-inset hover:bg-foreground/10", ); return cta.href ? ( <motion.a href={cta.href} onClick={cta.onClick} className={className} style={style}> {children} </motion.a> ) : ( <motion.button type="button" onClick={cta.onClick} className={className} style={style}> {children} </motion.button> ); } /** The docked button hugs its label, leaving room for the peek chevron. */ function dockedButton(full: number, label: number, room: number) { const fit = Math.min(full, room); const labelTo = clamp((fit - DOCK.buttonPad * 2) / Math.max(label, 1), 0.8, DOCK.labelScale); return { toW: Math.min(fit, label * labelTo + DOCK.buttonPad * 2), labelTo }; } function measurePlan({ cell, inner, name, badge, figure, period, slot, label, logo, insetL, insetR, }: { cell: HTMLElement; inner: HTMLElement; name: HTMLElement | null; badge: HTMLElement | null; figure: HTMLElement | null; period: HTMLElement | null; slot: HTMLElement | null; label: HTMLElement | null; logo: boolean; insetL: number; insetR: number; }): PlanMetrics { const padX = parseFloat(getComputedStyle(inner).paddingLeft) || 0; const cellW = cell.offsetWidth; const right = cellW - padX; const n = name ? box(name, inner) : null; const nameY = n ? DOCK.nameTop + (DOCK.row - n.h) / 2 : 0; const nameTo = { dx: 0, dy: n ? nameY - n.y : 0 }; const nameBase = n && name ? baseline(name, inner) + nameTo.dy : 0; let f: Figure | null = null; let pr: Period | null = null; if (figure && n) { const fb = box(figure, inner); if (logo) { const scale = DOCK.logoSize / Math.max(fb.h, 1); const x = padX; const y = DOCK.nameTop + DOCK.row / 2 - (fb.h * scale) / 2; f = { ...fb, base: fb.y + fb.h, scale, ...center(fb, x, y, scale) }; nameTo.dx = padX + fb.w * scale + 8 - n.x; } else { const size = parseFloat(getComputedStyle(figure).fontSize) || 48; const pb = period ? box(period, inner) : null; const periodSize = period ? parseFloat(getComputedStyle(period).fontSize) || 14 : 14; let scale = DOCK.priceSize / size; let periodScale = DOCK.periodSize / periodSize; const x = n.x + n.w + 7; const need = fb.w * scale + (pb ? 2 + pb.w * periodScale : 0); if (need > right - x) { const k = Math.max((right - x) / need, DOCK.minPriceSize / DOCK.priceSize); scale *= k; periodScale *= k; } const base = baseline(figure, inner); const y = nameBase - (base - fb.y) * scale; f = { ...fb, base, scale, ...center(fb, x, y, scale) }; if (pb && period) { pr = { ...pb, base: baseline(period, inner), scale: periodScale, gap: pb.x - (fb.x + fb.w) }; } } } const s = slot ? box(slot, inner) : null; const labelW = label ? label.offsetWidth : 0; return { ready: true, cellW, insetL, insetR, name: n, nameTo, figure: f, period: pr, badge: badge ? box(badge, inner) : null, slot: s ? { ...s, ...dockedButton(s.w, labelW, cellW - padX - DOCK.chevron) } : null, fades: fadeTops(cell), }; } /* ── ColumnGlow: featured tint and hover glow ───────────── */ /** The card's gap, closing as it docks, for anything that lines up with a card. */ function useColumnInsets() { const { t } = useDock(); const open = useLive([t], () => 1 - ease(t.get())); const left = useLive([t], () => `calc(var(--dock-inset-l, 0px) * ${open.get()})`); const right = useLive([t], () => `calc(var(--dock-inset-r, 0px) * ${open.get()})`); return { open, left, right }; } function insetVars(index: number, count: number) { return cn( index > 0 && "[--dock-inset-l:var(--dock-half-gap)]", index < count - 1 && "[--dock-inset-r:var(--dock-half-gap)]", ); } function ColumnGlow({ index, count, featured }: { index: number; count: number; featured: boolean }) { const { hoverCol, t, radius: corner } = useDock(); const glow = useSpring(0, LIFT_SPRING); useMotionValueEvent(hoverCol, "change", (col) => glow.set(col === index ? 1 : 0)); // Follows the card above it: inset like the card at rest, full width docked. const { open, left, right } = useColumnInsets(); const radius = useLive([t], () => { const top = corner * 0.7 * open.get(); return `${top}px ${top}px ${corner * 0.8}px ${corner * 0.8}px`; }); return ( <span className={cn("relative", insetVars(index, count))}> <motion.span className={cn("absolute inset-y-0", featured && TINT_PAGE)} style={{ left, right, borderRadius: radius }} > <motion.span className="absolute inset-0 rounded-[inherit] bg-foreground/[0.03]" style={{ opacity: glow }} /> </motion.span> </span> ); } /** A group label row's cell over a plan column, carrying the featured tint at its width. */ function LabelTint({ index, count, featured }: { index: number; count: number; featured: boolean }) { const { left, right } = useColumnInsets(); return ( <span aria-hidden className={cn("relative hidden @3xl/dock:block", insetVars(index, count))}> {featured ? <motion.span className={cn("absolute inset-y-0", TINT_PAGE)} style={{ left, right }} /> : null} </span> ); } /* ── FadeItem: body text that fades as it slides under ─── */ function FadeItem({ index, as = "div", className, children, }: { index: number; as?: "div" | "li"; className?: string; children: React.ReactNode; }) { const column = React.useContext(ColumnContext); const opacity = useLive(column.deps, () => column.fade(index)); const Tag = as === "li" ? motion.li : motion.div; return ( <Tag data-dock-fade className={className} style={{ opacity }}> {children} </Tag> ); } /* ── BillingToggle ──────────────────────────────────────── */ function BillingToggle({ options, value, onChange, }: { options: PricingDockBilling[]; value?: string; onChange: (id: string) => void; }) { const index = Math.max(0, options.findIndex((option) => option.id === value)); const refs = React.useRef<(HTMLButtonElement | null)[]>([]); const onKeyDown = (event: React.KeyboardEvent) => { const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; if (!step) return; event.preventDefault(); const next = (index + step + options.length) % options.length; onChange(options[next].id); refs.current[next]?.focus(); }; return ( <div role="radiogroup" aria-label="Billing period" onKeyDown={onKeyDown} className="relative grid h-9 rounded-full bg-foreground/[0.06] p-0.5 text-[13px] font-medium" style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }} > <motion.span aria-hidden className="absolute inset-y-0.5 left-0.5 rounded-full bg-(--dock-surface) shadow-[0_1px_3px_rgb(0_0_0/0.12)] ring-1 ring-foreground/[0.06]" style={{ width: `calc((100% - 4px) / ${options.length})` }} initial={false} animate={{ x: `${index * 100}%` }} transition={SLIDE_SPRING} /> {options.map((option, i) => { const on = i === index; return ( <button key={option.id} ref={(element) => { refs.current[i] = element; }} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => onChange(option.id)} className={cn( "relative flex items-center justify-center rounded-full px-3.5 whitespace-nowrap transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground", on ? "text-foreground" : "text-muted-foreground hover:text-foreground", )} > {option.label} {option.note ? ( // Floats on the option's corner, so it doesn't widen every segment. <span className="absolute -top-2 -right-1.5 rounded-full bg-(--dock-accent) px-1.5 py-px text-[10px]/4 font-semibold text-(--dock-accent-foreground) shadow-sm"> {option.note} </span> ) : null} </button> ); })} </div> ); } /* ── PlanPills: the phone's docked header ───────────────── */ function PlanPills({ plans, shown, onShow, reduced, }: { plans: PricingDockPlan[]; shown: number; onShow: (index: number) => void; reduced: boolean; }) { return ( <div className="sticky top-(--dock-top) z-20 border-b border-(--dock-border) bg-(--dock-surface) px-4 py-2 @3xl/dock:hidden"> <div role="group" aria-label="Plan shown in the table" className="relative grid h-9 rounded-full bg-foreground/[0.06] p-0.5 text-[13px] font-medium" style={{ gridTemplateColumns: `repeat(${plans.length}, minmax(0, 1fr))` }} > <motion.span aria-hidden className="absolute inset-y-0.5 left-0.5 rounded-full bg-(--dock-surface) shadow-[0_1px_3px_rgb(0_0_0/0.12)] ring-1 ring-foreground/[0.06]" style={{ width: `calc((100% - 4px) / ${plans.length})` }} initial={false} animate={{ x: `${shown * 100}%` }} transition={reduced ? { duration: 0 } : SLIDE_SPRING} /> {plans.map((plan, index) => ( <button key={plan.id} type="button" aria-pressed={index === shown} onClick={() => onShow(index)} className={cn( "relative truncate rounded-full px-2 transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground", index === shown ? "text-foreground" : "text-muted-foreground", )} > {plan.name} </button> ))} </div> </div> ); } /* ── RollingPrice: digits roll on wheels ────────────────── */ function RollingPrice({ text, amount, reduced }: { text: string; amount: number | null; reduced: boolean }) { const [last, setLast] = React.useState(amount); const [direction, setDirection] = React.useState(1); if (amount !== last) { setLast(amount); if (amount !== null && last !== null) setDirection(amount >= last ? 1 : -1); } const chars = Array.from(text); return ( <> {/* The text itself sets the width and baseline; the wheels draw over it. */} <span aria-hidden className="invisible"> {text} <Baseline /> </span> <span aria-hidden className="absolute inset-0 flex"> {chars.map((char, i) => { const place = chars.length - i; return /\d/.test(char) ? ( <Wheel key={place} digit={Number(char)} direction={direction} delay={(place - 1) * 0.035} reduced={reduced} /> ) : ( <span key={`${place}${char}`}>{char}</span> ); })} </span> </> ); } const DIGITS = Array.from({ length: 30 }, (_, i) => i % 10); function Wheel({ digit, direction, delay, reduced, }: { digit: number; direction: number; delay: number; reduced: boolean; }) { const position = useMotionValue(digit); // Draws position mod 10 from the middle copy of 0–9, so a roll can wrap // past 9 or 0 without a jump. const y = useTransform(position, (value) => `${-(mod(value, 10) + 10) * 1.1}em`); React.useEffect(() => { const from = position.get(); if (mod(from, 10) === digit) return; let to = from - mod(from, 10) + digit; if (direction > 0 && to < from) to += 10; if (direction < 0 && to > from) to -= 10; if (reduced) position.jump(to); else animate(position, to, { ...ROLL_SPRING, delay }); }, [digit, direction, delay, reduced, position]); return ( <span className="relative inline-block h-[1.1em] overflow-hidden"> <span className="invisible">0</span> <motion.span className="absolute inset-x-0 top-0 flex flex-col" style={{ y }}> {DIGITS.map((d, i) => ( <span key={i} className="block h-[1.1em] text-center"> {d} </span> ))} </motion.span> </span> ); } /* ── table cells ────────────────────────────────────────── */ const DRAW: Variants = { hidden: { pathLength: 0, opacity: 0 }, shown: (col: number) => ({ pathLength: 1, opacity: 1, transition: { delay: 0.06 + col * 0.07, duration: 0.42, ease: [0.65, 0, 0.35, 1] }, }), }; function Value({ value, col, featured, reduced, }: { value: PricingDockValue; col: number; featured: boolean; reduced: boolean; }) { if (value === true) { return ( <> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden className={cn("size-4", featured ? "text-(--dock-accent)" : "text-foreground/70")} > <motion.path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" custom={reduced ? -1 : col} variants={reduced ? undefined : DRAW} /> </svg> <span className="sr-only">Included</span> </> ); } if (value === false || value === null || value === undefined) { return ( <> <span aria-hidden className="text-foreground/25"> — </span> <span className="sr-only">Not included</span> </> ); } return <span className="text-foreground/85">{value}</span>; } function Hint({ children }: { children: React.ReactNode }) { const id = React.useId(); return ( <span className="group/hint relative ml-1.5 inline-flex align-[-2px]"> <button type="button" aria-describedby={id} className="inline-flex size-4 items-center justify-center rounded-full text-foreground/35 transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-2 focus-visible:outline-foreground" > <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden className="size-3.5"> <circle cx="8" cy="8" r="6.25" /> <path d="M8 7.25V11M8 5v.01" strokeLinecap="round" /> </svg> <span className="sr-only">More info</span> </button> <span role="tooltip" id={id} className="pointer-events-none absolute bottom-full left-[-0.5rem] z-30 mb-2 w-56 translate-y-1 rounded-lg bg-foreground px-3 py-2 text-xs/5 font-normal text-background opacity-0 shadow-lg transition duration-150 group-focus-within/hint:translate-y-0 group-focus-within/hint:opacity-100 group-hover/hint:translate-y-0 group-hover/hint:opacity-100" > {children} </span> </span> ); } /** A zero-size box whose top edge sits on the text's baseline. */ function Baseline() { return <span aria-hidden data-baseline className="inline-block h-0 w-0" />; } /* ── helpers ────────────────────────────────────────────── */ type Box = Part; const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }); function defaultFormatPrice(amount: number) { return usd.format(amount); } /** One value for every billing option, or the one for the selected option. */ function pick<T>(value: T | Record<string, T> | undefined, billing: string | undefined): T | undefined { if (value && typeof value === "object" && !Array.isArray(value) && !React.isValidElement(value)) { const map = value as Record<string, T>; return billing !== undefined && billing in map ? map[billing] : Object.values(map)[0]; } return value as T | undefined; } /** Layout position inside an ancestor, ignoring transforms. */ function offsetIn(element: HTMLElement, ancestor: HTMLElement) { let x = 0; let y = 0; let node: HTMLElement | null = element; while (node && node !== ancestor) { x += node.offsetLeft; y += node.offsetTop; node = node.offsetParent as HTMLElement | null; } return { x, y }; } /** The point a box scales toward to land at (x, y) at `scale`. */ function center(from: Part, x: number, y: number, scale: number) { if (Math.abs(1 - scale) < 0.01) return { px: x, py: y }; return { px: (x - scale * from.x) / (1 - scale), py: (y - scale * from.y) / (1 - scale) }; } function box(element: HTMLElement, ancestor: HTMLElement): Box { const { x, y } = offsetIn(element, ancestor); return { x, y, w: element.offsetWidth, h: element.offsetHeight }; } function baseline(element: HTMLElement, ancestor: HTMLElement) { const marker = element.querySelector<HTMLElement>("[data-baseline]"); return marker ? offsetIn(marker, ancestor).y : offsetIn(element, ancestor).y + element.offsetHeight * 0.8; } function fadeTops(cell: HTMLElement) { return Array.from(cell.querySelectorAll<HTMLElement>("[data-dock-fade]")).map((item) => offsetIn(item, cell).y); } function scrollBy(container: HTMLElement | null, delta: number) { if (Math.abs(delta) < 1) return; const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"; if (container) container.scrollTo({ top: container.scrollTop + delta, behavior }); else window.scrollTo({ top: window.scrollY + delta, behavior }); } function stage(value: number, [from, to]: [number, number]) { return clamp((value - from) / (to - from), 0, 1); } function ease(x: number) { return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2; } function easeOut(x: number) { return 1 - (1 - x) ** 3; } function mix(from: number, to: number, progress: number) { return from + (to - from) * progress; } function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function mod(value: number, n: number) { return ((value % n) + n) % n; }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 Pricing Dock. Each one installs whole, with the component included.
Studio Packages
A design studio's three ways to work together, set in an editorial serif with tighter corners (`radius={6}`), a wider `gap` and warm `--dock-border` lines. `formatPrice` writes prices in thousands, the Sprint sets its own `/sprint` period, and Quarterly billing rolls the retainers down.
Atelier North · Work with us
A senior design team, on call
Three ways to work together. Every plan gets the same people; you choose how much of their week.
"use client";
import * as React from "react";
import { PricingDock, type PricingDockGroup, type PricingDockPlan } from "@/components/ui/pricing-dock";
const plans: PricingDockPlan[] = [
{
id: "sprint",
name: "Sprint",
price: 8,
period: "/sprint",
caption: "Two weeks, fixed scope",
pitch: "A launch page, deck or rebrand in two weeks.",
highlights: ["One request at a time", "Delivered in two weeks", "Two rounds of revisions", "Source files included"],
cta: { label: "Book a call", href: "#" },
},
{
id: "retainer",
name: "Retainer",
price: { monthly: 12, quarterly: 11 },
caption: { monthly: "Pause or cancel any month", quarterly: "Billed every three months" },
pitch: "Senior designers shipping weekly, no hiring.",
highlights: ["Two active requests", "48-hour turnaround", "A dedicated lead designer", "Shared Slack channel"],
featured: true,
badge: "Most chosen",
cta: { label: "Book a call", href: "#" },
},
{
id: "partner",
name: "Partner",
price: { monthly: 20, quarterly: 18 },
caption: { monthly: "Three-month minimum", quarterly: "Billed every three months" },
pitch: "Brand, product and web, run like your in-house team.",
highlights: ["Three active requests", "Same-day turnaround on small asks", "Design lead and design engineer", "Quarterly brand strategy"],
cta: { label: "Book a call", href: "#" },
},
];
const groups: PricingDockGroup[] = [
{
label: "Capacity",
rows: [
{
feature: "Active requests",
hint: "How many briefs we work on at once. Queue as many as you like; the next starts when one ships.",
values: { sprint: "1", retainer: "2", partner: "3" },
},
{ feature: "Turnaround", values: { sprint: "2 weeks", retainer: "48 hours", partner: "Same day" } },
{ feature: "Revisions", values: { sprint: "2 rounds", retainer: "Unlimited", partner: "Unlimited" } },
{ feature: "Pause any time", values: { sprint: false, retainer: true, partner: true } },
],
},
{
label: "Team",
rows: [
{ feature: "Dedicated designer", values: { sprint: false, retainer: true, partner: true } },
{ feature: "Design engineer", values: { sprint: false, retainer: false, partner: true } },
{ feature: "Creative direction", values: { sprint: false, retainer: "Monthly", partner: "Weekly" } },
{ feature: "Slack channel", values: { sprint: false, retainer: true, partner: true } },
],
},
{
label: "What we make",
rows: [
{ feature: "Brand identity", values: { sprint: "Refresh", retainer: true, partner: true } },
{ feature: "Websites and landing pages", values: { sprint: true, retainer: true, partner: true } },
{
feature: "Framer or Webflow build",
hint: "We hand over a live site your team can edit, not just the design files.",
values: { sprint: false, retainer: true, partner: true },
},
{ feature: "Motion and video", values: { sprint: false, retainer: "Add-on", partner: true } },
{ feature: "Product UI", values: { sprint: false, retainer: true, partner: true } },
],
},
{
label: "Terms",
rows: [
{ feature: "Minimum term", values: { sprint: "One sprint", retainer: "One month", partner: "Three months" } },
{ feature: "You own everything", values: { sprint: true, retainer: true, partner: true } },
],
},
];
export function PricingDockStudioDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
return (
<div className="relative isolate w-full overflow-hidden rounded-xl border border-stone-300/70 bg-[#f4efe7] font-serif text-stone-900 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-100">
<div ref={scrollRef} className="h-[var(--demo-height,44rem)] overflow-y-auto overscroll-contain">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<header className="flex flex-col items-center pt-16 pb-12 text-center sm:pt-24">
<p className="font-sans text-[11px] tracking-[0.2em] text-[#a8492a] uppercase dark:text-[#e2875f]">
Atelier North · Work with us
</p>
<h2 className="mt-4 max-w-lg text-4xl/[1.1] tracking-tight text-balance sm:text-5xl/[1.05]">
A senior design team, <em className="text-[#a8492a] dark:text-[#e2875f]">on call</em>
</h2>
<p className="mt-4 max-w-md font-sans text-sm/6 text-pretty text-stone-600 dark:text-stone-400">
Three ways to work together. Every plan gets the same people; you choose how much of their week.
</p>
</header>
<PricingDock
containerRef={scrollRef}
plans={plans}
groups={groups}
billing={[
{ id: "monthly", label: "Monthly" },
{ id: "quarterly", label: "Quarterly", note: "−8%" },
]}
formatPrice={(amount) => `$${amount}k`}
intro="Not sure which fits? Most studios start with a Sprint and move to a Retainer once the first project ships."
compareLabel="See everything included"
radius={6}
gap={16}
className="[--dock-border:#e2d8c8] dark:[--dock-border:var(--color-stone-800)] [--dock-accent:#a8492a] [--dock-accent-foreground:#fbf8f3] [--dock-page:#f4efe7] [--dock-surface:#fbf8f3] dark:[--dock-accent:#e2875f] dark:[--dock-accent-foreground:#1c1917] dark:[--dock-page:var(--color-stone-950)] dark:[--dock-surface:var(--color-stone-900)] [&_[role=cell]]:font-sans [&_[role=cell]]:text-[13px] [&_[role=rowheader]]:text-[15px]"
/>
<footer className="flex flex-col items-center py-28 text-center">
<h3 className="text-3xl tracking-tight">
Let's talk about <em>your</em> next launch
</h3>
<p className="mt-3 max-w-sm font-sans text-sm/6 text-pretty text-stone-600 dark:text-stone-400">
Twenty minutes with a founder, no slides. We'll tell you honestly if we're the right fit.
</p>
<a
href="#"
className="mt-6 rounded-full bg-[#a8492a] px-5 py-2.5 font-sans text-sm font-medium text-[#fbf8f3] transition-colors hover:bg-[#93401f] dark:bg-[#e2875f] dark:text-stone-900"
>
Book a call
</a>
</footer>
</div>
</div>
</div>
);
}
Why Switch
Us against the two tools teams switch from, with logo cards instead of prices: each mark shrinks in beside its name as the cards dock. On a phone, the plan pills pick whose column the table shows, or swipe the table sideways.
Compare
Why teams switch to Northlight
The two tools teams most often move from, side by side. Hover a column to follow it, or click a name once it docks to see its card again.
"use client";
import * as React from "react";
import { PricingDock, type PricingDockGroup, type PricingDockPlan } from "@/components/ui/pricing-dock";
function Mark({ className, children }: { className: string; children: React.ReactNode }) {
return (
<span className={`flex size-10 items-center justify-center rounded-[11px] ${className}`} aria-hidden>
{children}
</span>
);
}
const plans: PricingDockPlan[] = [
{
id: "us",
name: "Northlight",
logo: (
<Mark className="bg-blue-600 text-white dark:bg-sky-400 dark:text-sky-950">
<svg viewBox="0 0 20 20" fill="currentColor" className="size-5">
<path d="M10 2.5 12.2 8l5.3 2-5.3 2L10 17.5 7.8 12l-5.3-2 5.3-2L10 2.5Z" />
</svg>
</Mark>
),
pitch: "Reviews that read the whole repo, not just the diff.",
highlights: ["First review in under a minute", "Learns your team's conventions", "Fixes as one-click suggestions", "Cloud or self-hosted"],
featured: true,
badge: "Free migration",
cta: { label: "Switch in 5 minutes", href: "#" },
},
{
id: "a",
name: "Competitor A",
logo: (
<Mark className="bg-zinc-200 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
<span className="font-mono text-sm font-semibold">A</span>
</Mark>
),
pitch: "Rule-based linting with an AI summary on top.",
highlights: ["First review in 5–10 minutes", "Fixed rule sets", "Comments only, no fixes", "Cloud only"],
cta: { label: "Moving from A", href: "#" },
},
{
id: "b",
name: "Competitor B",
logo: (
<Mark className="bg-zinc-200 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
<span className="font-mono text-sm font-semibold">B</span>
</Mark>
),
pitch: "A general chat assistant wired into pull requests.",
highlights: ["First review in 2–4 minutes", "Prompted per repository", "Suggestions in a chat thread", "Cloud only"],
cta: { label: "Moving from B", href: "#" },
},
];
const groups: PricingDockGroup[] = [
{
label: "Speed",
rows: [
{ feature: "Time to first review", values: { us: "Under 1 min", a: "5–10 min", b: "2–4 min" } },
{ feature: "Large pull requests", values: { us: "1,000+ files", a: "Up to 200", b: "Up to 300" } },
{ feature: "Re-reviews only what changed", values: { us: true, a: false, b: true } },
],
},
{
label: "Review quality",
rows: [
{
feature: "Reads the whole repository",
hint: "Follows imports, call sites and tests outside the diff before it comments.",
values: { us: true, a: false, b: "Open files" },
},
{ feature: "Learns team conventions", values: { us: true, a: false, b: "By prompt" } },
{ feature: "One-click fixes", values: { us: true, a: false, b: true } },
{
feature: "False-positive rate",
hint: "Comments dismissed without a change, across 40 open-source repos we reviewed in September 2026.",
values: { us: "3%", a: "18%", b: "9%" },
},
{ feature: "Writes missing tests", values: { us: true, a: false, b: false } },
],
},
{
label: "Workflow",
rows: [
{ feature: "GitHub, GitLab, Bitbucket", values: { us: true, a: "GitHub only", b: "GitHub, GitLab" } },
{ feature: "Slack and Linear", values: { us: true, a: "Slack", b: false } },
{ feature: "Custom review rules", values: { us: true, a: true, b: false } },
{ feature: "CLI and pre-commit hook", values: { us: true, a: false, b: false } },
],
},
{
label: "Security",
rows: [
{ feature: "Self-hosting", values: { us: true, a: false, b: false } },
{ feature: "Zero data retention", values: { us: true, a: false, b: "Enterprise" } },
{ feature: "SOC 2 Type II", values: { us: true, a: true, b: true } },
{ feature: "SSO and SCIM", values: { us: true, a: "Enterprise", b: true } },
{ feature: "Never trains on your code", values: { us: true, a: "Opt-out", b: true } },
],
},
{
label: "Price",
rows: [
{ feature: "Per seat, per month", values: { us: "$19", a: "$29", b: "$24" } },
{ feature: "Free for open source", values: { us: true, a: false, b: true } },
{ feature: "Migration help", values: { us: "Free, hands-on", a: false, b: false } },
],
},
];
export function PricingDockCompareDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
return (
<div className="relative isolate w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-[#08090c]">
<div ref={scrollRef} className="h-[var(--demo-height,44rem)] overflow-y-auto overscroll-contain">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<header className="pt-14 pb-10 sm:pt-20">
<p className="font-mono text-[11px] tracking-widest text-blue-600 uppercase dark:text-sky-400">Compare</p>
<h2 className="mt-3 max-w-lg text-3xl/tight font-semibold tracking-tight text-balance text-zinc-900 sm:text-4xl/tight dark:text-zinc-50">
Why teams switch to Northlight
</h2>
<p className="mt-3 max-w-md text-sm/6 text-pretty text-zinc-500">
The two tools teams most often move from, side by side. Hover a column to follow it, or click a name once
it docks to see its card again.
</p>
</header>
<PricingDock
containerRef={scrollRef}
plans={plans}
groups={groups}
intro="Checked against each tool's public docs and pricing in October 2026."
compareLabel="Jump to the comparison"
className="[--dock-accent:var(--color-blue-600)] [--dock-accent-foreground:white] [--dock-page:var(--color-zinc-50)] [--dock-surface:white] dark:[--dock-accent:var(--color-sky-400)] dark:[--dock-accent-foreground:var(--color-zinc-950)] dark:[--dock-page:#08090c] dark:[--dock-surface:var(--color-zinc-900)]"
/>
<footer className="flex flex-col items-center py-24 text-center">
<h3 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Bring your rules with you
</h3>
<p className="mt-2 max-w-sm text-sm/6 text-pretty text-zinc-500">
We import your existing review rules and ignore lists, so day one feels like day one hundred.
</p>
<a
href="#"
className="mt-5 rounded-full bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500 dark:bg-sky-400 dark:text-sky-950"
>
Start the migration
</a>
</footer>
</div>
</div>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
plansRequired | PricingDockPlan[] | None | One card and one table column per plan, left to right. Three or four fit best; the cards sit over their columns from a 48rem-wide container, and narrower ones get the phone layout. |
groupsRequired | PricingDockGroup[] | None | The comparison table: { label, rows } per section. Each label sticks under the docked bar until the next one pushes it out. |
billing | PricingDockBilling[] | None | Options for the billing toggle, as { id, label, note?, period? }, e.g. { id: "yearly", label: "Yearly", note: "−20%" }. The toggle sits in the top-left cell and shrinks into the docked bar, where it still works. Leave it out for one price per plan. |
defaultBilling | string | None | The billing option selected at first. Defaults to the first one. |
onBillingChange | (id: string) => void | None | Called with the option's id when the visitor switches billing. |
formatPrice | (amount: number, plan: PricingDockPlan, billing?: string) => string | whole US dollars | Turns a number price into text, e.g. (n) => "€" + n or (n) => "$" + n + "k". Digits roll on their own wheels when billing changes. |
period | string | "/mo" | Shown after every number price. A billing option's period, then a plan's, override it. Text prices like "Custom" show none. |
intro | React.ReactNode | None | A line in the top-left cell, under the toggle. It slides away with the page as the cards dock. |
compareLabel | string | null | "Compare all features" | Label of the link at the bottom of the top-left cell, which scrolls to exactly where the cards have docked. null hides it. |
featureLabel | string | "Feature" | Screen-reader header of the feature column. |
top | number | 0 | Where the docked bar pins, in px from the top of the scrollport: your fixed nav's height, or 0. The cards' top edges slide under whatever covers the space above it. |
containerRef | React.RefObject<HTMLElement | null> | None | The scroll container, when the table scrolls inside an overflow-y-auto box. Leave it out for the window. |
radius | number | 20 | Corner radius of the cards before they dock, in px. The corners square off as the cards close into the bar, and a peeked card gets them back. 0 keeps them square throughout. |
gap | number | 12 | Space between the cards before they dock, in px. It closes as they dock, so the bar has none. |
className | string | None | Classes for the table. Theme it with CSS variables: --dock-accent (featured card, badge and check marks), --dock-accent-foreground (text on it), --dock-surface (cards and the docked bar), --dock-page (the page behind, used by the group labels) and --dock-border (card outlines, the bar's dividers and the table's lines). They default to your foreground and background colors, e.g. [--dock-border:var(--color-stone-200)]. |
plan.idRequired | string | None | PricingDockPlan: keys this plan's column in each row's values. |
plan.nameRequired | string | None | PricingDockPlan: the plan's name, also its column header for screen readers. |
plan.price | number | string | Record<string, number | string> | None | PricingDockPlan: a number goes through formatPrice; text ("Custom") shows as is. One per billing option: { monthly: 24, yearly: 19 }. |
plan.period | string | None | PricingDockPlan: overrides the period after this plan's price, e.g. "/seat/mo". |
plan.caption | React.ReactNode | Record<string, React.ReactNode> | None | PricingDockPlan: small print under the price, one for all billing options or one per option. |
plan.logo | React.ReactNode | None | PricingDockPlan: shown instead of a price, e.g. a product's mark on a comparison page. It shrinks in before the name when docked. |
plan.pitch | React.ReactNode | None | PricingDockPlan: one line under the price. Two lines at most. |
plan.highlights | React.ReactNode[] | None | PricingDockPlan: three to five points under the button. They slide under it and fade as the card docks. |
plan.featured | boolean | None | PricingDockPlan: tints the card and runs the tint down its whole column. |
plan.badge | React.ReactNode | None | PricingDockPlan: a label over the name, e.g. "Most popular". Flattens into a 2px accent line along the top of the docked cell. |
plan.cta | { label: string; href?: string; onClick?: () => void } | None | PricingDockPlan: the plan's button, a link with href or a button with onClick. It is the same element docked and undocked, so focus and tab order never jump. |
row.featureRequired | React.ReactNode | None | PricingDockRow: the feature's name, the row's header. |
row.hint | React.ReactNode | None | PricingDockRow: explanation shown from a small info icon beside the feature. |
row.valuesRequired | Record<string, boolean | React.ReactNode> | None | PricingDockRow: one value per plan id. true draws a check mark, false (or a missing value) a dash; text and elements render as they are. |
plansRequiredType
PricingDockPlan[]One card and one table column per plan, left to right. Three or four fit best; the cards sit over their columns from a 48rem-wide container, and narrower ones get the phone layout.
groupsRequiredType
PricingDockGroup[]The comparison table:
{ label, rows }per section. Each label sticks under the docked bar until the next one pushes it out.billingType
PricingDockBilling[]Options for the billing toggle, as
{ id, label, note?, period? }, e.g.{ id: "yearly", label: "Yearly", note: "−20%" }. The toggle sits in the top-left cell and shrinks into the docked bar, where it still works. Leave it out for one price per plan.defaultBillingType
stringThe billing option selected at first. Defaults to the first one.
onBillingChangeType
(id: string) => voidCalled with the option's
idwhen the visitor switches billing.formatPriceType
(amount: number, plan: PricingDockPlan, billing?: string) => stringDefaultwhole US dollarsTurns a number price into text, e.g.
(n) => "€" + nor(n) => "$" + n + "k". Digits roll on their own wheels when billing changes.periodType
stringDefault"/mo"Shown after every number price. A billing option's
period, then a plan's, override it. Text prices like "Custom" show none.introType
React.ReactNodeA line in the top-left cell, under the toggle. It slides away with the page as the cards dock.
compareLabelType
string | nullDefault"Compare all features"Label of the link at the bottom of the top-left cell, which scrolls to exactly where the cards have docked.
nullhides it.featureLabelType
stringDefault"Feature"Screen-reader header of the feature column.
topType
numberDefault0Where the docked bar pins, in px from the top of the scrollport: your fixed nav's height, or 0. The cards' top edges slide under whatever covers the space above it.
containerRefType
React.RefObject<HTMLElement | null>The scroll container, when the table scrolls inside an
overflow-y-autobox. Leave it out for the window.radiusType
numberDefault20Corner radius of the cards before they dock, in px. The corners square off as the cards close into the bar, and a peeked card gets them back.
0keeps them square throughout.gapType
numberDefault12Space between the cards before they dock, in px. It closes as they dock, so the bar has none.
classNameType
stringClasses for the table. Theme it with CSS variables:
--dock-accent(featured card, badge and check marks),--dock-accent-foreground(text on it),--dock-surface(cards and the docked bar),--dock-page(the page behind, used by the group labels) and--dock-border(card outlines, the bar's dividers and the table's lines). They default to yourforegroundandbackgroundcolors, e.g.[--dock-border:var(--color-stone-200)].plan.idRequiredType
stringPricingDockPlan: keys this plan's column in each row'svalues.plan.nameRequiredType
stringPricingDockPlan: the plan's name, also its column header for screen readers.plan.priceType
number | string | Record<string, number | string>PricingDockPlan: a number goes throughformatPrice; text ("Custom") shows as is. One per billing option:{ monthly: 24, yearly: 19 }.plan.periodType
stringPricingDockPlan: overrides the period after this plan's price, e.g."/seat/mo".plan.captionType
React.ReactNode | Record<string, React.ReactNode>PricingDockPlan: small print under the price, one for all billing options or one per option.plan.logoType
React.ReactNodePricingDockPlan: shown instead of a price, e.g. a product's mark on a comparison page. It shrinks in before the name when docked.plan.pitchType
React.ReactNodePricingDockPlan: one line under the price. Two lines at most.plan.highlightsType
React.ReactNode[]PricingDockPlan: three to five points under the button. They slide under it and fade as the card docks.plan.featuredType
booleanPricingDockPlan: tints the card and runs the tint down its whole column.plan.badgeType
React.ReactNodePricingDockPlan: a label over the name, e.g. "Most popular". Flattens into a 2px accent line along the top of the docked cell.plan.ctaType
{ label: string; href?: string; onClick?: () => void }PricingDockPlan: the plan's button, a link withhrefor a button withonClick. It is the same element docked and undocked, so focus and tab order never jump.row.featureRequiredType
React.ReactNodePricingDockRow: the feature's name, the row's header.row.hintType
React.ReactNodePricingDockRow: explanation shown from a small info icon beside the feature.row.valuesRequiredType
Record<string, boolean | React.ReactNode>PricingDockRow: one value per plan id.truedraws a check mark,false(or a missing value) a dash; text and elements render as they are.