{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "permission-switch",
  "type": "registry:ui",
  "title": "Permission Switch",
  "author": "Rishabh Bose <https://theb0se.vercel.app/>",
  "description": "Tactile segmented control where the selected option expands to reveal its label while others collapse to icon-only, with a sliding pill and blur transitions.",
  "dependencies": [
    "framer-motion",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/ui/permission-switch.tsx",
      "content": "\"use client\";\n\nimport React, {\n  useState,\n  useRef,\n  useCallback,\n  useId,\n  useLayoutEffect,\n  useEffect,\n} from \"react\";\nimport { motion, useReducedMotion, type Transition } from \"framer-motion\";\nimport { CircleCheck, Hand, Ban, type LucideIcon } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\n/* ------------------------------------------------------------------ */\n/* Types & Interfaces                                                 */\n/* ------------------------------------------------------------------ */\n\nexport interface PermissionOption {\n  value: string;\n  label: string;\n  icon?: LucideIcon | React.ComponentType<any>;\n}\n\nexport interface PermissionSwitchProps {\n  /** Array of options with value, label, and optional icon */\n  options?: PermissionOption[];\n  /** Controlled active value */\n  value?: string;\n  /** Default uncontrolled initial value */\n  defaultValue?: string;\n  /** Callback fired when selected value changes */\n  onChange?: (value: string) => void;\n  /** If true, expands active tab with a slower, luxurious cinematic spring */\n  activeMotion?: boolean;\n  /** Custom duration in ms when activeMotion is enabled. Default: 780 */\n  slowDuration?: number;\n  /** Custom default duration in ms. Default: 460 */\n  duration?: number;\n  /** Accessible label for the segmented radiogroup. Default: \"Permission\" */\n  \"aria-label\"?: string;\n  /** Additional container class names */\n  className?: string;\n}\n\nexport const DEFAULT_PERMISSION_OPTIONS: PermissionOption[] = [\n  { value: \"allow\", label: \"Always allow\", icon: CircleCheck },\n  { value: \"ask\", label: \"Needs approval\", icon: Hand },\n  { value: \"deny\", label: \"Deny\", icon: Ban },\n];\n\n// Geometry layout constants (px)\nconst TRACK_PAD = 4;\nconst GAP = 2;\nconst ICON_SIZE = 20;\nconst CELL_PAD = 8;\nconst LABEL_GAP = 8;\nconst LABEL_TAIL = 4;\n\nconst DEFAULT_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];\nconst SLOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];\n\n/**\n * PermissionSwitch\n * A tactile segmented control where the active option smoothly expands\n * to reveal its label while inactive options collapse to icon-only.\n * Built with Tailwind CSS and Framer Motion for kinetic, fluid interactions.\n */\nexport function PermissionSwitch({\n  options = DEFAULT_PERMISSION_OPTIONS,\n  value,\n  defaultValue,\n  onChange,\n  activeMotion,\n  slowDuration = 780,\n  duration = 460,\n  \"aria-label\": ariaLabel = \"Permission\",\n  className,\n}: PermissionSwitchProps) {\n  const shouldReduceMotion = useReducedMotion() ?? false;\n  const isSlow = Boolean(activeMotion);\n  const activeDuration = isSlow ? slowDuration : duration;\n  const activeEase = isSlow ? SLOW_EASE : DEFAULT_EASE;\n\n  const isControlled = value !== undefined;\n  const [inner, setInner] = useState(\n    defaultValue ?? options[0]?.value ?? \"allow\"\n  );\n  const current = isControlled ? value : inner;\n\n  const activeIndex = Math.max(\n    0,\n    options.findIndex((o) => o.value === current)\n  );\n\n  const [labelWidths, setLabelWidths] = useState<number[]>(() =>\n    options.map(() => 0)\n  );\n  const [ready, setReady] = useState(false);\n\n  const measureRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const uid = useId();\n\n  // Measure natural label widths for exact pixel animations\n  const measure = useCallback(() => {\n    const next = options.map(\n      (_, i) =>\n        Math.ceil(\n          measureRefs.current[i]?.getBoundingClientRect().width ?? 0\n        ) + 1\n    );\n    setLabelWidths((prev) =>\n      prev.length === next.length &&\n        prev.every((w, i) => Math.abs(w - next[i]) < 0.5)\n        ? prev\n        : next\n    );\n  }, [options]);\n\n  useIsomorphicLayoutEffect(() => {\n    measure();\n    const id = requestAnimationFrame(() => setReady(true));\n    if (typeof document !== \"undefined\" && document.fonts?.ready) {\n      document.fonts.ready.then(measure);\n    }\n    window.addEventListener(\"resize\", measure);\n    return () => {\n      cancelAnimationFrame(id);\n      window.removeEventListener(\"resize\", measure);\n    };\n  }, [measure]);\n\n  const baseWidth = ICON_SIZE + CELL_PAD * 2; // 36px\n  const widths = options.map((_, i) =>\n    i === activeIndex\n      ? baseWidth + LABEL_GAP + (labelWidths[i] || 0) + LABEL_TAIL\n      : baseWidth\n  );\n\n  const pillLeft =\n    TRACK_PAD +\n    widths.slice(0, activeIndex).reduce((acc, w) => acc + w + GAP, 0);\n\n  const totalWidth =\n    TRACK_PAD * 2 +\n    widths.reduce((acc, w) => acc + w, 0) +\n    GAP * Math.max(0, options.length - 1);\n\n  const select = (i: number) => {\n    const opt = options[i];\n    if (!opt) return;\n    if (!isControlled) setInner(opt.value);\n    onChange?.(opt.value);\n    btnRefs.current[i]?.focus();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const last = options.length - 1;\n    let nextIndex: number | null = null;\n\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") {\n      nextIndex = activeIndex === last ? 0 : activeIndex + 1;\n    }\n    if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") {\n      nextIndex = activeIndex === 0 ? last : activeIndex - 1;\n    }\n    if (e.key === \"Home\") nextIndex = 0;\n    if (e.key === \"End\") nextIndex = last;\n\n    if (nextIndex !== null) {\n      e.preventDefault();\n      select(nextIndex);\n    }\n  };\n\n  const mainTransition: Transition =\n    shouldReduceMotion || !ready\n      ? { duration: 0 }\n      : {\n        duration: activeDuration / 1000,\n        ease: activeEase,\n      };\n\n  const labelTransition = (isActive: boolean): Transition => {\n    if (shouldReduceMotion || !ready) return { duration: 0 };\n    return {\n      duration: isSlow ? 0.38 : 0.22,\n      delay: isActive ? (isSlow ? 0.16 : 0.08) : 0,\n      ease: activeEase,\n    };\n  };\n\n  return (\n    <>\n      {/* Hidden container for accurate text width measurement */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute left-0 top-0 -z-50 invisible flex select-none overflow-hidden opacity-0\"\n      >\n        {options.map((opt, i) => (\n          <span\n            key={opt.value}\n            ref={(el) => {\n              measureRefs.current[i] = el;\n            }}\n            className=\"whitespace-nowrap text-sm font-medium tracking-tight\"\n          >\n            {opt.label}\n          </span>\n        ))}\n      </div>\n\n      <motion.div\n        role=\"radiogroup\"\n        aria-label={ariaLabel}\n        onKeyDown={onKeyDown}\n        initial={false}\n        animate={{\n          width: ready && totalWidth > 0 ? totalWidth : \"auto\",\n        }}\n        transition={mainTransition}\n        className={cn(\n          \"relative inline-flex items-center gap-0.5 rounded-full p-1 select-none\",\n          \"bg-neutral-200/70 dark:bg-neutral-800/80\",\n          \"border border-neutral-300/40 dark:border-neutral-700/50\",\n          className\n        )}\n      >\n        {/* Sliding background pill indicator */}\n        <motion.span\n          aria-hidden=\"true\"\n          initial={false}\n          animate={{\n            x: pillLeft,\n            width: widths[activeIndex] || baseWidth,\n          }}\n          transition={mainTransition}\n          className=\"pointer-events-none absolute top-1 left-0 h-9 rounded-full bg-white shadow-sm shadow-neutral-950/10 dark:bg-neutral-700 dark:shadow-neutral-950/40\"\n        />\n\n        {options.map((opt, i) => {\n          const Icon = opt.icon;\n          const active = i === activeIndex;\n\n          return (\n            <motion.button\n              key={opt.value}\n              ref={(el) => {\n                btnRefs.current[i] = el;\n              }}\n              id={`${uid}-${opt.value}`}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={active}\n              aria-label={opt.label}\n              tabIndex={active ? 0 : -1}\n              onClick={() => select(i)}\n              initial={false}\n              animate={{\n                width: widths[i] || baseWidth,\n              }}\n              transition={mainTransition}\n              className={cn(\n                \"group relative z-10 flex h-9 shrink-0 cursor-pointer items-center overflow-hidden rounded-full px-2 text-sm font-medium\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 dark:focus-visible:ring-blue-400 dark:focus-visible:ring-offset-neutral-900\",\n                \"transition-colors duration-200\",\n                active\n                  ? \"text-neutral-900 dark:text-neutral-100\"\n                  : \"text-neutral-500 hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200\"\n              )}\n            >\n              {Icon && (\n                <Icon\n                  className=\"h-5 w-5 shrink-0 transition-colors duration-200\"\n                  strokeWidth={1.75}\n                />\n              )}\n\n              <motion.span\n                aria-hidden={!active}\n                initial={false}\n                animate={{\n                  opacity: active ? 1 : 0,\n                  filter: active ? \"blur(0px)\" : \"blur(4px)\",\n                  x: active ? 0 : -6,\n                }}\n                transition={labelTransition(active)}\n                className={cn(\n                  \"pointer-events-none whitespace-nowrap text-sm font-medium tracking-tight\",\n                  Icon ? \"ml-2\" : \"ml-0\"\n                )}\n              >\n                {opt.label}\n              </motion.span>\n            </motion.button>\n          );\n        })}\n      </motion.div>\n    </>\n  );\n}\n\nexport default PermissionSwitch;\n",
      "type": "registry:ui",
      "target": "components/ui/permission-switch.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport async function copyToClipboard(text: string): Promise<boolean> {\n  if (typeof window === \"undefined\") return false;\n\n  try {\n    if (navigator.clipboard && window.isSecureContext) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n\n    const textArea = document.createElement(\"textarea\");\n    textArea.value = text;\n    textArea.style.position = \"fixed\";\n    textArea.style.left = \"-999999px\";\n    textArea.style.top = \"-999999px\";\n    document.body.appendChild(textArea);\n    textArea.focus();\n    textArea.select();\n    const succeeded = document.execCommand(\"copy\");\n    textArea.remove();\n    return succeeded;\n  } catch {\n    return false;\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/utils.ts"
    }
  ]
}
