{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pricing-dock",
  "type": "registry:ui",
  "title": "Pricing Dock",
  "description": "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.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "pricing",
    "scroll",
    "table"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/pricing-dock",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/pricing-dock.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n  type MotionValue,\n  type Variants,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - The fold follows scroll position, not time: the header is natively sticky, one rect read per\n *   scroll frame sets `u` and `t`, and every moving part is a motion value; scrolling re-renders\n *   only when `docked` flips.\n * - `useLive` reads every dependency before it computes, so motion subscribes to all of them even\n *   when a computation returns early.\n * - The table never moves: the header sticks at `top` less `--dock-fold`, so the cards' bottom\n *   edge is the bar's. A peek unfolds one card in place, at its column's width, over the table.\n * - The plan button is one element through the whole fold, resized by motion values, so focus\n *   never breaks.\n * - Reduced motion skips the staged fold: `t` jumps at the dock point, the table slides over the\n *   pinned cards, the heads fade back in on `swap`, and peeks, digits and the phone strip jump.\n */\nconst TIMING = {\n  /**\n   * A peeked card unfolding over the table and folding back. Its visualDuration, in seconds, is\n   * the `peekDuration` prop.\n   */\n  peek: { type: \"spring\", visualDuration: 0.42, bounce: 0.1 },\n  /** A hovered column's head lifting and its glow fading in, as a spring. */\n  lift: { visualDuration: 0.25, bounce: 0.2 },\n  /**\n   * A price digit's wheel rolling when billing changes. Its visualDuration, in seconds, is the\n   * `rollDuration` prop.\n   */\n  roll: { type: \"spring\", visualDuration: 0.5, bounce: 0.08 },\n  /** Between a price's digits as they roll, right to left, in seconds (the `rollStagger` prop). */\n  rollStagger: 0.035,\n  /**\n   * The billing toggle's and the plan pills' thumb sliding, and the phone table's strip moving\n   * to the plan shown.\n   */\n  slide: { type: \"spring\", visualDuration: 0.38, bounce: 0.12 },\n  /** Under reduced motion, the heads fading back in once the bar swaps, in seconds. */\n  swap: { duration: 0.16, ease: \"easeOut\" },\n  /** After the phone's swipe row stops scrolling, before the table shows its middle card, in ms. */\n  settle: 90,\n  /** A row's check marks drawing as it scrolls into view: the first column's wait, in seconds. */\n  checkDelay: 0.06,\n  /** Each next column's extra wait, in seconds. */\n  checkStagger: 0.07,\n  /** A check mark's stroke drawing, in seconds, and its curve. */\n  check: { duration: 0.42, ease: [0.65, 0, 0.35, 1] },\n  /** A table row's hover tint, as a Tailwind class. */\n  rowHover: \"duration-150\",\n  /** The compare link's arrow nudging down on hover, as a Tailwind class. */\n  arrow: \"duration-200\",\n  /** A plan button's hover color, as a Tailwind class. */\n  button: \"duration-150\",\n  /** A billing option's or a plan pill's label color, as a Tailwind class. */\n  segment: \"duration-200\",\n  /** A feature hint's tooltip rising and fading in, as a Tailwind class. */\n  hint: \"duration-150\",\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * PRICING DOCK STORYBOARD   (driven by scroll, not time)\n *\n *  rest     plan cards sit in a row, each one exactly over its\n *           own column of the comparison table below\n *  dock     the cards pin at `top` and fold over the scroll it\n *           takes them to shrink to bar height, so the fold\n *           moves exactly as fast as you scroll and unfolds in\n *           reverse. Pinning is native sticky: nothing lags\n *           the finger or the wheel\n *   flow    highlights scroll on with the page and fade as they\n *           slide under the button, top one first\n *   10–40%  the small print under the price fades\n *   15–45%  the pitch lifts and fades\n *   25–70%  the name rises to the bar's top row\n *   25–85%  the price shrinks to 18px and lands beside the\n *           name, its \"/mo\" tucked in after it\n *   20–60%  the badge lifts and thins to a 2px accent line,\n *           then stretches along the top of its cell\n *   40–100% the button narrows to a compact one. It is the\n *           same element throughout, so focus never breaks\n *   0–100%  gaps close and corners square off into one bar\n *   60–100% hairline dividers take over from card outlines\n *   85–100% the bar gets its background, shadow and border\n *  table    never moves: the cards' bottom edge is the bar's,\n *           so it rises to meet the first row. The featured\n *           tint runs down its column; group labels stick\n *           under the bar until the next one pushes them out\n *  peek     click a docked name and that card unfolds again in\n *           place, over the table, at its column's width. Esc,\n *           outside or scroll folds it back\n *  hover    the column under the pointer glows; its head lifts\n *  phone    cards swipe and snap; plan pills pin over a table\n *           that shows one plan, swipe it sideways\n *  reduced  no staged fold: the table slides up over the pinned\n *           cards, the compact bar fades in at the dock point,\n *           and peek opens instantly\n * ───────────────────────────────────────────────────────── */\n\nconst DOCK = {\n  compact: 92, // px, height of the docked bar\n  wideAt: 48, // rem of container width from which cards sit over their columns\n  gap: 12, // px between cards before they dock; the `gap` prop\n  radius: 20, // px, card corners before they dock; the `radius` prop\n  nameTop: 14, // px from the bar's top to its first row\n  row: 24, // px, height of that row\n  buttonTop: 50, // px from the bar's top to the docked button\n  buttonHeight: 28, // px\n  buttonPad: 12, // px each side of the docked button's label\n  labelScale: 13 / 14, // docked button label, relative to its full size\n  priceSize: 18, // px, docked price\n  periodSize: 12, // px, docked \"/mo\"\n  minPriceSize: 13, // px; a long name squeezes the docked price down to this\n  logoSize: 22, // px, docked logo\n  toggleScale: 0.8, // docked billing toggle\n  fade: 28, // px over which body text fades as it slides under the head\n  lift: 2, // px a hovered column's head rises\n  chevron: 44, // px kept clear at the right of the docked bar for the peek chevron\n};\n\nconst STAGE = {\n  caption: [0.1, 0.4],\n  pitch: [0.15, 0.45],\n  name: [0.25, 0.7],\n  price: [0.25, 0.85],\n  badgeText: [0.2, 0.35],\n  badge: [0.2, 0.6],\n  button: [0.4, 1],\n  toggle: [0.25, 0.85],\n  dividers: [0.6, 1],\n  bar: [0.85, 1],\n} satisfies Record<string, [number, number]>;\n\n/* ── types ──────────────────────────────────────────────── */\n\nexport type PricingDockPrice = number | string;\n\n/** What a plan's button does: a link with `href`, or a button with `onClick`. */\nexport interface PricingDockCta {\n  label: string;\n  href?: string;\n  onClick?: () => void;\n}\n\nexport interface PricingDockPlan {\n  /** Keys this plan's column in each row's `values`. */\n  id: string;\n  name: string;\n  /**\n   * A number is formatted by `formatPrice` and rolls when billing changes.\n   * Text (\"Custom\") shows as is, without a period. Give one per billing\n   * option as `{ monthly: 24, yearly: 19 }`.\n   */\n  price?: PricingDockPrice | Record<string, PricingDockPrice>;\n  /** Overrides the period after the price, e.g. \"/seat/mo\". */\n  period?: string;\n  /** Small print under the price, one for all billing options or one per option. */\n  caption?: React.ReactNode | Record<string, React.ReactNode>;\n  /** Shown instead of a price, e.g. a product's logo. Shrinks in beside the name when docked. */\n  logo?: React.ReactNode;\n  /** One line under the price. */\n  pitch?: React.ReactNode;\n  /** Three to five points under the button. */\n  highlights?: React.ReactNode[];\n  /** Tints the card and runs the tint down its column. */\n  featured?: boolean;\n  /** Label over the name, e.g. \"Most popular\". Flattens into an accent line when docked. */\n  badge?: React.ReactNode;\n  cta?: PricingDockCta;\n}\n\n/** `true` is a check, `false` a dash; anything else renders as is. */\nexport type PricingDockValue = boolean | React.ReactNode;\n\nexport interface PricingDockRow {\n  feature: React.ReactNode;\n  /** Opens from a small info icon beside the feature. */\n  hint?: React.ReactNode;\n  /** One value per plan id. A missing one reads as `false`. */\n  values: Record<string, PricingDockValue>;\n}\n\nexport interface PricingDockGroup {\n  label: string;\n  rows: PricingDockRow[];\n}\n\nexport interface PricingDockBilling {\n  id: string;\n  label: string;\n  /** A short tag beside the label, e.g. \"−20%\". */\n  note?: string;\n  /** Period after each price under this option. */\n  period?: string;\n}\n\nexport interface PricingDockProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  plans: PricingDockPlan[];\n  groups: PricingDockGroup[];\n  /** Billing options for the toggle. Leave out for a single price per plan. */\n  billing?: PricingDockBilling[];\n  /** The billing option selected at first. Defaults to the first one. */\n  defaultBilling?: string;\n  onBillingChange?: (id: string) => void;\n  /** Turns a number price into text. Defaults to whole US dollars. */\n  formatPrice?: (amount: number, plan: PricingDockPlan, billing: string | undefined) => string;\n  /** Period after each number price, unless a billing option or plan sets its own. */\n  period?: string;\n  /** Content in the top-left cell, under the toggle, before the cards dock. */\n  intro?: React.ReactNode;\n  /** Label of the button that scrolls to where the cards have docked. `null` hides it. */\n  compareLabel?: string | null;\n  /** Screen-reader header of the feature column. */\n  featureLabel?: string;\n  /** Where the docked bar pins, in px from the top of the scrollport: your fixed nav's height, or 0. */\n  top?: number;\n  /** Corner radius of the cards before they dock, in px. Squares off as they dock. */\n  radius?: number;\n  /** Space between the cards before they dock, in px. Closes as they dock. */\n  gap?: number;\n  /** How long a peeked card takes to unfold over the table, or fold back, in seconds. */\n  peekDuration?: number;\n  /** How long each price digit takes to roll when billing changes, in seconds. */\n  rollDuration?: number;\n  /** Delay between a price's digits as they roll, right to left, in seconds. */\n  rollStagger?: number;\n  /** Scroll container to track. Defaults to the window. Pass it when the dock scrolls inside a box. */\n  containerRef?: React.RefObject<HTMLElement | null>;\n}\n\n/* ── shared state ───────────────────────────────────────── */\n\ntype Geometry = {\n  wide: boolean;\n  /** Scroll it takes to dock: the cards' full height less the bar's. */\n  fold: number;\n  /** The cards' full height. */\n  full: number;\n  /** The tallest head (name to button). */\n  head: number;\n  /** Each column's left edge and the row's right edge, from the row's left edge. */\n  cols: number[];\n};\n\nconst NO_GEOMETRY: Geometry = { wide: false, fold: 0, full: 0, head: 0, cols: [] };\n\ntype Measurer = {\n  /** Natural height of the head, read before anything is measured from it. */\n  head: () => number;\n  measure: (wide: boolean) => void;\n};\n\ntype Dock = {\n  geo: MotionValue<Geometry>;\n  /** Scroll into the fold, in px: 0 at rest, `fold` once docked. */\n  u: MotionValue<number>;\n  /** Fold progress, 0 → 1. Jumps between the two with reduced motion. */\n  t: MotionValue<number>;\n  /** Fades the heads back in after a reduced-motion swap. */\n  swap: MotionValue<number>;\n  hoverCol: MotionValue<number>;\n  reduced: boolean;\n  radius: number;\n  gap: number;\n  peekDuration: number;\n  rollDuration: number;\n  rollStagger: number;\n  docked: boolean;\n  peek: number | null;\n  togglePeek: (index: number) => void;\n  register: (key: string, measurer: Measurer) => () => void;\n  observe: (elements: (Element | null)[]) => () => void;\n};\n\nconst DockContext = React.createContext<Dock | null>(null);\n\nfunction useDock() {\n  const dock = React.useContext(DockContext);\n  if (!dock) throw new Error(\"PricingDock parts must be rendered inside <PricingDock>.\");\n  return dock;\n}\n\n/** Fades body text by its distance to the line it slides under. */\ntype Column = { fade: (index: number) => number; deps: Dep[] };\nconst ColumnContext = React.createContext<Column>({ fade: () => 1, deps: [] });\n\nconst subscribe = () => () => {};\n\n/** Any motion value, whatever it holds. */\ntype Dep = { get(): unknown };\n\n/**\n * A motion value computed from others. motion only subscribes to the values a\n * computation reads, so this reads every dependency first: early returns can't\n * leave one out.\n */\nfunction useLive<T>(deps: Dep[], compute: () => T) {\n  return useTransform(() => {\n    for (const dep of deps) dep.get();\n    return compute();\n  });\n}\n\n/* ── PricingDock ────────────────────────────────────────── */\n\n/**\n * Plan cards over a comparison table. As you scroll, the cards fold into the\n * table's sticky header, each one straight down into its own column, so the\n * plan names, prices and buttons stay in view all the way down. Click a docked\n * name to see its full card again.\n */\nexport function PricingDock({\n  plans,\n  groups,\n  billing,\n  defaultBilling,\n  onBillingChange,\n  formatPrice = defaultFormatPrice,\n  period = \"/mo\",\n  intro,\n  compareLabel = \"Compare all features\",\n  featureLabel = \"Feature\",\n  top = 0,\n  radius = DOCK.radius,\n  gap = DOCK.gap,\n  peekDuration = TIMING.peek.visualDuration,\n  rollDuration = TIMING.roll.visualDuration,\n  rollStagger = TIMING.rollStagger,\n  containerRef,\n  className,\n  style,\n  ...props\n}: PricingDockProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const headerRef = React.useRef<HTMLDivElement>(null);\n  const rowRef = React.useRef<HTMLDivElement>(null);\n  const measurers = React.useRef(new Map<string, Measurer>());\n  const frame = React.useRef(0);\n  const observer = React.useRef<ResizeObserver | null>(null);\n  const swapTarget = React.useRef(0);\n  const dockedRef = React.useRef(false);\n  const peekRef = React.useRef<{ index: number; scroll: number } | null>(null);\n  const settle = React.useRef(0);\n  const drag = React.useRef<{ id: number; x: number; y: number; from: number; on: boolean } | null>(null);\n\n  const hydrated = React.useSyncExternalStore(subscribe, () => true, () => false);\n  const reduced = (useReducedMotion() ?? false) && hydrated;\n\n  const geo = useMotionValue<Geometry>(NO_GEOMETRY);\n  const u = useMotionValue(0);\n  const t = useMotionValue(0);\n  const swap = useMotionValue(1);\n  const hoverCol = useMotionValue(-1);\n  const { scrollY } = useScroll({ container: containerRef });\n\n  const [billingId, setBillingId] = React.useState(defaultBilling ?? billing?.[0]?.id);\n  const [docked, setDocked] = React.useState(false);\n  const [peek, setPeek] = React.useState<number | null>(null);\n  const featured = Math.max(0, plans.findIndex((plan) => plan.featured));\n  const [shown, setShown] = React.useState(featured);\n  const strip = useMotionValue(featured);\n  const stripX = useTransform(strip, (value) => `${-value * 100}%`);\n\n  const option = billing?.find((item) => item.id === billingId);\n  const hasBadge = plans.some((plan) => plan.badge);\n  const hasCaption = plans.some((plan) => plan.caption);\n  const hasFigure = plans.some((plan) => plan.price !== undefined || plan.logo);\n\n  /** The top of the scrollport, in viewport px. Null until the container mounts. */\n  const viewTop = React.useCallback(() => {\n    if (!containerRef) return 0;\n    const container = containerRef.current;\n    return container ? container.getBoundingClientRect().top + container.clientTop : null;\n  }, [containerRef]);\n\n  // One rect read per scroll frame: how far the header has scrolled past `top`.\n  const update = React.useCallback(() => {\n    const g = geo.get();\n    const header = headerRef.current;\n    const view = viewTop();\n    if (!g.wide || !g.fold || !header || view === null) {\n      u.set(0);\n      t.set(0);\n      return;\n    }\n    const next = clamp(top - (header.getBoundingClientRect().top - view), 0, g.fold);\n    u.set(next);\n    if (!reduced) {\n      t.set(next / g.fold);\n      return;\n    }\n    // No fold: the table slides up over the pinned cards, and at the dock\n    // point the compact bar fades in.\n    const target = next >= g.fold - 0.5 ? 1 : 0;\n    if (target !== swapTarget.current) {\n      swapTarget.current = target;\n      t.set(target);\n      animate(swap, [0, 1], TIMING.swap);\n    }\n  }, [geo, viewTop, top, reduced, u, t, swap]);\n\n  const measure = React.useCallback(() => {\n    frame.current = 0;\n    const root = rootRef.current;\n    const row = rowRef.current;\n    if (!root || !row) return;\n    const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;\n    const wide = root.clientWidth >= DOCK.wideAt * rem;\n    if (!wide) {\n      for (const name of [\"--dock-head\", \"--dock-head-h\", \"--dock-fold\"]) root.style.removeProperty(name);\n      measurers.current.forEach((measurer) => measurer.measure(false));\n      geo.set(NO_GEOMETRY);\n      update();\n      return;\n    }\n\n    let head = 0;\n    measurers.current.forEach((measurer) => (head = Math.max(head, measurer.head())));\n    // The heads pin inside their cells, so they may only take up the bar's\n    // height; the bodies start where the heads' content ends.\n    root.style.setProperty(\"--dock-head\", `${head}px`);\n    root.style.setProperty(\"--dock-head-h\", `${DOCK.compact}px`);\n    const full = row.offsetHeight;\n    const fold = Math.max(0, full - DOCK.compact);\n    root.style.setProperty(\"--dock-fold\", `${fold}px`);\n\n    const cells = Array.from(row.children) as HTMLElement[];\n    const cols = cells.map((cell) => cell.offsetLeft);\n    cols.push(row.offsetWidth);\n    geo.set({ wide, fold, full, head, cols });\n    measurers.current.forEach((measurer) => measurer.measure(true));\n    update();\n  }, [geo, update]);\n\n  const requestMeasure = React.useCallback(() => {\n    if (!frame.current) frame.current = requestAnimationFrame(measure);\n  }, [measure]);\n\n  const register = React.useCallback(\n    (key: string, measurer: Measurer) => {\n      measurers.current.set(key, measurer);\n      requestMeasure();\n      return () => {\n        if (measurers.current.get(key) === measurer) measurers.current.delete(key);\n      };\n    },\n    [requestMeasure],\n  );\n\n  const observe = React.useCallback((elements: (Element | null)[]) => {\n    const live = elements.filter((element): element is Element => !!element);\n    live.forEach((element) => observer.current?.observe(element));\n    return () => live.forEach((element) => observer.current?.unobserve(element));\n  }, []);\n\n  // On a phone, start the swipe row on the plan the table shows.\n  React.useLayoutEffect(() => {\n    const row = rowRef.current;\n    const card = row?.children[featured + 1] as HTMLElement | undefined;\n    if (!row || !card || row.scrollWidth <= row.clientWidth) return;\n    const rowRect = row.getBoundingClientRect();\n    const cardRect = card.getBoundingClientRect();\n    row.scrollLeft += cardRect.left + cardRect.width / 2 - rowRect.left - rowRect.width / 2;\n  }, [featured]);\n\n  React.useLayoutEffect(() => {\n    const ro = new ResizeObserver(() => requestMeasure());\n    observer.current = ro;\n    if (rootRef.current) ro.observe(rootRef.current);\n    if (rowRef.current) ro.observe(rowRef.current);\n    measure();\n    document.fonts?.ready.then(requestMeasure);\n    return () => {\n      ro.disconnect();\n      observer.current = null;\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n    };\n  }, [measure, requestMeasure]);\n\n  useMotionValueEvent(scrollY, \"change\", (value) => {\n    update();\n    const open = peekRef.current;\n    if (open && Math.abs(value - open.scroll) > 6) setPeek(null);\n  });\n\n  useMotionValueEvent(t, \"change\", (value) => {\n    const next = dockedRef.current ? value > 0.9 : value > 0.985;\n    if (next === dockedRef.current) return;\n    dockedRef.current = next;\n    setDocked(next);\n    if (!next) setPeek(null);\n  });\n\n  // Keep a ref of the open peek for the scroll handler, and close it on Esc or\n  // a press anywhere else.\n  React.useEffect(() => {\n    peekRef.current = peek === null ? null : { index: peek, scroll: scrollY.get() };\n    if (peek === null) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") setPeek(null);\n    };\n    const onDown = (event: PointerEvent) => {\n      const cell = rowRef.current?.children[peek + 1];\n      if (cell && !cell.contains(event.target as Node)) setPeek(null);\n    };\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onDown);\n    };\n  }, [peek, scrollY]);\n\n  const togglePeek = React.useCallback((index: number) => {\n    setPeek((open) => (open === index ? null : index));\n  }, []);\n\n  const chooseBilling = (id: string) => {\n    setBillingId(id);\n    onBillingChange?.(id);\n  };\n\n  /** Scrolls to where the cards have just finished docking. */\n  const dockNow = () => {\n    const header = headerRef.current;\n    const view = viewTop();\n    if (!header || view === null) return;\n    const delta = header.getBoundingClientRect().top - view - (top - geo.get().fold);\n    scrollBy(containerRef?.current ?? null, delta);\n  };\n\n  /* phone: one plan's column at a time */\n\n  const showPlan = (index: number, { scrollCards = true } = {}) => {\n    const next = clamp(index, 0, plans.length - 1);\n    setShown(next);\n    animate(strip, next, reduced ? { duration: 0 } : TIMING.slide);\n    const row = rowRef.current;\n    const card = row?.children[next + 1] as HTMLElement | undefined;\n    if (!scrollCards || !row || !card || geo.get().wide) return;\n    const rowRect = row.getBoundingClientRect();\n    const cardRect = card.getBoundingClientRect();\n    const left = row.scrollLeft + cardRect.left + cardRect.width / 2 - rowRect.left - rowRect.width / 2;\n    row.scrollTo({ left, behavior: reduced ? \"instant\" : \"smooth\" });\n  };\n\n  // The card snapped to the middle of the swipe row is the plan the table shows.\n  const onCardsScroll = () => {\n    if (geo.get().wide) return;\n    window.clearTimeout(settle.current);\n    settle.current = window.setTimeout(() => {\n      const row = rowRef.current;\n      if (!row) return;\n      const rowRect = row.getBoundingClientRect();\n      const middle = rowRect.left + rowRect.width / 2;\n      let best = 0;\n      let distance = Infinity;\n      Array.from(row.children)\n        .slice(1)\n        .forEach((card, index) => {\n          const rect = card.getBoundingClientRect();\n          const d = Math.abs(rect.left + rect.width / 2 - middle);\n          if (d < distance) {\n            distance = d;\n            best = index;\n          }\n        });\n      if (best !== strip.get()) showPlan(best, { scrollCards: false });\n    }, TIMING.settle);\n  };\n\n  React.useEffect(() => () => window.clearTimeout(settle.current), []);\n\n  const onBodyPointerDown = (event: React.PointerEvent) => {\n    if (geo.get().wide || event.button !== 0) return;\n    drag.current = { id: event.pointerId, x: event.clientX, y: event.clientY, from: strip.get(), on: false };\n  };\n  const onBodyPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== event.pointerId) return;\n    const dx = event.clientX - d.x;\n    const dy = event.clientY - d.y;\n    if (!d.on) {\n      if (Math.abs(dx) < 10 || Math.abs(dx) < Math.abs(dy) * 1.2) return;\n      d.on = true;\n      event.currentTarget.setPointerCapture(event.pointerId);\n    }\n    const width = event.currentTarget.querySelector(\"[data-dock-values]\")?.clientWidth || 1;\n    strip.jump(clamp(d.from - dx / width, -0.2, plans.length - 0.8));\n  };\n  const onBodyPointerUp = (event: React.PointerEvent) => {\n    const d = drag.current;\n    drag.current = null;\n    if (!d?.on || d.id !== event.pointerId) return;\n    const moved = strip.get() - d.from;\n    const next = Math.abs(moved) > 0.18 ? d.from + Math.sign(moved) : d.from;\n    showPlan(Math.round(next));\n  };\n\n  /* column hover, wide only */\n\n  const onPointerMove = (event: React.PointerEvent) => {\n    const g = geo.get();\n    const row = rowRef.current;\n    if (!g.wide || !row || event.pointerType !== \"mouse\") return;\n    const x = event.clientX - row.getBoundingClientRect().left;\n    let col = -1;\n    for (let i = 1; i < g.cols.length - 1; i++) {\n      if (x >= g.cols[i] && x < g.cols[i + 1]) col = i - 1;\n    }\n    if (col !== hoverCol.get()) hoverCol.set(col);\n  };\n\n  const dock = React.useMemo<Dock>(\n    () => ({\n      geo,\n      u,\n      t,\n      swap,\n      hoverCol,\n      reduced,\n      radius,\n      gap,\n      peekDuration,\n      rollDuration,\n      rollStagger,\n      docked,\n      peek,\n      togglePeek,\n      register,\n      observe,\n    }),\n    [geo, u, t, swap, hoverCol, reduced, radius, gap, peekDuration, rollDuration, rollStagger, docked, peek, togglePeek, register, observe],\n  );\n\n  const cols = `minmax(0,1.15fr) repeat(${plans.length}, minmax(0,1fr))`;\n\n  return (\n    <DockContext.Provider value={dock}>\n      <div\n        ref={rootRef}\n        role=\"table\"\n        aria-label=\"Compare plans\"\n        aria-colcount={plans.length + 1}\n        className={cn(\n          \"@container/dock relative isolate w-full text-foreground\",\n          \"[--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)]\",\n          className,\n        )}\n        style={\n          {\n            \"--dock-top\": `${top}px`,\n            \"--dock-compact\": `${DOCK.compact}px`,\n            \"--dock-half-gap\": `${gap / 2}px`,\n            \"--dock-cols\": cols,\n            ...style,\n          } as React.CSSProperties\n        }\n        onPointerMove={onPointerMove}\n        onPointerLeave={() => hoverCol.set(-1)}\n        {...props}\n      >\n        {/* Header: the cards. Sticks once it has scrolled the whole fold past\n            `top`, so the table under it never moves. */}\n        <div\n          ref={headerRef}\n          role=\"rowgroup\"\n          className={cn(\n            \"relative z-20 @3xl/dock:sticky @3xl/dock:top-[calc(var(--dock-top)-var(--dock-fold))] @3xl/dock:pt-0\",\n            billing && billing.length > 1 && \"pt-14\",\n          )}\n        >\n          <Bar />\n          <div\n            ref={rowRef}\n            role=\"row\"\n            onScroll={onCardsScroll}\n            className={cn(\n              \"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\",\n              \"@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\",\n            )}\n          >\n            <CornerCell\n              label={featureLabel}\n              billing={billing}\n              billingId={billingId}\n              onBillingChange={chooseBilling}\n              intro={intro}\n              compareLabel={compareLabel}\n              onCompare={dockNow}\n            />\n            {plans.map((plan, index) => (\n              <PlanCell\n                key={plan.id}\n                plan={plan}\n                index={index}\n                count={plans.length}\n                billingId={billingId}\n                period={plan.period ?? option?.period ?? period}\n                formatPrice={formatPrice}\n                hasBadge={hasBadge}\n                hasCaption={hasCaption}\n                hasFigure={hasFigure}\n              />\n            ))}\n          </div>\n        </div>\n\n        <PlanPills plans={plans} shown={shown} onShow={(index) => showPlan(index)} reduced={reduced} />\n\n        <div\n          className=\"relative touch-pan-y touch-pinch-zoom @3xl/dock:mt-4 @3xl/dock:touch-auto\"\n          onPointerDown={onBodyPointerDown}\n          onPointerMove={onBodyPointerMove}\n          onPointerUp={onBodyPointerUp}\n          onPointerCancel={() => (drag.current = null)}\n        >\n          {/* The featured column's tint and the hover glow, behind every row. */}\n          <div\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 hidden grid-cols-(--dock-cols) @3xl/dock:grid\"\n          >\n            <span />\n            {plans.map((plan, index) => (\n              <ColumnGlow key={plan.id} index={index} count={plans.length} featured={!!plan.featured} />\n            ))}\n          </div>\n\n          {groups.map((group) => (\n            <div key={group.label} role=\"rowgroup\" className=\"relative\">\n              <div\n                role=\"row\"\n                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)\"\n              >\n                <div\n                  role=\"cell\"\n                  aria-colspan={plans.length + 1}\n                  className=\"px-4 pt-6 pb-2 text-xs font-medium tracking-wide text-muted-foreground\"\n                >\n                  {group.label}\n                </div>\n                {plans.map((plan, index) => (\n                  <LabelTint key={plan.id} index={index} count={plans.length} featured={!!plan.featured} />\n                ))}\n              </div>\n              {group.rows.map((row, rowIndex) => (\n                <motion.div\n                  key={rowIndex}\n                  role=\"row\"\n                  initial=\"hidden\"\n                  whileInView=\"shown\"\n                  viewport={{ once: true, amount: 0.6 }}\n                  className={`grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] border-b border-(--dock-border)/60 transition-colors ${TIMING.rowHover} hover:bg-foreground/[0.025] @3xl/dock:grid-cols-(--dock-cols)`}\n                >\n                  <div role=\"rowheader\" className=\"flex items-center px-4 py-3 text-sm text-foreground/80\">\n                    <span>\n                      {row.feature}\n                      {row.hint ? <Hint>{row.hint}</Hint> : null}\n                    </span>\n                  </div>\n                  <div data-dock-values className=\"overflow-hidden @3xl/dock:contents\">\n                    <motion.div className=\"flex @3xl/dock:contents\" style={{ x: stripX }}>\n                      {plans.map((plan, col) => (\n                        <div\n                          key={plan.id}\n                          role=\"cell\"\n                          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\"\n                        >\n                          <Value value={row.values[plan.id]} col={col} featured={!!plan.featured} reduced={reduced} />\n                        </div>\n                      ))}\n                    </motion.div>\n                  </div>\n                </motion.div>\n              ))}\n            </div>\n          ))}\n        </div>\n      </div>\n    </DockContext.Provider>\n  );\n}\n\nconst TINT = \"bg-[color-mix(in_oklab,var(--dock-accent)_6%,var(--dock-surface))]\";\nconst TINT_PAGE = \"bg-[color-mix(in_oklab,var(--dock-accent)_6%,var(--dock-page))]\";\n\n/* ── Bar: the docked header's background ───────────────── */\n\nfunction Bar() {\n  const { t } = useDock();\n  const opacity = useLive([t], () => ease(stage(t.get(), STAGE.bar)));\n  return (\n    <motion.div\n      aria-hidden\n      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)]\"\n      style={{ opacity }}\n    />\n  );\n}\n\n/* ── Corner: billing toggle and intro ───────────────────── */\n\ntype CornerMetrics = {\n  ready: boolean;\n  toggle: Box | null;\n  /** Scale that fits the toggle in its column at rest; 1 unless its labels are long. */\n  fit: number;\n  fades: number[];\n};\n\n/** The toggle shrinks in place and settles in the middle of the bar. */\nfunction toggleAt(metrics: MotionValue<CornerMetrics>, t: MotionValue<number>) {\n  const m = metrics.get();\n  const box = m.toggle;\n  if (!m.ready || !box) return null;\n  const e = ease(stage(t.get(), STAGE.toggle));\n  const docked = Math.min(m.fit, DOCK.toggleScale);\n  const scale = mix(m.fit, docked, e);\n  const dy = ((DOCK.compact - box.h * docked) / 2 - box.y) * e;\n  return { box, scale, dy };\n}\n\nfunction CornerCell({\n  label,\n  billing,\n  billingId,\n  onBillingChange,\n  intro,\n  compareLabel,\n  onCompare,\n}: {\n  label: string;\n  billing?: PricingDockBilling[];\n  billingId?: string;\n  onBillingChange: (id: string) => void;\n  intro?: React.ReactNode;\n  compareLabel?: string | null;\n  onCompare: () => void;\n}) {\n  const { t, u, swap, register } = useDock();\n  const id = React.useId();\n  const cellRef = React.useRef<HTMLDivElement>(null);\n  const innerRef = React.useRef<HTMLDivElement>(null);\n  const toggleRef = React.useRef<HTMLDivElement>(null);\n  const metrics = useMotionValue<CornerMetrics>({ ready: false, toggle: null, fit: 1, fades: [] });\n\n  React.useLayoutEffect(\n    () =>\n      register(\"corner\", {\n        head: () => 0,\n        measure: (wide) => {\n          const cell = cellRef.current;\n          const inner = innerRef.current;\n          const toggle = toggleRef.current;\n          if (!wide || !cell || !inner) return metrics.set({ ready: false, toggle: null, fit: 1, fades: [] });\n          const pad = parseFloat(getComputedStyle(inner).paddingLeft) || 0;\n          const room = cell.clientWidth - pad * 2;\n          metrics.set({\n            ready: true,\n            toggle: toggle ? box(toggle, inner) : null,\n            fit: toggle ? Math.min(1, room / Math.max(toggle.offsetWidth, 1)) : 1,\n            fades: fadeTops(cell),\n          });\n        },\n      }),\n    [register, metrics],\n  );\n\n  const toggleTransform = useLive([metrics, t], () => {\n    const at = toggleAt(metrics, t);\n    return at && (at.dy || at.scale !== 1) ? `translateY(${at.dy}px) scale(${at.scale})` : \"none\";\n  });\n\n  const column = React.useMemo<Column>(\n    () => ({\n      fade: (index) => {\n        const m = metrics.get();\n        const top = m.fades[index];\n        if (!m.ready || top === undefined) return 1;\n        // Slides under the toggle, or under the top of the bar without one.\n        const at = toggleAt(metrics, t);\n        const line = at ? at.box.y + at.dy + at.box.h * at.scale + 8 : DOCK.nameTop;\n        const rest = at ? top - (at.box.y + at.box.h + 8) : top - DOCK.nameTop;\n        // Gone before the bar forms around it.\n        return fadeBy(top - u.get() - line, rest) * (1 - stage(t.get(), [0.5, 0.8]));\n      },\n      deps: [metrics, u, t],\n    }),\n    [metrics, u, t],\n  );\n\n  return (\n    <div\n      ref={cellRef}\n      role=\"columnheader\"\n      aria-labelledby={id}\n      className=\"absolute inset-x-0 top-0 flex justify-center @3xl/dock:relative @3xl/dock:flex-col @3xl/dock:justify-start\"\n    >\n      <span id={id} className=\"sr-only\">\n        {label}\n      </span>\n      <div className=\"relative z-10 @3xl/dock:sticky @3xl/dock:top-(--dock-top)\">\n        <motion.div ref={innerRef} className=\"relative px-4 @3xl/dock:pt-5\" style={{ opacity: swap }}>\n          {billing && billing.length > 1 ? (\n            <motion.div ref={toggleRef} className=\"w-fit origin-top-left\" style={{ transform: toggleTransform }}>\n              <BillingToggle options={billing} value={billingId} onChange={onBillingChange} />\n            </motion.div>\n          ) : null}\n        </motion.div>\n      </div>\n      <ColumnContext.Provider value={column}>\n        <div className=\"hidden flex-1 flex-col gap-4 px-4 pt-5 pb-6 @3xl/dock:flex\">\n          {intro ? (\n            <FadeItem index={0} className=\"text-sm/6 text-pretty text-muted-foreground\">\n              {intro}\n            </FadeItem>\n          ) : null}\n          {compareLabel ? (\n            <FadeItem index={intro ? 1 : 0} className=\"mt-auto\">\n              <button\n                type=\"button\"\n                onClick={onCompare}\n                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\"\n              >\n                {compareLabel}\n                <svg\n                  viewBox=\"0 0 16 16\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.75\"\n                  aria-hidden\n                  className={`size-3.5 transition-transform ${TIMING.arrow} group-hover/compare:translate-y-0.5`}\n                >\n                  <path d=\"M8 3v10m0 0 4-4m-4 4-4-4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                </svg>\n              </button>\n            </FadeItem>\n          ) : null}\n        </div>\n      </ColumnContext.Provider>\n    </div>\n  );\n}\n\n/* ── PlanCell: one card, folding into its header cell ──── */\n\ntype Part = { x: number; y: number; w: number; h: number };\n/**\n * The price (or logo) shrinks toward a fixed point, (px, py), so it moves in a\n * straight line and never pokes out of its card on the way.\n */\ntype Figure = Part & { base: number; scale: number; px: number; py: number };\n/** \"/mo\": follows the price's right edge and sits on its baseline. */\ntype Period = Part & { base: number; scale: number; gap: number };\n\ntype PlanMetrics = {\n  ready: boolean;\n  cellW: number;\n  insetL: number;\n  insetR: number;\n  name: Part | null;\n  nameTo: { dx: number; dy: number };\n  figure: Figure | null;\n  period: Period | null;\n  badge: Part | null;\n  /** The button's slot, with its docked width and label scale. */\n  slot: (Part & { toW: number; labelTo: number }) | null;\n  fades: number[];\n};\n\nconst NO_METRICS: PlanMetrics = {\n  ready: false,\n  cellW: 0,\n  insetL: 0,\n  insetR: 0,\n  name: null,\n  nameTo: { dx: 0, dy: 0 },\n  figure: null,\n  period: null,\n  badge: null,\n  slot: null,\n  fades: [],\n};\n\nfunction PlanCell({\n  plan,\n  index,\n  count,\n  billingId,\n  period,\n  formatPrice,\n  hasBadge,\n  hasCaption,\n  hasFigure,\n}: {\n  plan: PricingDockPlan;\n  index: number;\n  count: number;\n  billingId?: string;\n  period: string;\n  formatPrice: NonNullable<PricingDockProps[\"formatPrice\"]>;\n  hasBadge: boolean;\n  hasCaption: boolean;\n  hasFigure: boolean;\n}) {\n  const {\n    geo,\n    u,\n    t,\n    swap,\n    hoverCol,\n    reduced,\n    radius,\n    gap: cardGap,\n    peekDuration,\n    rollDuration,\n    rollStagger,\n    docked,\n    peek,\n    togglePeek,\n    register,\n    observe,\n  } = useDock();\n  const nameId = React.useId();\n  const cellRef = React.useRef<HTMLDivElement>(null);\n  const innerRef = React.useRef<HTMLDivElement>(null);\n  const nameRef = React.useRef<HTMLSpanElement>(null);\n  const badgeRef = React.useRef<HTMLSpanElement>(null);\n  const figureRef = React.useRef<HTMLSpanElement>(null);\n  const periodRef = React.useRef<HTMLSpanElement>(null);\n  const slotRef = React.useRef<HTMLDivElement>(null);\n  const labelRef = React.useRef<HTMLSpanElement>(null);\n  const metrics = useMotionValue<PlanMetrics>(NO_METRICS);\n\n  const open = peek === index;\n  const p = useMotionValue(0);\n  const lift = useSpring(0, TIMING.lift);\n\n  React.useEffect(() => {\n    if (reduced) p.jump(open ? 1 : 0);\n    else animate(p, open ? 1 : 0, { ...TIMING.peek, visualDuration: peekDuration });\n  }, [open, reduced, p, peekDuration]);\n\n  useMotionValueEvent(hoverCol, \"change\", (col) => lift.set(col === index ? 1 : 0));\n\n  const price = pick(plan.price, billingId);\n  const amount = typeof price === \"number\" ? price : null;\n  const priceText = price === undefined ? null : typeof price === \"number\" ? formatPrice(price, plan, billingId) : price;\n  const caption = pick(plan.caption, billingId);\n\n  React.useLayoutEffect(\n    () =>\n      register(plan.id, {\n        head: () => innerRef.current?.offsetHeight ?? 0,\n        measure: (wide) => {\n          const cell = cellRef.current;\n          const inner = innerRef.current;\n          if (!wide || !cell || !inner) return metrics.set(NO_METRICS);\n          metrics.set(\n            measurePlan({\n              cell,\n              inner,\n              name: nameRef.current,\n              badge: badgeRef.current,\n              figure: figureRef.current,\n              period: periodRef.current,\n              slot: slotRef.current,\n              label: labelRef.current,\n              logo: !!plan.logo && price === undefined,\n              insetL: index > 0 ? cardGap / 2 : 0,\n              insetR: index < count - 1 ? cardGap / 2 : 0,\n            }),\n          );\n        },\n      }),\n    [register, metrics, plan.id, plan.logo, price, index, count, cardGap],\n  );\n\n  React.useLayoutEffect(\n    () => observe([innerRef.current, figureRef.current, labelRef.current]),\n    [observe],\n  );\n\n  const state = React.useMemo<PlanState>(\n    () => ({ metrics, geo, u, t, p, reduced }),\n    [metrics, geo, u, t, p, reduced],\n  );\n  const live = React.useMemo<Dep[]>(() => [metrics, geo, u, t, p], [metrics, geo, u, t, p]);\n  const column = React.useMemo<Column>(() => ({ fade: (k) => fadeOf(state, k), deps: live }), [state, live]);\n\n  /* shell: gaps close, corners square, outline gives way to a divider */\n  const frame = useLive(live, () => ease(foldOf(state)));\n  // Gaps follow the dock, not the peek: a peeked card keeps its column's width.\n  const gap = useLive(live, () => 1 - ease(t.get()));\n  const shellLeft = useLive(live, () => `calc(var(--dock-inset-l, 0px) * ${gap.get()})`);\n  const shellRight = useLive(live, () => `calc(var(--dock-inset-r, 0px) * ${gap.get()})`);\n  const shellRadius = useLive(live, () => radius * (1 - frame.get()));\n  const shellBottom = useLive(live, () => -dropOf(state));\n  const ringOpacity = useLive(live, () => 1 - stage(foldOf(state), STAGE.dividers));\n  const dividerOpacity = useLive(live, () => (geo.get().wide ? stage(foldOf(state), STAGE.dividers) : 0));\n  const peekShadow = useLive(live, () => p.get() * t.get());\n\n  /* head */\n  const headY = useTransform(lift, (value) => -DOCK.lift * value);\n\n  const badgeText = useLive(live, () => 1 - stage(foldOf(state), STAGE.badgeText));\n  const blobX = useLive(live, () => badgeOf(state)?.x ?? 0);\n  const blobY = useLive(live, () => badgeOf(state)?.y ?? 0);\n  const blobW = useLive(live, () => badgeOf(state)?.w ?? \"100%\");\n  const blobH = useLive(live, () => badgeOf(state)?.h ?? \"100%\");\n  const blobRadius = useLive(live, () => badgeOf(state)?.radius ?? 999);\n\n  const nameTransform = useLive(live, () => {\n    const m = metrics.get();\n    const e = ease(stage(foldOf(state), STAGE.name));\n    if (!m.ready || e === 0) return \"none\";\n    return `translate(${m.nameTo.dx * e}px, ${m.nameTo.dy * e}px)`;\n  });\n  const figureTransform = useLive(live, () => figureOf(state)?.figure ?? \"none\");\n  const periodTransform = useLive(live, () => figureOf(state)?.period ?? \"none\");\n\n  const captionOpacity = useLive(live, () => 1 - ease(stage(foldOf(state), STAGE.caption)));\n  const captionY = useLive(live, () => -4 * ease(stage(foldOf(state), STAGE.caption)));\n  const pitchOpacity = useLive(live, () => 1 - ease(stage(foldOf(state), STAGE.pitch)));\n  const pitchY = useLive(live, () => -6 * ease(stage(foldOf(state), STAGE.pitch)));\n\n  const buttonWidth = useLive(live, () => {\n    const b = buttonOf(state);\n    return b && b.e > 0 ? b.w : \"100%\";\n  });\n  const buttonHeight = useLive(live, () => {\n    const b = buttonOf(state);\n    return b && (b.e > 0 || b.dy) ? b.h : \"100%\";\n  });\n  const buttonY = useLive(live, () => buttonOf(state)?.dy ?? 0);\n  const labelScale = useLive(live, () => mix(1, metrics.get().slot?.labelTo ?? 1, buttonOf(state)?.e ?? 0));\n\n  const chevronOpacity = useLive(live, () => ease(stage(t.get(), STAGE.bar)));\n  const chevronRotate = useLive(live, () => 180 * p.get());\n\n  /* body: scrolls on with the page; a peek drops it back under the head */\n  const bodyY = useLive(live, () => dropOf(state));\n\n  return (\n    <div\n      ref={cellRef}\n      role=\"columnheader\"\n      aria-labelledby={nameId}\n      className={cn(\n        \"relative flex w-[min(80cqi,19rem)] shrink-0 snap-center flex-col @3xl/dock:w-auto\",\n        // The table slides over the pinned card instead. `clip` keeps sticky working.\n        reduced && !open && \"@3xl/dock:overflow-clip\",\n        index > 0 && \"@3xl/dock:[--dock-inset-l:var(--dock-half-gap)]\",\n        index < count - 1 && \"@3xl/dock:[--dock-inset-r:var(--dock-half-gap)]\",\n      )}\n    >\n      {/* shell: the card's surface, outline and divider */}\n      <motion.div\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute inset-y-0 bg-(--dock-surface)\",\n          plan.featured && TINT,\n        )}\n        style={{ left: shellLeft, right: shellRight, bottom: shellBottom, borderRadius: shellRadius }}\n      >\n        <motion.span\n          className={cn(\n            \"absolute inset-0 rounded-[inherit] ring-1 ring-(--dock-border) ring-inset\",\n            plan.featured && \"ring-[color-mix(in_oklab,var(--dock-accent)_35%,transparent)]\",\n          )}\n          style={{ opacity: ringOpacity }}\n        />\n        <motion.span className=\"absolute inset-y-0 left-0 w-px bg-(--dock-border)\" style={{ opacity: dividerOpacity }} />\n        <motion.span\n          className=\"absolute inset-0 rounded-[inherit] shadow-[0_24px_48px_-20px_rgb(0_0_0/0.35)]\"\n          style={{ opacity: peekShadow }}\n        />\n        <motion.span className=\"absolute inset-0 rounded-[inherit] bg-foreground/[0.025]\" style={{ opacity: lift }} />\n      </motion.div>\n\n      {/* head: pins at the top while the cell scrolls past */}\n      <div className=\"relative z-10 @3xl/dock:sticky @3xl/dock:top-(--dock-top) @3xl/dock:h-(--dock-head-h)\">\n        <motion.div\n          ref={innerRef}\n          className=\"relative px-4 pt-5 @5xl/dock:px-5\"\n          style={{ y: headY, opacity: swap }}\n        >\n          {hasBadge ? (\n            <div className=\"mb-3 h-[22px]\">\n              {plan.badge ? (\n                <span\n                  ref={badgeRef}\n                  className=\"relative inline-flex h-[22px] items-center px-2.5 text-[11px] font-medium whitespace-nowrap text-(--dock-accent-foreground)\"\n                >\n                  <motion.span\n                    aria-hidden\n                    className=\"absolute top-0 left-0 bg-(--dock-accent)\"\n                    style={{ x: blobX, y: blobY, width: blobW, height: blobH, borderRadius: blobRadius }}\n                  />\n                  <motion.span className=\"relative\" style={{ opacity: badgeText }}>\n                    {plan.badge}\n                  </motion.span>\n                </span>\n              ) : null}\n            </div>\n          ) : null}\n\n          <div\n            className={cn(\"flex h-6 items-center\", docked && \"cursor-pointer\")}\n            onClick={docked ? () => togglePeek(index) : undefined}\n          >\n            <motion.span\n              ref={nameRef}\n              id={nameId}\n              className=\"truncate text-[15px] font-semibold tracking-tight\"\n              style={{ transform: nameTransform }}\n            >\n              {plan.name}\n              <Baseline />\n            </motion.span>\n          </div>\n\n          {hasFigure ? (\n            <div className=\"mt-3 flex h-[clamp(1.925rem,4.62cqi,3.3rem)] items-baseline gap-1 whitespace-nowrap\">\n              {priceText !== null ? (\n                <>\n                  <motion.span\n                    ref={figureRef}\n                    className=\"relative inline-flex origin-top-left text-[clamp(1.75rem,4.2cqi,3rem)] leading-[1.1] font-semibold tracking-tight tabular-nums\"\n                    style={{ transform: figureTransform }}\n                  >\n                    <RollingPrice\n                      text={priceText}\n                      amount={amount}\n                      duration={rollDuration}\n                      stagger={rollStagger}\n                      reduced={reduced}\n                    />\n                    <span className=\"sr-only\">\n                      {priceText}\n                      {amount !== null && period ? ` ${period}` : \"\"}\n                    </span>\n                  </motion.span>\n                  {amount !== null && period ? (\n                    <motion.span\n                      ref={periodRef}\n                      aria-hidden\n                      className=\"origin-top-left text-sm text-muted-foreground\"\n                      style={{ transform: periodTransform }}\n                    >\n                      {period}\n                      <Baseline />\n                    </motion.span>\n                  ) : null}\n                </>\n              ) : plan.logo ? (\n                <motion.span\n                  ref={figureRef}\n                  className=\"inline-flex origin-top-left self-center\"\n                  style={{ transform: figureTransform }}\n                >\n                  {plan.logo}\n                </motion.span>\n              ) : null}\n            </div>\n          ) : null}\n\n          {hasCaption ? (\n            <motion.p\n              className=\"mt-1 h-4 truncate text-xs text-muted-foreground\"\n              style={{ opacity: captionOpacity, y: captionY }}\n            >\n              {caption}\n            </motion.p>\n          ) : null}\n\n          {plan.pitch ? (\n            <motion.p\n              className=\"mt-3 line-clamp-2 min-h-10 text-sm/5 text-pretty text-muted-foreground\"\n              style={{ opacity: pitchOpacity, y: pitchY }}\n            >\n              {plan.pitch}\n            </motion.p>\n          ) : (\n            <div className=\"mt-3 min-h-10\" />\n          )}\n\n          <div ref={slotRef} className=\"relative mt-5 h-10\">\n            {plan.cta ? (\n              <PlanButton\n                cta={plan.cta}\n                featured={!!plan.featured}\n                style={{ width: buttonWidth, height: buttonHeight, y: buttonY }}\n              >\n                <motion.span ref={labelRef} className=\"inline-block\" style={{ scale: labelScale }}>\n                  {plan.cta.label}\n                </motion.span>\n              </PlanButton>\n            ) : null}\n          </div>\n\n          <motion.button\n            type=\"button\"\n            aria-label={`${plan.name} details`}\n            aria-expanded={open}\n            aria-hidden={!docked}\n            tabIndex={docked ? 0 : -1}\n            onClick={() => togglePeek(index)}\n            className={cn(\n              \"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\",\n              !docked && \"pointer-events-none\",\n            )}\n            style={{ opacity: chevronOpacity }}\n          >\n            <motion.svg\n              viewBox=\"0 0 16 16\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.75\"\n              aria-hidden\n              className=\"size-3.5\"\n              style={{ rotate: chevronRotate }}\n            >\n              <path d=\"m4 6 4 4 4-4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </motion.svg>\n          </motion.button>\n        </motion.div>\n      </div>\n\n      {/* body: highlights */}\n      <ColumnContext.Provider value={column}>\n        <motion.div\n          className=\"relative flex-1 px-4 pt-6 pb-6 @3xl/dock:mt-[calc(var(--dock-head)-var(--dock-compact))] @5xl/dock:px-5\"\n          style={{ y: bodyY }}\n        >\n          {plan.highlights?.length ? (\n            <ul className=\"space-y-2.5 text-sm/5\">\n              {plan.highlights.map((item, k) => (\n                <FadeItem key={k} index={k} as=\"li\" className=\"flex gap-2.5 text-foreground/80\">\n                  <svg\n                    viewBox=\"0 0 16 16\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2\"\n                    aria-hidden\n                    className={cn(\n                      \"mt-0.5 size-4 shrink-0\",\n                      plan.featured ? \"text-(--dock-accent)\" : \"text-foreground/50\",\n                    )}\n                  >\n                    <path d=\"m3.5 8.5 3 3 6-7\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                  </svg>\n                  <span>{item}</span>\n                </FadeItem>\n              ))}\n            </ul>\n          ) : null}\n        </motion.div>\n      </ColumnContext.Provider>\n    </div>\n  );\n}\n\ntype PlanState = {\n  metrics: MotionValue<PlanMetrics>;\n  geo: MotionValue<Geometry>;\n  u: MotionValue<number>;\n  t: MotionValue<number>;\n  /** 0 → 1 as a peek unfolds this card again. */\n  p: MotionValue<number>;\n  reduced: boolean;\n};\n\n/** This card's fold: the dock's, less whatever a peek undoes. */\nfunction foldOf(s: PlanState) {\n  return s.t.get() * (1 - s.p.get());\n}\n\n/** How far a peek drops the card below the bar, in px. */\nfunction dropOf(s: PlanState) {\n  return s.geo.get().fold * s.t.get() * s.p.get();\n}\n\nfunction buttonOf(s: PlanState) {\n  const m = s.metrics.get();\n  const slot = m.slot;\n  if (!m.ready || !slot) return null;\n  const e = ease(stage(foldOf(s), STAGE.button));\n  const w = mix(slot.w, slot.toW, e);\n  const h = mix(slot.h, DOCK.buttonHeight, e);\n  // The card's bottom edge, in head px. The button rides it up if its own\n  // move hasn't lifted it clear yet.\n  const edge = s.geo.get().full - s.u.get() + dropOf(s);\n  const y = s.reduced ? mix(slot.y, DOCK.buttonTop, e) : Math.min(mix(slot.y, DOCK.buttonTop, e), edge - 14 - h);\n  return { e, w, h, dy: y - slot.y };\n}\n\n/** The badge's fill, flattening into a line along the top of the cell. */\nfunction badgeOf(s: PlanState) {\n  const m = s.metrics.get();\n  const from = m.badge;\n  const e = stage(foldOf(s), STAGE.badge);\n  if (!m.ready || !from || e === 0) return null;\n  // Lifts and thins first, clearing the way for the name, then stretches.\n  const up = easeOut(e);\n  const out = ease(e);\n  const open = 1 - ease(s.t.get());\n  const x = m.insetL * open;\n  const w = m.cellW - (m.insetL + m.insetR) * open;\n  return {\n    x: (x - from.x) * out,\n    y: -from.y * up,\n    w: mix(from.w, w, out),\n    h: mix(from.h, 2, up),\n    radius: (from.h / 2) * (1 - up),\n  };\n}\n\n/** Where the price (or logo) and its period are, partway through the fold. */\nfunction figureOf(s: PlanState) {\n  const m = s.metrics.get();\n  const f = m.figure;\n  const raw = stage(foldOf(s), STAGE.price);\n  if (!m.ready || !f || raw === 0) return null;\n  // Shrinks and slides over first, then rises into the name's row, so it\n  // passes beside a long name instead of through it.\n  const e = ease(stage(raw, [0, 0.85]));\n  const rise = ease(stage(raw, [0.2, 1]));\n  const k = mix(1, f.scale, e);\n  const still = Math.abs(1 - f.scale) < 0.01;\n  const dx = still ? (f.px - f.x) * e : (f.px - f.x) * (1 - k);\n  const dy = still ? (f.py - f.y) * rise : (f.py - f.y) * (1 - mix(1, f.scale, rise));\n  const figure = `translate(${dx}px, ${dy}px) scale(${k})`;\n  const p = m.period;\n  if (!p) return { figure, period: \"none\" };\n  // Glued to the price: just after its right edge, on its baseline.\n  const kp = mix(1, p.scale, e);\n  const left = f.x + dx + f.w * k + mix(p.gap, 2, e);\n  const base = f.y + dy + (f.base - f.y) * k;\n  const period = `translate(${left - p.x}px, ${base - p.y - (p.base - p.y) * kp}px) scale(${kp})`;\n  return { figure, period };\n}\n\n/** Body text fades by its distance to the button it slides under. */\nfunction fadeOf(s: PlanState, k: number) {\n  const m = s.metrics.get();\n  const top = m.fades[k];\n  if (!m.ready || top === undefined) return 1;\n  const b = buttonOf(s);\n  if (!b || !m.slot) return 1;\n  const line = m.slot.y + b.dy + b.h + 6;\n  const fold = foldOf(s);\n  // Whatever hasn't slid under yet is gone before the bar forms.\n  return fadeBy(top - s.u.get() + dropOf(s) - line, top - (m.slot.y + m.slot.h + 6)) * (1 - stage(fold, [0.35, 0.65]));\n}\n\n/**\n * Opacity of text `distance` px below the line it slides under. Text that\n * starts closer than the fade distance starts opaque and fades over what it has.\n */\nfunction fadeBy(distance: number, rest: number) {\n  return clamp(distance / clamp(rest, 4, DOCK.fade), 0, 1);\n}\n\nfunction PlanButton({\n  cta,\n  featured,\n  style,\n  children,\n}: {\n  cta: PricingDockCta;\n  featured: boolean;\n  style: React.ComponentProps<typeof motion.a>[\"style\"];\n  children: React.ReactNode;\n}) {\n  const className = cn(\n    `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 ${TIMING.button} focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground`,\n    featured\n      ? \"bg-(--dock-accent) text-(--dock-accent-foreground) hover:bg-[color-mix(in_oklab,var(--dock-accent)_88%,var(--dock-surface))]\"\n      : \"bg-foreground/[0.06] text-foreground ring-1 ring-(--dock-border) ring-inset hover:bg-foreground/10\",\n  );\n  return cta.href ? (\n    <motion.a href={cta.href} onClick={cta.onClick} className={className} style={style}>\n      {children}\n    </motion.a>\n  ) : (\n    <motion.button type=\"button\" onClick={cta.onClick} className={className} style={style}>\n      {children}\n    </motion.button>\n  );\n}\n\n/** The docked button hugs its label, leaving room for the peek chevron. */\nfunction dockedButton(full: number, label: number, room: number) {\n  const fit = Math.min(full, room);\n  const labelTo = clamp((fit - DOCK.buttonPad * 2) / Math.max(label, 1), 0.8, DOCK.labelScale);\n  return { toW: Math.min(fit, label * labelTo + DOCK.buttonPad * 2), labelTo };\n}\n\nfunction measurePlan({\n  cell,\n  inner,\n  name,\n  badge,\n  figure,\n  period,\n  slot,\n  label,\n  logo,\n  insetL,\n  insetR,\n}: {\n  cell: HTMLElement;\n  inner: HTMLElement;\n  name: HTMLElement | null;\n  badge: HTMLElement | null;\n  figure: HTMLElement | null;\n  period: HTMLElement | null;\n  slot: HTMLElement | null;\n  label: HTMLElement | null;\n  logo: boolean;\n  insetL: number;\n  insetR: number;\n}): PlanMetrics {\n  const padX = parseFloat(getComputedStyle(inner).paddingLeft) || 0;\n  const cellW = cell.offsetWidth;\n  const right = cellW - padX;\n\n  const n = name ? box(name, inner) : null;\n  const nameY = n ? DOCK.nameTop + (DOCK.row - n.h) / 2 : 0;\n  const nameTo = { dx: 0, dy: n ? nameY - n.y : 0 };\n  const nameBase = n && name ? baseline(name, inner) + nameTo.dy : 0;\n\n  let f: Figure | null = null;\n  let pr: Period | null = null;\n  if (figure && n) {\n    const fb = box(figure, inner);\n    if (logo) {\n      const scale = DOCK.logoSize / Math.max(fb.h, 1);\n      const x = padX;\n      const y = DOCK.nameTop + DOCK.row / 2 - (fb.h * scale) / 2;\n      f = { ...fb, base: fb.y + fb.h, scale, ...center(fb, x, y, scale) };\n      nameTo.dx = padX + fb.w * scale + 8 - n.x;\n    } else {\n      const size = parseFloat(getComputedStyle(figure).fontSize) || 48;\n      const pb = period ? box(period, inner) : null;\n      const periodSize = period ? parseFloat(getComputedStyle(period).fontSize) || 14 : 14;\n      let scale = DOCK.priceSize / size;\n      let periodScale = DOCK.periodSize / periodSize;\n      const x = n.x + n.w + 7;\n      const need = fb.w * scale + (pb ? 2 + pb.w * periodScale : 0);\n      if (need > right - x) {\n        const k = Math.max((right - x) / need, DOCK.minPriceSize / DOCK.priceSize);\n        scale *= k;\n        periodScale *= k;\n      }\n      const base = baseline(figure, inner);\n      const y = nameBase - (base - fb.y) * scale;\n      f = { ...fb, base, scale, ...center(fb, x, y, scale) };\n      if (pb && period) {\n        pr = { ...pb, base: baseline(period, inner), scale: periodScale, gap: pb.x - (fb.x + fb.w) };\n      }\n    }\n  }\n\n  const s = slot ? box(slot, inner) : null;\n  const labelW = label ? label.offsetWidth : 0;\n\n  return {\n    ready: true,\n    cellW,\n    insetL,\n    insetR,\n    name: n,\n    nameTo,\n    figure: f,\n    period: pr,\n    badge: badge ? box(badge, inner) : null,\n    slot: s ? { ...s, ...dockedButton(s.w, labelW, cellW - padX - DOCK.chevron) } : null,\n    fades: fadeTops(cell),\n  };\n}\n\n/* ── ColumnGlow: featured tint and hover glow ───────────── */\n\n/** The card's gap, closing as it docks, for anything that lines up with a card. */\nfunction useColumnInsets() {\n  const { t } = useDock();\n  const open = useLive([t], () => 1 - ease(t.get()));\n  const left = useLive([t], () => `calc(var(--dock-inset-l, 0px) * ${open.get()})`);\n  const right = useLive([t], () => `calc(var(--dock-inset-r, 0px) * ${open.get()})`);\n  return { open, left, right };\n}\n\nfunction insetVars(index: number, count: number) {\n  return cn(\n    index > 0 && \"[--dock-inset-l:var(--dock-half-gap)]\",\n    index < count - 1 && \"[--dock-inset-r:var(--dock-half-gap)]\",\n  );\n}\n\nfunction ColumnGlow({ index, count, featured }: { index: number; count: number; featured: boolean }) {\n  const { hoverCol, t, radius: corner } = useDock();\n  const glow = useSpring(0, TIMING.lift);\n  useMotionValueEvent(hoverCol, \"change\", (col) => glow.set(col === index ? 1 : 0));\n  // Follows the card above it: inset like the card at rest, full width docked.\n  const { open, left, right } = useColumnInsets();\n  const radius = useLive([t], () => {\n    const top = corner * 0.7 * open.get();\n    return `${top}px ${top}px ${corner * 0.8}px ${corner * 0.8}px`;\n  });\n  return (\n    <span className={cn(\"relative\", insetVars(index, count))}>\n      <motion.span\n        className={cn(\"absolute inset-y-0\", featured && TINT_PAGE)}\n        style={{ left, right, borderRadius: radius }}\n      >\n        <motion.span className=\"absolute inset-0 rounded-[inherit] bg-foreground/[0.03]\" style={{ opacity: glow }} />\n      </motion.span>\n    </span>\n  );\n}\n\n/** A group label row's cell over a plan column, carrying the featured tint at its width. */\nfunction LabelTint({ index, count, featured }: { index: number; count: number; featured: boolean }) {\n  const { left, right } = useColumnInsets();\n  return (\n    <span aria-hidden className={cn(\"relative hidden @3xl/dock:block\", insetVars(index, count))}>\n      {featured ? <motion.span className={cn(\"absolute inset-y-0\", TINT_PAGE)} style={{ left, right }} /> : null}\n    </span>\n  );\n}\n\n/* ── FadeItem: body text that fades as it slides under ─── */\n\nfunction FadeItem({\n  index,\n  as = \"div\",\n  className,\n  children,\n}: {\n  index: number;\n  as?: \"div\" | \"li\";\n  className?: string;\n  children: React.ReactNode;\n}) {\n  const column = React.useContext(ColumnContext);\n  const opacity = useLive(column.deps, () => column.fade(index));\n  const Tag = as === \"li\" ? motion.li : motion.div;\n  return (\n    <Tag data-dock-fade className={className} style={{ opacity }}>\n      {children}\n    </Tag>\n  );\n}\n\n/* ── BillingToggle ──────────────────────────────────────── */\n\nfunction BillingToggle({\n  options,\n  value,\n  onChange,\n}: {\n  options: PricingDockBilling[];\n  value?: string;\n  onChange: (id: string) => void;\n}) {\n  const index = Math.max(0, options.findIndex((option) => option.id === value));\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  const onKeyDown = (event: React.KeyboardEvent) => {\n    const step = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? 1 : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? -1 : 0;\n    if (!step) return;\n    event.preventDefault();\n    const next = (index + step + options.length) % options.length;\n    onChange(options[next].id);\n    refs.current[next]?.focus();\n  };\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label=\"Billing period\"\n      onKeyDown={onKeyDown}\n      className=\"relative grid h-9 rounded-full bg-foreground/[0.06] p-0.5 text-[13px] font-medium\"\n      style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}\n    >\n      <motion.span\n        aria-hidden\n        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]\"\n        style={{ width: `calc((100% - 4px) / ${options.length})` }}\n        initial={false}\n        animate={{ x: `${index * 100}%` }}\n        transition={TIMING.slide}\n      />\n      {options.map((option, i) => {\n        const on = i === index;\n        return (\n          <button\n            key={option.id}\n            ref={(element) => {\n              refs.current[i] = element;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={on}\n            tabIndex={on ? 0 : -1}\n            onClick={() => onChange(option.id)}\n            className={cn(\n              `relative flex items-center justify-center rounded-full px-3.5 whitespace-nowrap transition-colors ${TIMING.segment} focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground`,\n              on ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n            )}\n          >\n            {option.label}\n            {option.note ? (\n              // Floats on the option's corner, so it doesn't widen every segment.\n              <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\">\n                {option.note}\n              </span>\n            ) : null}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n/* ── PlanPills: the phone's docked header ───────────────── */\n\nfunction PlanPills({\n  plans,\n  shown,\n  onShow,\n  reduced,\n}: {\n  plans: PricingDockPlan[];\n  shown: number;\n  onShow: (index: number) => void;\n  reduced: boolean;\n}) {\n  return (\n    <div className=\"sticky top-(--dock-top) z-20 border-b border-(--dock-border) bg-(--dock-surface) px-4 py-2 @3xl/dock:hidden\">\n      <div\n        role=\"group\"\n        aria-label=\"Plan shown in the table\"\n        className=\"relative grid h-9 rounded-full bg-foreground/[0.06] p-0.5 text-[13px] font-medium\"\n        style={{ gridTemplateColumns: `repeat(${plans.length}, minmax(0, 1fr))` }}\n      >\n        <motion.span\n          aria-hidden\n          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]\"\n          style={{ width: `calc((100% - 4px) / ${plans.length})` }}\n          initial={false}\n          animate={{ x: `${shown * 100}%` }}\n          transition={reduced ? { duration: 0 } : TIMING.slide}\n        />\n        {plans.map((plan, index) => (\n          <button\n            key={plan.id}\n            type=\"button\"\n            aria-pressed={index === shown}\n            onClick={() => onShow(index)}\n            className={cn(\n              `relative truncate rounded-full px-2 transition-colors ${TIMING.segment} focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-foreground`,\n              index === shown ? \"text-foreground\" : \"text-muted-foreground\",\n            )}\n          >\n            {plan.name}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/* ── RollingPrice: digits roll on wheels ────────────────── */\n\nfunction RollingPrice({\n  text,\n  amount,\n  duration,\n  stagger,\n  reduced,\n}: {\n  text: string;\n  amount: number | null;\n  duration: number;\n  stagger: number;\n  reduced: boolean;\n}) {\n  const [last, setLast] = React.useState(amount);\n  const [direction, setDirection] = React.useState(1);\n  if (amount !== last) {\n    setLast(amount);\n    if (amount !== null && last !== null) setDirection(amount >= last ? 1 : -1);\n  }\n\n  const chars = Array.from(text);\n  return (\n    <>\n      {/* The text itself sets the width and baseline; the wheels draw over it. */}\n      <span aria-hidden className=\"invisible\">\n        {text}\n        <Baseline />\n      </span>\n      <span aria-hidden className=\"absolute inset-0 flex\">\n        {chars.map((char, i) => {\n          const place = chars.length - i;\n          return /\\d/.test(char) ? (\n            <Wheel\n              key={place}\n              digit={Number(char)}\n              direction={direction}\n              duration={duration}\n              delay={(place - 1) * stagger}\n              reduced={reduced}\n            />\n          ) : (\n            <span key={`${place}${char}`}>{char}</span>\n          );\n        })}\n      </span>\n    </>\n  );\n}\n\nconst DIGITS = Array.from({ length: 30 }, (_, i) => i % 10);\n\nfunction Wheel({\n  digit,\n  direction,\n  duration,\n  delay,\n  reduced,\n}: {\n  digit: number;\n  direction: number;\n  duration: number;\n  delay: number;\n  reduced: boolean;\n}) {\n  const position = useMotionValue(digit);\n  // Draws position mod 10 from the middle copy of 0–9, so a roll can wrap\n  // past 9 or 0 without a jump.\n  const y = useTransform(position, (value) => `${-(mod(value, 10) + 10) * 1.1}em`);\n\n  React.useEffect(() => {\n    const from = position.get();\n    if (mod(from, 10) === digit) return;\n    let to = from - mod(from, 10) + digit;\n    if (direction > 0 && to < from) to += 10;\n    if (direction < 0 && to > from) to -= 10;\n    if (reduced) position.jump(to);\n    else animate(position, to, { ...TIMING.roll, visualDuration: duration, delay });\n  }, [digit, direction, duration, delay, reduced, position]);\n\n  return (\n    <span className=\"relative inline-block h-[1.1em] overflow-hidden\">\n      <span className=\"invisible\">0</span>\n      <motion.span className=\"absolute inset-x-0 top-0 flex flex-col\" style={{ y }}>\n        {DIGITS.map((d, i) => (\n          <span key={i} className=\"block h-[1.1em] text-center\">\n            {d}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n\n/* ── table cells ────────────────────────────────────────── */\n\nconst DRAW: Variants = {\n  hidden: { pathLength: 0, opacity: 0 },\n  shown: (col: number) => ({\n    pathLength: 1,\n    opacity: 1,\n    transition: { delay: TIMING.checkDelay + col * TIMING.checkStagger, ...TIMING.check },\n  }),\n};\n\nfunction Value({\n  value,\n  col,\n  featured,\n  reduced,\n}: {\n  value: PricingDockValue;\n  col: number;\n  featured: boolean;\n  reduced: boolean;\n}) {\n  if (value === true) {\n    return (\n      <>\n        <svg\n          viewBox=\"0 0 16 16\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          aria-hidden\n          className={cn(\"size-4\", featured ? \"text-(--dock-accent)\" : \"text-foreground/70\")}\n        >\n          <motion.path\n            d=\"m3.5 8.5 3 3 6-7\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            custom={reduced ? -1 : col}\n            variants={reduced ? undefined : DRAW}\n          />\n        </svg>\n        <span className=\"sr-only\">Included</span>\n      </>\n    );\n  }\n  if (value === false || value === null || value === undefined) {\n    return (\n      <>\n        <span aria-hidden className=\"text-foreground/25\">\n          —\n        </span>\n        <span className=\"sr-only\">Not included</span>\n      </>\n    );\n  }\n  return <span className=\"text-foreground/85\">{value}</span>;\n}\n\nfunction Hint({ children }: { children: React.ReactNode }) {\n  const id = React.useId();\n  return (\n    <span className=\"group/hint relative ml-1.5 inline-flex align-[-2px]\">\n      <button\n        type=\"button\"\n        aria-describedby={id}\n        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\"\n      >\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden className=\"size-3.5\">\n          <circle cx=\"8\" cy=\"8\" r=\"6.25\" />\n          <path d=\"M8 7.25V11M8 5v.01\" strokeLinecap=\"round\" />\n        </svg>\n        <span className=\"sr-only\">More info</span>\n      </button>\n      <span\n        role=\"tooltip\"\n        id={id}\n        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 ${TIMING.hint} group-focus-within/hint:translate-y-0 group-focus-within/hint:opacity-100 group-hover/hint:translate-y-0 group-hover/hint:opacity-100`}\n      >\n        {children}\n      </span>\n    </span>\n  );\n}\n\n/** A zero-size box whose top edge sits on the text's baseline. */\nfunction Baseline() {\n  return <span aria-hidden data-baseline className=\"inline-block h-0 w-0\" />;\n}\n\n/* ── helpers ────────────────────────────────────────────── */\n\ntype Box = Part;\n\nconst usd = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 });\n\nfunction defaultFormatPrice(amount: number) {\n  return usd.format(amount);\n}\n\n/** One value for every billing option, or the one for the selected option. */\nfunction pick<T>(value: T | Record<string, T> | undefined, billing: string | undefined): T | undefined {\n  if (value && typeof value === \"object\" && !Array.isArray(value) && !React.isValidElement(value)) {\n    const map = value as Record<string, T>;\n    return billing !== undefined && billing in map ? map[billing] : Object.values(map)[0];\n  }\n  return value as T | undefined;\n}\n\n/** Layout position inside an ancestor, ignoring transforms. */\nfunction offsetIn(element: HTMLElement, ancestor: HTMLElement) {\n  let x = 0;\n  let y = 0;\n  let node: HTMLElement | null = element;\n  while (node && node !== ancestor) {\n    x += node.offsetLeft;\n    y += node.offsetTop;\n    node = node.offsetParent as HTMLElement | null;\n  }\n  return { x, y };\n}\n\n/** The point a box scales toward to land at (x, y) at `scale`. */\nfunction center(from: Part, x: number, y: number, scale: number) {\n  if (Math.abs(1 - scale) < 0.01) return { px: x, py: y };\n  return { px: (x - scale * from.x) / (1 - scale), py: (y - scale * from.y) / (1 - scale) };\n}\n\nfunction box(element: HTMLElement, ancestor: HTMLElement): Box {\n  const { x, y } = offsetIn(element, ancestor);\n  return { x, y, w: element.offsetWidth, h: element.offsetHeight };\n}\n\nfunction baseline(element: HTMLElement, ancestor: HTMLElement) {\n  const marker = element.querySelector<HTMLElement>(\"[data-baseline]\");\n  return marker ? offsetIn(marker, ancestor).y : offsetIn(element, ancestor).y + element.offsetHeight * 0.8;\n}\n\nfunction fadeTops(cell: HTMLElement) {\n  return Array.from(cell.querySelectorAll<HTMLElement>(\"[data-dock-fade]\")).map((item) => offsetIn(item, cell).y);\n}\n\nfunction scrollBy(container: HTMLElement | null, delta: number) {\n  if (Math.abs(delta) < 1) return;\n  const behavior = matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"instant\" : \"smooth\";\n  if (container) container.scrollTo({ top: container.scrollTop + delta, behavior });\n  else window.scrollTo({ top: window.scrollY + delta, behavior });\n}\n\nfunction stage(value: number, [from, to]: [number, number]) {\n  return clamp((value - from) / (to - from), 0, 1);\n}\n\nfunction ease(x: number) {\n  return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2;\n}\n\nfunction easeOut(x: number) {\n  return 1 - (1 - x) ** 3;\n}\n\nfunction mix(from: number, to: number, progress: number) {\n  return from + (to - from) * progress;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction mod(value: number, n: number) {\n  return ((value % n) + n) % n;\n}\n"
    }
  ]
}