{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "plan-comparison",
  "type": "registry:block",
  "title": "Plan comparison",
  "description": "Compare meaningful differences between plans and billing periods.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/button.json",
    "https://irisui.dev/r/segmented-control.json",
    "https://irisui.dev/r/switch.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/plan-comparison/plan-comparison.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { animate, AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check } from \"lucide-react\";\nimport { Button } from \"../../button/button\";\nimport SegmentedControl from \"../../segmented-control/segmented-control\";\nimport { Switch } from \"../../switch/switch\";\nimport { motionTokens } from \"../../lib/motion-tokens\";\nimport { cn } from \"../../lib/utils\";\n\ntype Plan = \"cloud\" | \"plus\";\ntype Billing = \"monthly\" | \"yearly\";\ntype Feature = { label: string; cloud: string; plus: string; shared?: boolean };\n\n/** Anasa's two smaller cloud plans. Prices and credits are the published ones; credit costs are per use. */\nconst plans: Record<Plan, { name: string; note: string }> = {\n  cloud: { name: \"Cloud\", note: \"For notes and daily summaries\" },\n  plus: { name: \"Cloud Plus\", note: \"For heavy AI use\" },\n};\n\nconst features: Feature[] = [\n  { label: \"Local app and Markdown files\", cloud: \"Included\", plus: \"Included\", shared: true },\n  { label: \"AI credits each month\", cloud: \"400\", plus: \"1,000\" },\n  { label: \"Yearly price\", cloud: \"€39.99\", plus: \"€69.99\" },\n  { label: \"Smart Connections\", cloud: \"3 credits\", plus: \"3 credits\", shared: true },\n  { label: \"Chat\", cloud: \"3 credits\", plus: \"3 credits\", shared: true },\n  { label: \"Note summary\", cloud: \"5 credits\", plus: \"5 credits\", shared: true },\n  { label: \"Daily summary\", cloud: \"10 credits\", plus: \"10 credits\", shared: true },\n  { label: \"Tag suggestions\", cloud: \"1 credit\", plus: \"1 credit\", shared: true },\n];\n\nconst pricing: Record<Plan, Record<Billing, number>> = {\n  cloud: { monthly: 4.99, yearly: 39.99 },\n  plus: { monthly: 6.99, yearly: 69.99 },\n};\n\nfunction Value({ text }: { text: string }) {\n  if (text === \"Included\")\n    return (\n      <span className=\"inline-flex items-center gap-1.5 text-ink [&_svg]:text-green\">\n        <Check size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n        Included\n      </span>\n    );\n  return <span className={text === \"Not included\" ? \"text-ink-3\" : undefined}>{text}</span>;\n}\n\n/** Visually hidden but read by screen readers. */\nconst srOnly = \"absolute h-px w-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\";\n\n/** Counts to a new value instead of jumping, keeping `decimals` digits after the point while it moves. */\nfunction AnimatedNumber({ value, decimals = 0 }: { value: number; decimals?: number }) {\n  const reduce = useReducedMotion();\n  const [displayValue, setDisplayValue] = useState(value);\n  const previousValue = useRef(value);\n\n  useEffect(() => {\n    if (reduce) {\n      previousValue.current = value;\n      return;\n    }\n\n    const controls = animate(previousValue.current, value, {\n      duration: motionTokens.duration.standard,\n      ease: [...motionTokens.ease.enter],\n      onUpdate: (latest) => setDisplayValue(latest),\n    });\n\n    previousValue.current = value;\n    return () => controls.stop();\n  }, [reduce, value]);\n\n  return <>{(reduce ? value : displayValue).toFixed(decimals)}</>;\n}\n\nexport function PlanComparison() {\n  const id = useId();\n  const reduce = useReducedMotion();\n  const [billing, setBilling] = useState<Billing>(\"monthly\");\n  const [differencesOnly, setDifferencesOnly] = useState(false);\n  const [selected, setSelected] = useState<Plan | null>(null);\n  const rootRef = useRef<HTMLElement>(null);\n  const [size, setSize] = useState<\"lg\" | \"md\" | \"sm\" | \"xs\">(\"lg\");\n  useLayoutEffect(() => {\n    const node = rootRef.current;\n    if (!node) return;\n    const measure = (width: number) => setSize(width <= 340 ? \"xs\" : width <= 540 ? \"sm\" : width <= 720 ? \"md\" : \"lg\");\n    measure(node.getBoundingClientRect().width);\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry) measure(entry.contentRect.width);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  const visibleFeatures = differencesOnly ? features.filter((feature) => !feature.shared) : features;\n  /* Layout follows the block width (data-size, measured above), not the viewport. Below lg the gutters tighten; at sm\n   * and xs the feature name sits above its two values, which stay aligned under the plan columns. */\n  const compact = size !== \"lg\";\n  const narrow = size === \"sm\" || size === \"xs\";\n  const xs = size === \"xs\";\n  const columns = narrow\n    ? \"grid-cols-[repeat(2,minmax(0,1fr))]\"\n    : \"grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,1fr)]\";\n\n  return (\n    <section\n      ref={rootRef}\n      className=\"mx-auto w-[min(100%,1060px)] min-w-0 overflow-hidden rounded-panel border border-line bg-surface font-body text-sm leading-body tracking-body text-ink\"\n      data-size={size}\n      aria-labelledby={`${id}-title`}\n    >\n      <div\n        className={cn(\n          \"flex items-end justify-between gap-x-8 gap-y-4 px-8 pt-8 pb-6\",\n          compact && \"flex-col items-start px-5 pt-6 pb-5\",\n          xs && \"px-4\",\n        )}\n      >\n        <div className=\"min-w-0 max-w-[560px]\">\n          <h2\n            id={`${id}-title`}\n            className={cn(\n              \"m-0 font-display text-3xl leading-display font-normal tracking-display text-balance\",\n              narrow && \"text-2xl leading-display\",\n            )}\n          >\n            Add Anasa Cloud to your vault\n          </h2>\n          <p className=\"mt-2 mb-0 max-w-[46ch] text-ink-2 text-pretty\">\n            Only the details that change between plans. Switch billing to see what you would pay.\n          </p>\n        </div>\n        <div className={cn(\"grid flex-none justify-items-end gap-1.5\", compact && \"justify-items-start\")}>\n          <SegmentedControl\n            label=\"Billing period\"\n            value={billing}\n            onValueChange={(value) => setBilling(value as Billing)}\n            options={[\n              { value: \"monthly\", label: \"Monthly\" },\n              { value: \"yearly\", label: \"Yearly\" },\n            ]}\n          />\n          <small className=\"min-h-4 text-xs/[inherit] text-ink-3 tabular-nums\">\n            {billing === \"yearly\" ? \"Billed yearly, save up to 33 %\" : \"Billed month to month\"}\n          </small>\n        </div>\n      </div>\n\n      <div\n        className={cn(\n          \"flex min-h-[52px] items-center justify-between gap-x-4 gap-y-2 border-t border-line px-8 py-2 [&>button]:flex-none\",\n          compact && \"px-5\",\n          narrow && \"flex-wrap\",\n          xs && \"px-4\",\n        )}\n      >\n        <div className=\"flex items-baseline gap-2.5\">\n          <h3 className=\"m-0 text-sm/[inherit] font-medium\">Compare plans</h3>\n          <span className=\"min-w-[11ch] text-xs/[inherit] text-ink-3 tabular-nums\">\n            <AnimatedNumber value={visibleFeatures.length} /> of {features.length} features\n          </span>\n        </div>\n        <Switch checked={differencesOnly} onCheckedChange={setDifferencesOnly} label=\"Show differences only\" />\n      </div>\n\n      <div className=\"border-t border-line\" role=\"table\" aria-label=\"Cloud and Cloud Plus plan comparison\">\n        {/* Plan cells share three row tracks (name, price, button), so a description that wraps in one column keeps prices and buttons level. */}\n        <div className={cn(\"grid grid-rows-[auto_auto_auto]\", columns)} role=\"row\">\n          <div\n            className={cn(\n              \"row-[1/span_3] flex items-end pt-0 pr-4 pb-5 pl-8 text-xs/[inherit] text-ink-3\",\n              compact && \"pl-5\",\n              narrow && \"hidden\",\n            )}\n            role=\"columnheader\"\n          >\n            What changes\n          </div>\n          {([\"cloud\", \"plus\"] as const).map((plan, index) => (\n            <div\n              key={plan}\n              className={cn(\n                \"relative row-span-3 grid min-w-0 grid-rows-subgrid content-start border-l border-line p-5 transition-[background-color] duration-standard ease-standard motion-reduce:transition-none\",\n                compact && \"px-3.5 py-4\",\n                narrow && index === 0 && \"border-l-0 pl-5\",\n                xs && (index === 0 ? \"pr-3 pl-4\" : \"px-3\"),\n                selected === plan && \"bg-hover\",\n              )}\n              role=\"columnheader\"\n              aria-label={`${plans[plan].name} plan`}\n            >\n              <div>\n                <h3 className=\"m-0 text-base/[inherit] font-medium tracking-[-.01em]\">{plans[plan].name}</h3>\n                <span className=\"mt-0.5 block text-xs/[inherit] text-ink-3\">{plans[plan].note}</span>\n              </div>\n              <div className=\"mt-4 mb-3.5 flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5\">\n                <span\n                  className={cn(\n                    \"inline-flex min-w-[3ch] font-display text-3xl/none font-normal tracking-display tabular-nums\",\n                    narrow && \"text-2xl/none\",\n                  )}\n                  aria-live=\"polite\"\n                  aria-atomic=\"true\"\n                >\n                  €<AnimatedNumber value={pricing[plan][billing]} decimals={2} />\n                </span>\n                <span className=\"text-xs/[inherit] whitespace-nowrap text-ink-3\">\n                  {billing === \"yearly\" ? \"per year\" : \"per month\"}\n                </span>\n              </div>\n              <Button\n                type=\"button\"\n                variant={selected === plan ? \"primary\" : \"secondary\"}\n                className=\"w-full\"\n                aria-pressed={selected === plan}\n                onClick={() => setSelected((current) => (current === plan ? null : plan))}\n              >\n                {selected === plan ? \"Selected\" : `Select ${plans[plan].name}`}\n              </Button>\n              {selected === plan && (\n                <motion.span\n                  className=\"absolute right-0 -bottom-px left-0 h-0.5 bg-accent\"\n                  layoutId={`${id}-selected-rail`}\n                  transition={reduce ? { duration: 0 } : motionTokens.spring.responsive}\n                  aria-hidden=\"true\"\n                />\n              )}\n            </div>\n          ))}\n        </div>\n\n        <div className=\"border-t border-line\" role=\"rowgroup\">\n          <AnimatePresence initial={false}>\n            {visibleFeatures.map((feature) => (\n              <motion.div\n                key={feature.label}\n                className={cn(\"grid overflow-hidden border-b border-line-soft last:border-b-0\", columns)}\n                role=\"row\"\n                initial={reduce ? false : { height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={\n                  reduce\n                    ? undefined\n                    : {\n                        height: 0,\n                        opacity: 0,\n                        transition: {\n                          height: motionTokens.spring.smooth,\n                          opacity: { duration: motionTokens.duration.fast },\n                        },\n                      }\n                }\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        height: motionTokens.spring.smooth,\n                        opacity: {\n                          duration: motionTokens.duration.standard,\n                          ease: [...motionTokens.ease.enter],\n                          delay: 0.04,\n                        },\n                      }\n                }\n              >\n                <div\n                  className={cn(\n                    \"flex min-h-11 min-w-0 items-center py-2.5 pr-5 pl-8 text-ink\",\n                    compact && \"pl-5\",\n                    narrow && \"col-span-full min-h-0 px-5 pt-2.5 pb-0 text-xs/[inherit] text-ink-2\",\n                    xs && \"px-4\",\n                  )}\n                  role=\"rowheader\"\n                >\n                  {feature.label}\n                </div>\n                {([\"cloud\", \"plus\"] as const).map((plan, index) => (\n                  <div\n                    key={plan}\n                    className={cn(\n                      \"flex min-h-11 min-w-0 items-center border-l border-line px-5 py-2.5 text-ink-2 transition-[background-color,color] duration-standard ease-standard motion-reduce:transition-none\",\n                      compact && \"px-3.5\",\n                      narrow && \"min-h-0 border-l-0 pt-1 pb-2.5\",\n                      narrow && (index === 0 ? \"pl-5\" : \"border-l border-line-soft\"),\n                      xs && (index === 0 ? \"pr-3 pl-4\" : \"px-3\"),\n                      selected === plan && \"bg-hover text-ink\",\n                    )}\n                    role=\"cell\"\n                  >\n                    <span className={srOnly}>{plans[plan].name}</span>\n                    <Value text={feature[plan]} />\n                  </div>\n                ))}\n              </motion.div>\n            ))}\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <div\n        className={cn(\n          \"flex min-h-12 items-center justify-between gap-x-6 gap-y-2 border-t border-line px-8 py-3 text-xs/[inherit] text-ink-3\",\n          compact && \"flex-col items-start px-5\",\n          xs && \"px-4\",\n        )}\n      >\n        <span>Prices in EUR. The local app is free; hosted services are optional.</span>\n        <p\n          className={cn(\"m-0 min-h-[1.4em] text-right text-ink-2\", compact && \"text-left\", srOnly)}\n          role=\"status\"\n          aria-live=\"polite\"\n        >\n          {selected ? `${plans[selected].name} selected` : \"\"}\n        </p>\n      </div>\n    </section>\n  );\n}\n\nexport default PlanComparison;\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/plan-comparison/plan-comparison.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/plan-comparison",
  "meta": {
    "kind": "block",
    "group": "Blocks",
    "export": "PlanComparison",
    "docs": "https://irisui.dev/components/plan-comparison"
  }
}
