{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"segmented-control","type":"registry:component","title":"Segmented Control","description":"Form-ready SegmentedControl with radiogroup semantics, a raised thumb that glides between options on a shared-layout spring, icon and/or label options, sizes, full-width layout, disabled options, arrow-key roving where selection follows focus and an optional hidden input for forms, plus a ToggleGroup of spring-pressed buttons in single or multiple mode.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/segmented-control.tsx","type":"registry:component","target":"@components/motion/segmented-control.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/segmented-control\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  useCallback,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// SegmentedControl vs Tabs: `Tabs` (tabs.tsx, variants pill/segment/underline)\n// switches *panels* with tablist/tab semantics. `SegmentedControl` is a form\n// control: it picks one *value* with radiogroup/radio semantics, can post that\n// value with a form via `name`, supports disabled options, icon-only options\n// and a full-width layout, and its thumb is a raised card surface rather than\n// a filled accent pill. Reach for Tabs when each option owns content below it.\n\nexport type SegmentedControlSize = \"sm\" | \"md\" | \"lg\";\n\nexport interface SegmentedControlOption {\n  value: string;\n  label?: ReactNode;\n  icon?: ReactNode;\n  disabled?: boolean;\n  /** Required when the option has only an icon. */\n  \"aria-label\"?: string;\n}\n\nconst trackSize: Record<SegmentedControlSize, string> = {\n  sm: \"h-8 p-0.5 text-xs\",\n  md: \"h-10 p-1 text-sm\",\n  lg: \"h-12 p-1 text-sm\",\n};\n\nconst itemSize: Record<SegmentedControlSize, string> = {\n  sm: \"gap-1.5 px-2.5 [&_svg]:size-3.5\",\n  md: \"gap-2 px-3.5 [&_svg]:size-4\",\n  lg: \"gap-2 px-5 [&_svg]:size-[18px]\",\n};\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState<T>(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\n/** Index of the next enabled option from `from` in `dir`, wrapping around. */\nfunction nextEnabled(options: { disabled?: boolean }[], from: number, dir: 1 | -1) {\n  const n = options.length;\n  for (let step = 1; step <= n; step++) {\n    const i = (((from + dir * step) % n) + n) % n;\n    if (!options[i].disabled) return i;\n  }\n  return from;\n}\n\nexport interface SegmentedControlProps {\n  options: SegmentedControlOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  size?: SegmentedControlSize;\n  /** Stretch to the container width with equal-width segments. */\n  fullWidth?: boolean;\n  disabled?: boolean;\n  /** When set, a hidden input posts the value with the surrounding form. */\n  name?: string;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n  thumbClassName?: string;\n}\n\nexport function SegmentedControl({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  size = \"md\",\n  fullWidth = false,\n  disabled = false,\n  name,\n  className,\n  thumbClassName,\n  ...aria\n}: SegmentedControlProps) {\n  const [current, setValue] = useControllable(\n    value,\n    defaultValue ?? options.find((o) => !o.disabled)?.value ?? \"\",\n    onValueChange,\n  );\n  const reduce = useReducedMotion();\n  const layoutId = useId();\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const items = options.map((o) => ({ ...o, disabled: disabled || o.disabled }));\n  const selectedIndex = items.findIndex((o) => o.value === current);\n  // Roving tab stop: the checked radio, or the first enabled one when none is.\n  const tabStop = selectedIndex >= 0 && !items[selectedIndex].disabled\n    ? selectedIndex\n    : items.findIndex((o) => !o.disabled);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    let next: number | null = null;\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        next = nextEnabled(items, index, 1);\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        next = nextEnabled(items, index, -1);\n        break;\n      case \"Home\":\n        next = nextEnabled(items, -1, 1);\n        break;\n      case \"End\":\n        next = nextEnabled(items, items.length, -1);\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    if (next === null || items[next].disabled) return;\n    // Selection follows focus, as the WAI-ARIA radio group pattern prescribes.\n    refs.current[next]?.focus();\n    if (items[next].value !== current) setValue(items[next].value);\n  };\n\n  return (\n    // layoutRoot keeps the thumb's shared-layout projection local, so scrolled\n    // or fixed ancestors never leak offsets into its glide.\n    <motion.div\n      layoutRoot\n      role=\"radiogroup\"\n      aria-disabled={disabled || undefined}\n      {...aria}\n      className={cn(\n        \"relative isolate items-stretch rounded-xl border border-border bg-muted\",\n        fullWidth ? \"flex w-full\" : \"inline-flex\",\n        trackSize[size],\n        disabled && \"opacity-60\",\n        className,\n      )}\n    >\n      {name ? <input type=\"hidden\" name={name} value={current} /> : null}\n      {items.map((option, index) => {\n        const checked = option.value === current;\n        const iconOnly = !option.label;\n        return (\n          <motion.button\n            key={option.value}\n            ref={(el) => {\n              refs.current[index] = el;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            aria-label={option[\"aria-label\"]}\n            disabled={option.disabled}\n            tabIndex={index === tabStop ? 0 : -1}\n            data-state={checked ? \"checked\" : \"unchecked\"}\n            onClick={() => !checked && setValue(option.value)}\n            onKeyDown={(e) => onKeyDown(e, index)}\n            whileTap={reduce || option.disabled ? undefined : { scale: 0.96 }}\n            transition={SPRING_PRESS}\n            className={cn(\n              \"relative inline-flex min-w-0 select-none items-center justify-center whitespace-nowrap rounded-[9px] font-medium outline-none transition-colors duration-200\",\n              \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-muted\",\n              \"disabled:cursor-not-allowed disabled:opacity-50\",\n              fullWidth && \"flex-1\",\n              itemSize[size],\n              iconOnly && \"aspect-square px-0\",\n              checked\n                ? \"text-foreground\"\n                : \"text-muted-foreground enabled:hover:text-foreground\",\n            )}\n          >\n            {checked ? (\n              <motion.span\n                layoutId={layoutId}\n                aria-hidden\n                transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                className={cn(\n                  \"absolute inset-0 -z-10 rounded-[9px] border border-border bg-card shadow-sm\",\n                  thumbClassName,\n                )}\n              />\n            ) : null}\n            {option.icon ? (\n              <span aria-hidden className=\"inline-flex shrink-0\">\n                {option.icon}\n              </span>\n            ) : null}\n            {option.label ? <span className=\"truncate\">{option.label}</span> : null}\n          </motion.button>\n        );\n      })}\n    </motion.div>\n  );\n}\n\n/* ------------------------------------------------------------------------ */\n/* ToggleGroup                                                               */\n/* ------------------------------------------------------------------------ */\n\nexport interface ToggleGroupItem {\n  value: string;\n  label?: ReactNode;\n  icon?: ReactNode;\n  disabled?: boolean;\n  /** Required when the item has only an icon. */\n  \"aria-label\"?: string;\n}\n\ntype ToggleGroupBase = {\n  items: ToggleGroupItem[];\n  size?: SegmentedControlSize;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n};\n\nexport type ToggleGroupSingleProps = ToggleGroupBase & {\n  type: \"single\";\n  /** The pressed item, or `\"\"` when none is pressed. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Disallow un-pressing the active item, so one is always pressed. */\n  required?: boolean;\n};\n\nexport type ToggleGroupMultipleProps = ToggleGroupBase & {\n  type: \"multiple\";\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n};\n\nexport type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;\n\nconst toggleSize: Record<SegmentedControlSize, string> = {\n  sm: \"h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5\",\n  md: \"h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4\",\n  lg: \"h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]\",\n};\n\nexport function ToggleGroup(props: ToggleGroupProps) {\n  const { items, size = \"md\", disabled = false, className } = props;\n  const reduce = useReducedMotion();\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const [focusIndex, setFocusIndex] = useState<number | null>(null);\n\n  // Normalise both modes to a string[] internally.\n  const single = props.type === \"single\";\n  const [current, setCurrent] = useControllable<string[]>(\n    props.value === undefined ? undefined : single ? (props.value ? [props.value as string] : []) : (props.value as string[]),\n    single\n      ? props.defaultValue\n        ? [props.defaultValue as string]\n        : []\n      : ((props.defaultValue as string[] | undefined) ?? []),\n    (next) => {\n      if (props.type === \"single\") props.onValueChange?.(next[0] ?? \"\");\n      else props.onValueChange?.(next);\n    },\n  );\n\n  const list = items.map((i) => ({ ...i, disabled: disabled || i.disabled }));\n  const firstEnabled = list.findIndex((i) => !i.disabled);\n  const tabStop =\n    focusIndex !== null && !list[focusIndex]?.disabled ? focusIndex : firstEnabled;\n\n  const toggle = (itemValue: string) => {\n    const pressed = current.includes(itemValue);\n    if (props.type === \"single\") {\n      if (pressed && props.required) return;\n      setCurrent(pressed ? [] : [itemValue]);\n    } else {\n      setCurrent(pressed ? current.filter((v) => v !== itemValue) : [...current, itemValue]);\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    let next: number | null = null;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") next = nextEnabled(list, index, 1);\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") next = nextEnabled(list, index, -1);\n    else if (event.key === \"Home\") next = nextEnabled(list, -1, 1);\n    else if (event.key === \"End\") next = nextEnabled(list, list.length, -1);\n    if (next === null) return;\n    event.preventDefault();\n    // Focus moves; pressing stays on Space/Enter (toggle buttons, not radios).\n    setFocusIndex(next);\n    refs.current[next]?.focus();\n  };\n\n  return (\n    <motion.div\n      role=\"group\"\n      aria-label={props[\"aria-label\"]}\n      aria-labelledby={props[\"aria-labelledby\"]}\n      className={cn(\n        \"inline-flex items-center gap-0.5 rounded-xl border border-border bg-card p-1\",\n        disabled && \"opacity-60\",\n        className,\n      )}\n    >\n      {list.map((item, index) => {\n        const pressed = current.includes(item.value);\n        return (\n          <motion.button\n            key={item.value}\n            ref={(el) => {\n              refs.current[index] = el;\n            }}\n            type=\"button\"\n            aria-pressed={pressed}\n            aria-label={item[\"aria-label\"]}\n            disabled={item.disabled}\n            tabIndex={index === tabStop ? 0 : -1}\n            data-state={pressed ? \"on\" : \"off\"}\n            onClick={() => {\n              setFocusIndex(index);\n              toggle(item.value);\n            }}\n            onFocus={() => setFocusIndex(index)}\n            onKeyDown={(e) => onKeyDown(e, index)}\n            whileTap={reduce || item.disabled ? undefined : { scale: 0.88 }}\n            transition={SPRING_PRESS}\n            className={cn(\n              \"relative isolate inline-flex select-none items-center justify-center rounded-lg font-medium outline-none transition-colors duration-200\",\n              \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card\",\n              \"disabled:cursor-not-allowed disabled:opacity-50\",\n              toggleSize[size],\n              pressed\n                ? \"text-foreground\"\n                : \"text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground\",\n            )}\n          >\n            <AnimatePresence initial={false}>\n              {pressed ? (\n                <motion.span\n                  key=\"on\"\n                  aria-hidden\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }}\n                  transition={SPRING_SWAP}\n                  className=\"absolute inset-0 -z-10 rounded-lg bg-muted ring-1 ring-border-strong\"\n                />\n              ) : null}\n            </AnimatePresence>\n            {item.icon ? (\n              <span aria-hidden className=\"inline-flex shrink-0\">\n                {item.icon}\n              </span>\n            ) : null}\n            {item.label ? <span>{item.label}</span> : null}\n          </motion.button>\n        );\n      })}\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}