{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"comparison-table","type":"registry:component","title":"Comparison table","description":"Plans side by side, with a column that glides to the plan under your pointer and prices that roll.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/pricing-toggle.json"],"files":[{"path":"registry/comparison-table.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { Price, PricingToggle } from \"./pricing-toggle\";\n\n/* ─────────────────────────────────────────────────────────\n * COMPARISON TABLE: plans side by side, and one that stands out\n *\n *   rest      the recommended plan sits in a hairline column that\n *             runs the height of the table\n *   point     move across the table and the column glides to the\n *             plan under you with a little give, then settles\n *             back when you leave\n *   billing   Monthly/Yearly is thrown to the choice and every\n *             price rolls; the note under it slides the same way\n *   groups    each section folds open and shut to its height\n *   phone     one plan at a time: a switch is thrown to the plan\n *             and its column slides in from the side you went\n *\n * Header stays at the top while the features scroll past.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ComparisonPlan {\n  id: string;\n  name: string;\n  /** Monthly price, or { monthly, yearly } per month; null for \"Custom\". */\n  price: number | { monthly: number; yearly: number } | null;\n  description?: string;\n  /** The column that's highlighted at rest. */\n  featured?: boolean;\n  badge?: string;\n  cta?: { label: string; href?: string; onClick?: () => void };\n}\n\nexport type ComparisonValue = boolean | string | number | null | undefined;\n\nexport interface ComparisonRow {\n  label: string;\n  hint?: string;\n  values: Record<string, ComparisonValue>;\n}\n\nexport interface ComparisonGroup {\n  title: string;\n  rows: ComparisonRow[];\n  /** Start folded. */\n  collapsed?: boolean;\n}\n\nexport interface ComparisonTableProps extends React.HTMLAttributes<HTMLDivElement> {\n  plans: ComparisonPlan[];\n  groups: ComparisonGroup[];\n  currency?: string;\n  /** Shows a Monthly/Yearly switch, e.g. \"Save 20%\". */\n  yearlyBadge?: string;\n  /** How far from the top the header sticks (e.g. under a fixed navbar). */\n  stickyTop?: number;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction Cell({ value }: { value: ComparisonValue }) {\n  if (value === true)\n    return (\n      <svg role=\"img\" aria-label=\"Included\" viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4 text-foreground\">\n        <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      </svg>\n    );\n  if (value === false || value === null || value === undefined)\n    return (\n      <svg role=\"img\" aria-label=\"Not included\" viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4 text-muted-foreground/45\">\n        <path d=\"M4.5 8h7\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n      </svg>\n    );\n  return <span className=\"text-[13px] text-foreground\">{value}</span>;\n}\n\nexport function ComparisonTable({ plans, groups, currency = \"USD\", yearlyBadge, stickyTop = 0, className = \"\", ...props }: ComparisonTableProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [billing, setBilling] = React.useState(\"monthly\");\n  const [billingDir, setBillingDir] = React.useState(1);\n  const [hover, setHover] = React.useState<number | null>(null);\n  const [selected, setSelected] = React.useState(() => Math.max(0, plans.findIndex((p) => p.featured)));\n  const [dir, setDir] = React.useState(1);\n  const [open, setOpen] = React.useState(() => groups.map((g) => !g.collapsed));\n  const [cols, setCols] = React.useState<{ left: number; width: number }[]>([]);\n  const [ready, setReady] = React.useState(false);\n  const headRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const headerRef = React.useRef<HTMLDivElement>(null);\n  const [headerHeight, setHeaderHeight] = React.useState(0);\n  const switchRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const [thumb, setThumb] = React.useState({ left: 0, width: 0 });\n\n  const featured = plans.findIndex((p) => p.featured);\n  const lit = hover ?? (featured >= 0 ? featured : null);\n  const template = `minmax(9.5rem,1.35fr) repeat(${plans.length}, minmax(0,1fr))`;\n\n  // Where each plan's column is, so the highlight can glide between them.\n  React.useLayoutEffect(() => {\n    const measure = () => {\n      const next = headRefs.current.map((el) => ({ left: el?.offsetLeft ?? 0, width: el?.offsetWidth ?? 0 }));\n      setCols((c) => (c.length === next.length && c.every((x, i) => x.left === next[i].left && x.width === next[i].width) ? c : next));\n      setHeaderHeight((h) => (h === headerRef.current?.offsetHeight ? h : (headerRef.current?.offsetHeight ?? 0)));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    headRefs.current.forEach((el) => el && observer.observe(el));\n    if (headerRef.current) observer.observe(headerRef.current);\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => {\n      observer.disconnect();\n      cancelAnimationFrame(frame);\n    };\n  }, [plans.length]);\n\n  // The phone switch's thumb.\n  React.useLayoutEffect(() => {\n    const el = switchRefs.current[selected];\n    if (el) setThumb((t) => (t.left === el.offsetLeft && t.width === el.offsetWidth ? t : { left: el.offsetLeft, width: el.offsetWidth }));\n  }, [selected, plans.length]);\n\n  const pick = (i: number) => {\n    setDir(i >= selected ? 1 : -1);\n    setSelected(i);\n  };\n\n  const onMove = (e: React.PointerEvent) => {\n    if (e.pointerType === \"touch\") return;\n    const x = e.clientX - (e.currentTarget as HTMLElement).getBoundingClientRect().left;\n    const i = cols.findIndex((c) => x >= c.left - 6 && x <= c.left + c.width + 6);\n    setHover(i >= 0 ? i : null);\n  };\n\n  const column = lit !== null ? cols[lit] : undefined;\n  const glide = ready && !reduced ? `transform 520ms ${THROW}, width 420ms ${EASE}, opacity 300ms ${EASE}` : \"none\";\n  // On a phone only the chosen plan shows; it slides in from the side you went.\n  const phone = (i: number) => (i === selected ? `max-sm:block ${dir > 0 ? \"max-sm:animate-[ui-slide-from-right_360ms_cubic-bezier(0.16,1,0.3,1)_both]\" : \"max-sm:animate-[ui-slide-from-left_360ms_cubic-bezier(0.16,1,0.3,1)_both]\"} motion-reduce:animate-none` : \"max-sm:hidden\");\n\n  const priceOf = (plan: ComparisonPlan) => (plan.price === null ? null : typeof plan.price === \"number\" ? plan.price : billing === \"yearly\" ? plan.price.yearly : plan.price.monthly);\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      <div className=\"mb-6 flex flex-wrap items-center justify-between gap-3\">\n        {yearlyBadge && (\n          <PricingToggle\n            value={billing}\n            onValueChange={(v) => {\n              setBillingDir(v === \"yearly\" ? 1 : -1);\n              setBilling(v);\n            }}\n            options={[\n              { value: \"monthly\", label: \"Monthly\" },\n              { value: \"yearly\", label: \"Yearly\", badge: yearlyBadge },\n            ]}\n          />\n        )}\n        {/* Phones: one plan at a time. */}\n        <div role=\"tablist\" aria-label=\"Plan\" className=\"relative inline-flex rounded-full bg-muted p-0.5 sm:hidden\">\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute inset-y-0.5 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n            style={{ width: thumb.width, transform: `translateX(${thumb.left}px)`, transition: thumb.width && !reduced ? `transform 460ms ${THROW}, width 380ms ${EASE}` : \"none\" }}\n          />\n          {plans.map((p, i) => (\n            <button\n              key={p.id}\n              ref={(el) => {\n                switchRefs.current[i] = el;\n              }}\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={i === selected}\n              onClick={() => pick(i)}\n              className={`relative h-8 rounded-full px-3.5 text-[12.5px] font-medium transition-colors duration-300 ${FOCUS} ${i === selected ? \"text-foreground\" : \"text-muted-foreground\"}`}\n            >\n              {p.name}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div role=\"table\" aria-label=\"Plan comparison\" className=\"relative\" onPointerMove={onMove} onPointerLeave={() => setHover(null)}>\n        {/* The column that runs the height of the table and glides between plans: this part under the header… */}\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-0 left-0 hidden rounded-b-[18px] bg-accent/60 shadow-[inset_1px_0_0_0_var(--border),inset_-1px_0_0_0_var(--border),inset_0_-1px_0_0_var(--border)] sm:block\"\n          style={{ top: headerHeight, width: column?.width ?? 0, transform: `translateX(${column?.left ?? 0}px)`, opacity: column ? 1 : 0, transition: glide }}\n        />\n\n        {/* Plan headers, kept in view while the features scroll by. */}\n        <div ref={headerRef} role=\"rowgroup\" className=\"sticky z-10 bg-background\" style={{ top: stickyTop }}>\n          {/* …and this part in the header, gliding in step, so the column reads as one even while the header sticks. */}\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-y-0 left-0 hidden rounded-t-[18px] bg-accent/60 shadow-[inset_1px_0_0_0_var(--border),inset_-1px_0_0_0_var(--border),inset_0_1px_0_0_var(--border)] sm:block\"\n            style={{ width: column?.width ?? 0, transform: `translateX(${column?.left ?? 0}px)`, opacity: column ? 1 : 0, transition: glide }}\n          />\n          <div role=\"row\" className=\"grid max-sm:!grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]\" style={{ gridTemplateColumns: template }}>\n            <div role=\"columnheader\" className=\"flex items-end pb-5 pr-4 text-[12px] text-muted-foreground max-sm:hidden\">\n              <span className=\"sr-only\">Feature</span>\n            </div>\n            {plans.map((plan, i) => {\n              const amount = priceOf(plan);\n              return (\n                <div\n                  key={plan.id}\n                  ref={(el) => {\n                    headRefs.current[i] = el;\n                  }}\n                  role=\"columnheader\"\n                  aria-label={plan.name}\n                  className={`relative px-4 pb-5 pt-5 max-sm:col-span-2 max-sm:px-0 ${phone(i)}`}\n                >\n                  <div className=\"relative flex items-center gap-2\">\n                    <span className=\"text-[14px] font-medium text-foreground\">{plan.name}</span>\n                    {plan.badge && <span className=\"rounded-full bg-primary/10 px-2 py-px text-[11px] font-medium text-primary\">{plan.badge}</span>}\n                  </div>\n                  {plan.description && <p className=\"relative mt-1 min-h-8 text-[12px] leading-4 text-muted-foreground\">{plan.description}</p>}\n                  <div className=\"relative mt-3\">\n                    {amount === null ? (\n                      <p className=\"text-[28px] font-medium leading-[1.15] tracking-[-0.03em] text-foreground\">Custom</p>\n                    ) : (\n                      <Price\n                        amount={amount}\n                        currency={currency}\n                        direction={billingDir}\n                        numberClassName=\"text-[28px] font-medium leading-[1.15] tracking-[-0.03em] text-foreground\"\n                      />\n                    )}\n                  </div>\n                  {plan.cta && (\n                    <a\n                      href={plan.cta.href ?? \"#\"}\n                      onClick={(e) => {\n                        if (plan.cta?.onClick) {\n                          e.preventDefault();\n                          plan.cta.onClick();\n                        }\n                      }}\n                      className={`relative mt-4 flex h-9 items-center justify-center rounded-full text-[13px] font-medium transition-[background-color,color,box-shadow] duration-300 ${FOCUS} ${\n                        plan.featured ? \"bg-primary text-primary-foreground hover:bg-primary/90\" : \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\"\n                      }`}\n                    >\n                      {plan.cta.label}\n                    </a>\n                  )}\n                </div>\n              );\n            })}\n          </div>\n        </div>\n\n        {groups.map((group, g) => {\n          const isOpen = open[g];\n          const groupId = `${id}-g${g}`;\n          return (\n            <div key={group.title} role=\"rowgroup\">\n              <div role=\"row\" className=\"relative\">\n                <div role=\"rowheader\" className=\"border-t border-border\">\n                  <button\n                    type=\"button\"\n                    aria-expanded={isOpen}\n                    aria-controls={groupId}\n                    onClick={() => setOpen((o) => o.map((v, i) => (i === g ? !v : v)))}\n                    className={`flex w-full items-center gap-2 rounded-md py-3.5 text-left text-[12.5px] font-medium text-foreground ${FOCUS}`}\n                  >\n                    <svg\n                      aria-hidden=\"true\"\n                      viewBox=\"0 0 12 12\"\n                      className=\"size-3 text-muted-foreground transition-transform duration-300 motion-reduce:transition-none\"\n                      style={{ transform: isOpen ? \"rotate(90deg)\" : \"none\", transitionTimingFunction: EASE }}\n                    >\n                      <path d=\"m4.5 2.5 3.5 3.5-3.5 3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                    </svg>\n                    {group.title}\n                  </button>\n                </div>\n              </div>\n              <div\n                id={groupId}\n                inert={!isOpen}\n                className=\"grid\"\n                style={{\n                  gridTemplateRows: isOpen ? \"1fr\" : \"0fr\",\n                  opacity: isOpen ? 1 : 0,\n                  transition: reduced ? \"none\" : `grid-template-rows 460ms ${EASE}, opacity ${isOpen ? \"360ms\" : \"160ms\"} ${EASE}`,\n                }}\n              >\n                <div className=\"min-h-0 overflow-hidden\">\n                  {group.rows.map((row) => (\n                    <div\n                      key={row.label}\n                      role=\"row\"\n                      className=\"grid border-t border-border/70 max-sm:!grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]\"\n                      style={{ gridTemplateColumns: template }}\n                    >\n                      <div role=\"rowheader\" className=\"py-3 pr-4\">\n                        <span className=\"text-[13px] text-foreground/85\">{row.label}</span>\n                        {row.hint && <span className=\"mt-0.5 block text-[11.5px] leading-4 text-muted-foreground\">{row.hint}</span>}\n                      </div>\n                      {plans.map((plan, i) => (\n                        <div key={plan.id} role=\"cell\" className={`relative flex items-center px-4 py-3 ${phone(i)}`}>\n                          <Cell value={row.values[plan.id]} />\n                        </div>\n                      ))}\n                    </div>\n                  ))}\n                </div>\n              </div>\n            </div>\n          );\n        })}\n        {/* Closes the column at the bottom. */}\n        <div aria-hidden=\"true\" className=\"h-4 border-t border-border\" />\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-slide-from-right":{"from":{"opacity":"0","transform":"translateX(8px)"},"to":{"opacity":"1","transform":"none"}},"@keyframes ui-slide-from-left":{"from":{"opacity":"0","transform":"translateX(-8px)"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/comparison-table"}