{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"switch-cards-plan-features","type":"registry:component","title":"Switch cards · Plan features","description":"Workspace feature toggles where child options unlock with their parent, a locked row names its plan, and a save bar springs in once anything changes.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/examples/switch-cards/plan-features.tsx","type":"registry:component","target":"components/examples/switch-cards/plan-features.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, LoaderCircle, Lock } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PlanFeature {\n  id: string;\n  label: string;\n  description: string;\n  enabled: boolean;\n  /** Id of the feature this one depends on. */\n  parent?: string;\n  /** Plan needed to use it; renders a locked row. */\n  requires?: string;\n}\n\nexport interface PlanFeatureCardProps {\n  workspace?: string;\n  features?: PlanFeature[];\n  onSave?: (values: Record<string, boolean>) => void | Promise<void>;\n  onUpgrade?: (plan: string) => void;\n  className?: string;\n}\n\nconst FEATURES: PlanFeature[] = [\n  { id: \"public\", label: \"Public links\", description: \"Anyone with the link can view shared pages.\", enabled: true },\n  { id: \"password\", label: \"Require a password\", description: \"Visitors enter a password before viewing.\", enabled: false, parent: \"public\" },\n  { id: \"index\", label: \"Allow search engines\", description: \"Let public pages appear in search results.\", enabled: false, parent: \"public\" },\n  { id: \"ai\", label: \"Writing assistant\", description: \"Draft, summarize and rewrite inside docs.\", enabled: false },\n  { id: \"ai-train\", label: \"Learn from workspace\", description: \"Use your docs to improve suggestions for your team.\", enabled: false, parent: \"ai\" },\n  { id: \"sso\", label: \"SAML single sign-on\", description: \"Sign in through Okta, Entra ID or Google Workspace.\", enabled: false, requires: \"Business\" },\n];\nconst spring = { type: \"spring\", stiffness: 520, damping: 32 } as const;\nconst soft = { type: \"spring\", stiffness: 320, damping: 30 } as const;\n\nfunction Switch({ checked, disabled, onToggle, labelledBy, describedBy }: { checked: boolean; disabled?: boolean; onToggle: () => void; labelledBy: string; describedBy: string }) {\n  return (\n    <motion.button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={checked}\n      aria-labelledby={labelledBy}\n      aria-describedby={describedBy}\n      disabled={disabled}\n      onClick={onToggle}\n      whileTap={disabled ? undefined : \"press\"}\n      className={cn(\n        \"relative inline-flex h-6 w-10 shrink-0 items-center rounded-full border p-[2px] outline-none transition-[background-color,border-color,opacity] duration-200 focus-visible:shadow-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-40\",\n        checked ? \"border-transparent bg-volt\" : \"border-border-strong bg-surface-sunken\",\n      )}\n    >\n      <motion.span animate={{ x: checked ? 16 : 0 }} variants={{ press: { scaleX: 1.25 } }} transition={spring} style={{ originX: checked ? 1 : 0 }} className={cn(\"block size-[18px] rounded-full shadow-sm\", checked ? \"bg-on-volt\" : \"bg-ink-subtle\")} />\n    </motion.button>\n  );\n}\n\n/** Workspace feature toggles where child options unlock with their parent, locked features show the plan they need, and a save bar springs in once something changes. */\nexport function PlanFeatureCard({ workspace = \"Northwind Studio\", features = FEATURES, onSave = () => new Promise((r) => setTimeout(r, 900)), onUpgrade, className }: PlanFeatureCardProps) {\n  const reduce = useReducedMotion();\n  const uid = React.useId();\n  const initial = React.useMemo(() => Object.fromEntries(features.map((f) => [f.id, f.enabled])), [features]);\n  const [base, setBase] = React.useState(initial);\n  const [values, setValues] = React.useState(initial);\n  const [status, setStatus] = React.useState<\"idle\" | \"saving\" | \"saved\">(\"idle\");\n  const dirty = features.filter((f) => values[f.id] !== base[f.id]).length;\n\n  async function save() {\n    setStatus(\"saving\");\n    await onSave(values);\n    setBase(values);\n    setStatus(\"saved\");\n    setTimeout(() => setStatus(\"idle\"), 1600);\n  }\n\n  return (\n    <section aria-labelledby={`${uid}-t`} className={cn(\"w-full max-w-[480px] overflow-hidden rounded-lg border border-border bg-surface-raised shadow-sm\", className)}>\n      <header className=\"border-b border-border px-5 py-4\">\n        <h2 id={`${uid}-t`} className=\"font-display text-[16px] font-semibold text-ink\">Features</h2>\n        <p className=\"mt-0.5 text-[12.5px] text-ink-muted\">Applies to everyone in {workspace}.</p>\n      </header>\n      <ul className=\"divide-y divide-border\">\n        {features.filter((f) => !f.parent).map((f) => {\n          const children = features.filter((c) => c.parent === f.id);\n          const on = !!values[f.id];\n          return (\n            <li key={f.id} className=\"px-5 py-3.5\">\n              <Row f={f} uid={uid} checked={on} disabled={!!f.requires} onToggle={() => setValues((v) => ({ ...v, [f.id]: !on }))} onUpgrade={onUpgrade} />\n              {children.length > 0 && (\n                <motion.ul\n                  aria-label={`${f.label} options`}\n                  animate={{ opacity: on ? 1 : 0.55 }}\n                  transition={{ duration: 0.2 }}\n                  className=\"relative ml-1 mt-2 space-y-2.5 border-l border-border-strong/40 pl-4\"\n                >\n                  {children.map((c) => (\n                    <li key={c.id}>\n                      <Row f={c} uid={uid} checked={on && !!values[c.id]} disabled={!on} onToggle={() => setValues((v) => ({ ...v, [c.id]: !v[c.id] }))} />\n                    </li>\n                  ))}\n                  <AnimatePresence initial={false}>\n                    {!on && (\n                      <motion.li initial={{ height: 0, opacity: 0 }} animate={{ height: \"auto\", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={reduce ? { duration: 0 } : soft} className=\"overflow-hidden text-[11.5px] text-ink-subtle\">\n                        Turn on {f.label.toLowerCase()} to change these.\n                      </motion.li>\n                    )}\n                  </AnimatePresence>\n                </motion.ul>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n      <AnimatePresence initial={false}>\n        {(dirty > 0 || status !== \"idle\") && (\n          <motion.div initial={{ height: 0 }} animate={{ height: \"auto\" }} exit={{ height: 0 }} transition={reduce ? { duration: 0 } : soft} className=\"overflow-hidden\">\n            <div className=\"flex items-center justify-between gap-3 border-t border-border bg-surface px-5 py-3\">\n              <p aria-live=\"polite\" className=\"text-[12.5px] text-ink-muted\">\n                {status === \"saved\" ? (\n                  <span className=\"inline-flex items-center gap-1.5 font-medium text-success\"><Check aria-hidden className=\"size-3.5\" />Changes saved</span>\n                ) : (\n                  `${dirty} unsaved ${dirty === 1 ? \"change\" : \"changes\"}`\n                )}\n              </p>\n              {status !== \"saved\" && (\n                <div className=\"flex gap-2\">\n                  <button type=\"button\" disabled={status === \"saving\"} onClick={() => setValues(base)} className=\"h-8 rounded-full px-3 text-[12.5px] font-medium text-ink-muted outline-none hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)]\">\n                    Discard\n                  </button>\n                  <motion.button type=\"button\" disabled={status === \"saving\"} whileTap={{ scale: 0.96 }} onClick={save} className=\"inline-flex h-8 items-center gap-1.5 rounded-full bg-volt px-3.5 text-[12.5px] font-semibold text-on-volt outline-none focus-visible:shadow-[var(--focus-ring)] disabled:opacity-80\">\n                    {status === \"saving\" && <LoaderCircle aria-hidden className=\"size-3.5 animate-spin\" />}\n                    Save changes\n                  </motion.button>\n                </div>\n              )}\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </section>\n  );\n}\n\nfunction Row({ f, uid, checked, disabled, onToggle, onUpgrade }: { f: PlanFeature; uid: string; checked: boolean; disabled: boolean; onToggle: () => void; onUpgrade?: (plan: string) => void }) {\n  return (\n    <div className=\"flex items-center justify-between gap-4\">\n      <div className=\"min-w-0\">\n        <p id={`${uid}-${f.id}`} className={cn(\"flex items-center gap-2 font-medium\", f.parent ? \"text-[13px]\" : \"text-[13.5px]\", disabled ? \"text-ink-muted\" : \"text-ink\")}>\n          {f.label}\n          {f.requires && (\n            <span className=\"inline-flex items-center gap-1 rounded-full bg-ember-soft px-1.5 py-0.5 text-[10.5px] font-semibold text-ember-ink\">\n              <Lock aria-hidden className=\"size-2.5\" />\n              {f.requires}\n            </span>\n          )}\n        </p>\n        <p id={`${uid}-${f.id}-d`} className=\"text-[12px] text-ink-subtle\">\n          {f.description}\n          {f.requires && (\n            <>\n              {\" \"}\n              <button type=\"button\" onClick={() => onUpgrade?.(f.requires ?? \"\")} className=\"rounded-xs font-medium text-ink underline underline-offset-2 outline-none focus-visible:shadow-[var(--focus-ring)]\">\n                Upgrade to {f.requires}\n              </button>\n            </>\n          )}\n        </p>\n      </div>\n      <Switch checked={checked} disabled={disabled} onToggle={onToggle} labelledBy={`${uid}-${f.id}`} describedBy={`${uid}-${f.id}-d`} />\n    </div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["examples","switch","toggle","settings","preferences","notifications","privacy","features","form"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/switch-cards#plan-features"}}