{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comparison-table",
  "type": "registry:ui",
  "title": "Comparison Table",
  "description": "Plans against features with a sticky header, a gliding row highlight and phone view.",
  "categories": [
    "marketing"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/comparison-table.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, X } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ComparisonPlan = {\n  id: string;\n  name: string;\n  /** \"$20\", \"Free\", \"Custom\". */\n  price?: React.ReactNode;\n  /** Quiet text after the price: \"/ month\", \"per seat\". */\n  period?: React.ReactNode;\n  /** A short label beside the name, usually on the highlighted plan: \"Popular\". */\n  badge?: string;\n  /** The plan's call to action. Gets an href to render a link, or onClick for a button. */\n  action?: { label: string; href?: string; onClick?: () => void };\n};\n\n/** true draws a check, false a cross, a string is printed. Add a note for \"up to 3\", \"add-on\", etc. */\nexport type ComparisonValue = boolean | string | { value: boolean | string; note?: string };\n\nexport type ComparisonFeature = {\n  name: string;\n  /** Opens in a small popover from the feature name, on hover or press. */\n  description?: string;\n  values: Record<string, ComparisonValue>;\n};\n\nexport type ComparisonSection = { title: string; features: ComparisonFeature[] };\n\nexport type ComparisonTableProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  plans: ComparisonPlan[];\n  sections: ComparisonSection[];\n  /** Id of the plan whose column is outlined and whose action is primary. */\n  highlight?: string;\n  /** Distance from the top of the scrolling area where the plan header sticks (a sticky site header's height). */\n  stickyTop?: number | string;\n  /** Names the table for screen readers. */\n  caption?: string;\n  /** Content for the empty cell above the feature names. */\n  corner?: React.ReactNode;\n  /** On narrow containers one plan shows at a time. Controlled plan id… */\n  plan?: string;\n  /** …or the one shown first. Defaults to the highlighted plan. */\n  defaultPlan?: string;\n  onPlanChange?: (id: string) => void;\n};\n\n// The highlighted column is drawn with inset shadows rather than borders, so it\n// can have side rules, a rounded cap and a 3% tint without shifting any cell.\nconst col = {\n  side: \"shadow-[inset_1px_0_0_var(--line-2),inset_-1px_0_0_var(--line-2),inset_0_0_0_100vmax_color-mix(in_oklab,var(--fg)_3%,transparent)]\",\n  top: \"shadow-[inset_1px_0_0_var(--line-2),inset_-1px_0_0_var(--line-2),inset_0_1px_0_var(--line-2),inset_0_0_0_100vmax_color-mix(in_oklab,var(--fg)_3%,transparent)]\",\n  bottom: \"shadow-[inset_1px_0_0_var(--line-2),inset_-1px_0_0_var(--line-2),inset_0_-1px_0_var(--line-2),inset_0_0_0_100vmax_color-mix(in_oklab,var(--fg)_3%,transparent)]\",\n};\n\n/**\n * Plans across the top, features down the side. The plan header sticks while\n * the features scroll under it, one highlight glides with the pointer so a row\n * can be read across, and on a narrow container the columns collapse to one\n * plan at a time behind a switcher.\n */\nexport function ComparisonTable({\n  plans,\n  sections,\n  highlight,\n  stickyTop = 0,\n  caption = \"Compare plans\",\n  corner,\n  plan: planProp,\n  defaultPlan,\n  onPlanChange,\n  className,\n  style,\n  ...rest\n}: ComparisonTableProps) {\n  const reduce = useReducedMotion();\n  const [active, setActive] = useControllableState({\n    value: planProp,\n    defaultValue: defaultPlan ?? highlight ?? plans[0]?.id ?? \"\",\n    onChange: onPlanChange,\n  });\n  const root = useRef<HTMLDivElement>(null);\n  const table = useRef<HTMLTableElement>(null);\n  const head = useRef<HTMLTableSectionElement>(null);\n  const [stuck, setStuck] = useState(false);\n  const [row, setRow] = useState<{ y: number; h: number; shown: boolean; jump: boolean }>({ y: 0, h: 0, shown: false, jump: true });\n  const captionId = useId();\n\n  // The header is stuck when it has slid down from the table's top edge. Any\n  // scroll, of the window or of a container, is checked once per frame.\n  useEffect(() => {\n    let frame = 0;\n    const check = () => {\n      frame = 0;\n      const t = table.current?.getBoundingClientRect();\n      const h = head.current?.getBoundingClientRect();\n      if (!t || !h) return;\n      const next = h.top - t.top > 1;\n      setStuck((s) => (s === next ? s : next));\n    };\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(check);\n    };\n    window.addEventListener(\"scroll\", onScroll, { capture: true, passive: true });\n    window.addEventListener(\"resize\", onScroll, { passive: true });\n    onScroll();\n    return () => {\n      window.removeEventListener(\"scroll\", onScroll, { capture: true });\n      window.removeEventListener(\"resize\", onScroll);\n      cancelAnimationFrame(frame);\n    };\n  }, []);\n\n  const track = (e: React.PointerEvent<HTMLTableRowElement>) => {\n    if (e.pointerType !== \"mouse\" || !root.current) return;\n    const r = e.currentTarget.getBoundingClientRect();\n    const o = root.current.getBoundingClientRect();\n    setRow((prev) => ({ y: r.top - o.top, h: r.height, shown: true, jump: !prev.shown }));\n  };\n\n  const top = typeof stickyTop === \"number\" ? `${stickyTop}px` : stickyTop;\n  const last = (s: number, f: number) => s === sections.length - 1 && f === sections[s].features.length - 1;\n\n  return (\n    <div\n      ref={root}\n      data-slot=\"comparison-table\"\n      data-stuck={stuck || undefined}\n      className={cn(\"group/ct @container relative w-full min-w-0 [--ct-bg:var(--frame)]\", className)}\n      style={{ ...style, [\"--ct-top\" as string]: top }}\n      {...rest}\n    >\n      <PlanSwitcher plans={plans} active={active} onChange={setActive} reduce={!!reduce} label={caption} />\n\n      <table\n        ref={table}\n        aria-labelledby={captionId}\n        // Leaving the table fades the highlight; moving between rows only moves it.\n        onPointerLeave={() => setRow((r) => ({ ...r, shown: false }))}\n        className=\"w-full table-fixed border-separate border-spacing-0 text-[13px]\">\n        <caption id={captionId} className=\"sr-only\">\n          {caption}\n        </caption>\n        <thead ref={head}>\n          <tr>\n            <td\n              className={cn(\n                \"sticky top-(--ct-top) z-(--z-sticky) w-[34%] bg-(--ct-bg) @max-[36rem]:top-[calc(var(--ct-top)+58px)] pb-3 pr-4 align-bottom @max-[36rem]:w-[52%]\",\n                \"border-b border-line transition-[border-color] duration-200 group-data-stuck/ct:border-line-2\",\n                \"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-3 after:bg-linear-to-b after:from-(--ct-bg) after:to-transparent after:opacity-0 after:transition-opacity after:duration-200 after:content-[''] group-data-stuck/ct:after:opacity-100\",\n              )}\n            >\n              {corner}\n            </td>\n            {plans.map((p) => {\n              const hi = p.id === highlight;\n              return (\n                <th\n                  key={p.id}\n                  scope=\"col\"\n                  data-plan={p.id}\n                  data-highlight={hi || undefined}\n                  className={cn(\n                    \"sticky top-(--ct-top) z-(--z-sticky) bg-(--ct-bg) p-0 @max-[36rem]:top-[calc(var(--ct-top)+58px)] text-left align-bottom font-normal\",\n                    \"border-b border-line transition-[border-color] duration-200 group-data-stuck/ct:border-line-2\",\n                \"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-3 after:bg-linear-to-b after:from-(--ct-bg) after:to-transparent after:opacity-0 after:transition-opacity after:duration-200 after:content-[''] group-data-stuck/ct:after:opacity-100\",\n                    // Narrow containers show one plan column at a time.\n                    p.id !== active && \"@max-[36rem]:hidden\",\n                    hi && cn(\"rounded-t-xl\", col.top),\n                  )}\n                >\n                  <PlanHead plan={p} highlighted={hi} />\n                </th>\n              );\n            })}\n          </tr>\n        </thead>\n\n        {sections.map((section, si) => (\n          <tbody key={section.title}>\n            <tr>\n              <th scope=\"colgroup\" colSpan={1} className=\"pb-2 pr-4 pt-6 text-left align-bottom font-normal\">\n                <span className=\"font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">{section.title}</span>\n              </th>\n              {plans.map((p) => (\n                <td\n                  key={p.id}\n                  aria-hidden\n                  className={cn(p.id !== active && \"@max-[36rem]:hidden\", p.id === highlight && col.side)}\n                />\n              ))}\n            </tr>\n            {section.features.map((f, fi) => (\n              <tr key={f.name} onPointerEnter={track}>\n                <th scope=\"row\" className=\"border-t border-line py-2.5 pr-4 text-left align-top font-normal text-fg\">\n                  <FeatureName feature={f} />\n                </th>\n                {plans.map((p) => {\n                  const hi = p.id === highlight;\n                  const end = last(si, fi);\n                  return (\n                    <td\n                      key={p.id}\n                      data-plan={p.id}\n                      className={cn(\n                        \"border-t border-line px-3 py-2.5 align-top\",\n                        p.id !== active && \"@max-[36rem]:hidden\",\n                        hi && (end ? cn(\"rounded-b-xl\", col.bottom) : col.side),\n                        hi && end && \"pb-3.5\",\n                      )}\n                    >\n                      <Cell value={f.values[p.id]} />\n                    </td>\n                  );\n                })}\n              </tr>\n            ))}\n          </tbody>\n        ))}\n      </table>\n\n      {/* One highlight for every row: it glides after the pointer so a line can be read across the plans. */}\n      <motion.div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-0 top-0 rounded-md bg-fg/[0.035] motion-reduce:hidden\"\n        initial={false}\n        animate={{ y: row.y, height: row.h, opacity: row.shown ? 1 : 0 }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { y: row.jump ? { duration: 0 } : spring.follow, height: row.jump ? { duration: 0 } : spring.follow, opacity: { duration: row.shown ? 0.12 : 0.15 } }\n        }\n      />\n    </div>\n  );\n}\n\nfunction PlanHead({ plan, highlighted }: { plan: ComparisonPlan; highlighted: boolean }) {\n  const a = plan.action;\n  const actionClass = cn(\n    \"relative mt-1 inline-flex h-7 w-full select-none items-center justify-center rounded-md px-2.5 text-[12px] font-medium tracking-[-0.005em]\",\n    \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n    \"transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n    \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n    highlighted ? \"bg-fg text-frame hover:bg-fg/90\" : \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\",\n  );\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1 px-3 pb-3 pt-3.5\">\n      <div className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1\">\n        <span className=\"truncate text-[13.5px] font-medium tracking-[-0.01em] text-fg\">{plan.name}</span>\n        {plan.badge && (\n          <span className=\"inline-flex h-[18px] shrink-0 items-center rounded-full border border-line-2 bg-raised px-1.5 text-[10.5px] font-medium leading-none text-fg-2\">\n            {plan.badge}\n          </span>\n        )}\n      </div>\n      {plan.price != null && (\n        <p className=\"flex min-w-0 flex-wrap items-baseline gap-x-1\">\n          <span className=\"text-[18px] font-medium leading-6 tracking-[-0.02em] text-fg tabular\">{plan.price}</span>\n          {plan.period != null && <span className=\"text-[11.5px] text-fg-3\">{plan.period}</span>}\n        </p>\n      )}\n      {a &&\n        (a.href ? (\n          <a href={a.href} onClick={a.onClick} className={cn(actionClass, \"cursor-pointer\")}>\n            {a.label}\n          </a>\n        ) : (\n          <button type=\"button\" onClick={a.onClick} className={actionClass}>\n            {a.label}\n          </button>\n        ))}\n    </div>\n  );\n}\n\nfunction FeatureName({ feature }: { feature: ComparisonFeature }) {\n  if (!feature.description) return <span className=\"text-pretty\">{feature.name}</span>;\n  return (\n    <Popover.Root>\n      <Popover.Trigger\n        openOnHover\n        delay={300}\n        closeDelay={80}\n        className={cn(\n          \"-mx-1 cursor-help rounded-[4px] px-1 text-left text-pretty text-fg outline-none\",\n          \"underline decoration-fg-4 decoration-dotted underline-offset-[4px] transition-[text-decoration-color] duration-150\",\n          \"hover:decoration-fg-2 data-popup-open:decoration-fg-2\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        )}\n      >\n        {feature.name}\n      </Popover.Trigger>\n      <Popover.Portal>\n        <Popover.Positioner side=\"top\" align=\"start\" sideOffset={8} collisionPadding={8} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            className={cn(\n              \"w-64 max-w-(--available-width) rounded-xl border border-line-2 bg-raised px-3 py-2.5 text-fg shadow-pop outline-none\",\n              \"origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n              \"data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1\",\n              \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-140 data-ending-style:ease-out-quart\",\n              \"data-instant:transition-none\",\n              \"motion-reduce:data-starting-style:translate-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <Popover.Title className=\"text-[12.5px] font-medium tracking-[-0.005em]\">{feature.name}</Popover.Title>\n            <Popover.Description className=\"mt-0.5 text-pretty text-[12.5px] leading-[1.5] text-fg-2\">{feature.description}</Popover.Description>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\nfunction Cell({ value }: { value: ComparisonValue | undefined }) {\n  const v = typeof value === \"object\" && value !== null ? value.value : value;\n  const note = typeof value === \"object\" && value !== null ? value.note : undefined;\n  return (\n    <div className=\"flex min-w-0 flex-col gap-0.5\">\n      {v === true ? (\n        <span className=\"flex h-5 items-center text-fg\">\n          <Check size={16} />\n          <span className=\"sr-only\">Included</span>\n        </span>\n      ) : v === false || v === undefined ? (\n        <span className=\"flex h-5 items-center text-fg-4\">\n          <X size={14} />\n          <span className=\"sr-only\">Not included</span>\n        </span>\n      ) : (\n        <span className=\"text-pretty leading-5 text-fg-2 tabular\">{v}</span>\n      )}\n      {note && <span className=\"text-pretty text-[12px] leading-4 text-fg-3\">{note}</span>}\n    </div>\n  );\n}\n\n/**\n * Below 36rem the table shows one plan; this picks which. One pill slides\n * between the plans on the snappy spring.\n */\nfunction PlanSwitcher({\n  plans,\n  active,\n  onChange,\n  reduce,\n  label,\n}: {\n  plans: ComparisonPlan[];\n  active: string;\n  onChange: (id: string) => void;\n  reduce: boolean;\n  label: string;\n}) {\n  const id = useId();\n  return (\n    // Sticks above the plan header, so the plan can be changed from anywhere in the table.\n    <div className=\"sticky top-(--ct-top) z-(--z-sticky) hidden bg-(--ct-bg) pb-3 pt-2 @max-[36rem]:block\">\n    <ToggleGroup\n      value={[active]}\n      // A plan is always shown: pressing the current one again does nothing.\n      onValueChange={(v) => v[0] && onChange(v[0])}\n      aria-label={`${label}: choose a plan`}\n      className=\"flex w-full rounded-lg border border-line bg-raised p-0.5\"\n    >\n      {plans.map((p) => (\n          <Toggle\n            key={p.id}\n            value={p.id}\n            className={cn(\n              \"relative isolate flex h-8 min-w-0 flex-1 select-none items-center justify-center rounded-md px-2 text-[12.5px] font-medium text-fg-3\",\n              \"outline-none transition-[color,scale] duration-150 hover:text-fg-2 active:scale-[0.97] data-pressed:text-fg\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n            )}\n          >\n            {p.id === active && (\n              <motion.span\n                layoutId={`${id}-pill`}\n                className=\"absolute inset-0 -z-10 rounded-md border border-line-2 bg-frame\"\n                transition={reduce ? { duration: 0 } : spring.snappy}\n              />\n            )}\n            <span className=\"truncate\">{p.name}</span>\n          </Toggle>\n      ))}\n    </ToggleGroup>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/comparison-table"
}
