# 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.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/pricing-dock
- Tags: Pricing, Scroll, Table
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/pricing-dock.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/pricing-dock
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/pricing-dock.json
```

## Usage

The main demo shows typical usage.

### AI API Pricing

An AI API's four plans under a sticky site nav, so the bar docks at `top={56}`. Scroll and each card folds into its column's header. Switch to Yearly from the docked toggle and the docked prices roll; click a docked name to peek at its whole card; hover a column to follow it down.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/pricing-dock-demo
```

```tsx title="components/pricing-dock-demo.tsx"
"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>
  );
}
```

## 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.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/pricing-dock-studio-demo
```

```tsx title="components/pricing-dock-studio-demo.tsx"
"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&apos;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&apos;ll tell you honestly if we&apos;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.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/pricing-dock-compare-demo
```

```tsx title="components/pricing-dock-compare-demo.tsx"
"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 |
| --- | --- | --- | --- |
| `plans` (required) | `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. |
| `groups` (required) | `PricingDockGroup[]` |  | The comparison table: `{ label, rows }` per section. Each label sticks under the docked bar until the next one pushes it out. |
| `billing` | `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. |
| `defaultBilling` | `string` |  | The billing option selected at first. Defaults to the first one. |
| `onBillingChange` | `(id: string) => void` |  | 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` |  | 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>` |  | 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` |  | 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.id` (required) | `string` |  | `PricingDockPlan`: keys this plan's column in each row's `values`. |
| `plan.name` (required) | `string` |  | `PricingDockPlan`: the plan's name, also its column header for screen readers. |
| `plan.price` | `number \| string \| Record<string, number \| string>` |  | `PricingDockPlan`: a number goes through `formatPrice`; text ("Custom") shows as is. One per billing option: `{ monthly: 24, yearly: 19 }`. |
| `plan.period` | `string` |  | `PricingDockPlan`: overrides the period after this plan's price, e.g. `"/seat/mo"`. |
| `plan.caption` | `React.ReactNode \| Record<string, React.ReactNode>` |  | `PricingDockPlan`: small print under the price, one for all billing options or one per option. |
| `plan.logo` | `React.ReactNode` |  | `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` |  | `PricingDockPlan`: one line under the price. Two lines at most. |
| `plan.highlights` | `React.ReactNode[]` |  | `PricingDockPlan`: three to five points under the button. They slide under it and fade as the card docks. |
| `plan.featured` | `boolean` |  | `PricingDockPlan`: tints the card and runs the tint down its whole column. |
| `plan.badge` | `React.ReactNode` |  | `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 }` |  | `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.feature` (required) | `React.ReactNode` |  | `PricingDockRow`: the feature's name, the row's header. |
| `row.hint` | `React.ReactNode` |  | `PricingDockRow`: explanation shown from a small info icon beside the feature. |
| `row.values` (required) | `Record<string, boolean \| React.ReactNode>` |  | `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. |

## Source

```tsx title="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;
}
```
