{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"multi-select","type":"registry:component","title":"Multi Select","description":"Searchable multi-value select whose field is the combobox input: picked options ride as chips that spring in, glide aside and collapse into a \"+N\" overflow chip, over a grouped listbox with draw-in check marks, a gliding active row, Select all / Clear actions, a max limit that shakes on refusal, optional creatable options, full keyboard control and controlled/uncontrolled string[] value.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/multi-select.tsx","type":"registry:component","target":"@components/motion/multi-select.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/multi-select\n\nimport { ChevronsUpDown, Plus, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type MultiSelectOption = {\n  value: string;\n  label: string;\n  /** Extra search terms. */\n  keywords?: string[];\n  disabled?: boolean;\n  icon?: ReactNode;\n};\n\nexport type MultiSelectGroup = {\n  label: string;\n  options: MultiSelectOption[];\n};\n\nexport interface MultiSelectProps {\n  /** Flat options, groups, or a mix. */\n  options: Array<MultiSelectOption | MultiSelectGroup>;\n  /** Controlled selected values. */\n  value?: string[];\n  /** Uncontrolled initial values. */\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Most selections allowed. Remaining options disable at the limit. */\n  max?: number;\n  /** Offer \"Create …\" when the search matches nothing exactly. */\n  creatable?: boolean;\n  /**\n   * Called with the typed label when creating. Return the new option's value\n   * (default: the label itself). Created options are kept in the list.\n   */\n  onCreate?: (label: string) => string | undefined;\n  /** Chips shown before collapsing the rest into \"+N\". Default 3. */\n  maxVisibleChips?: number;\n  /** Show the \"Select all\" / \"Clear\" bar. Default true. */\n  actions?: boolean;\n  label?: string;\n  placeholder?: string;\n  emptyMessage?: ReactNode;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\ntype FlatOption = MultiSelectOption & { group: number | null };\ntype Row =\n  | { kind: \"option\"; option: FlatOption; id: string }\n  | { kind: \"create\"; label: string; id: string };\n\nconst SHAKE = { x: [0, -5, 5, -3, 3, 0] };\n\nfunction isGroup(\n  entry: MultiSelectOption | MultiSelectGroup,\n): entry is MultiSelectGroup {\n  return \"options\" in entry;\n}\n\nfunction matches(option: MultiSelectOption, query: string) {\n  if (!query) return true;\n  const haystack = [option.label, option.value, ...(option.keywords ?? [])]\n    .join(\" \")\n    .toLowerCase();\n  return haystack.includes(query);\n}\n\n/**\n * Multi-value select whose field is the search input: picked values ride as\n * chips that spring in and glide aside, overflow collapses into \"+N\", and the\n * grouped listbox toggles options with draw-in checks.\n */\nexport function MultiSelect({\n  options,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  max,\n  creatable = false,\n  onCreate,\n  maxVisibleChips = 3,\n  actions = true,\n  label,\n  placeholder = \"Select…\",\n  emptyMessage = \"No options found.\",\n  disabled = false,\n  \"aria-label\": ariaLabel,\n  className,\n}: MultiSelectProps) {\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const inputId = `${uid}-input`;\n  const listId = `${uid}-list`;\n  const labelId = `${uid}-label`;\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState<string[]>(defaultValue ?? []);\n  const selected = controlled ? valueProp : internal;\n  const selectedSet = useMemo(() => new Set(selected), [selected]);\n\n  const openControlled = openProp !== undefined;\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const open = (openControlled ? openProp : internalOpen) && !disabled;\n\n  const [query, setQuery] = useState(\"\");\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const [created, setCreated] = useState<MultiSelectOption[]>([]);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n\n  const { groups, flat } = useMemo(() => {\n    const groupLabels: string[] = [];\n    const all: FlatOption[] = [];\n    for (const entry of options) {\n      if (isGroup(entry)) {\n        groupLabels.push(entry.label);\n        for (const option of entry.options)\n          all.push({ ...option, group: groupLabels.length - 1 });\n      } else {\n        all.push({ ...entry, group: null });\n      }\n    }\n    const known = new Set(all.map((o) => o.value));\n    for (const option of created)\n      if (!known.has(option.value)) all.push({ ...option, group: null });\n    return { groups: groupLabels, flat: all };\n  }, [options, created]);\n\n  const byValue = useMemo(\n    () => new Map(flat.map((option) => [option.value, option])),\n    [flat],\n  );\n  const labelOf = (v: string) => byValue.get(v)?.label ?? v;\n\n  const needle = query.trim().toLowerCase();\n  const visible = useMemo(\n    () => flat.filter((option) => matches(option, needle)),\n    [flat, needle],\n  );\n  const atLimit = max !== undefined && selected.length >= max;\n  const isDisabled = (option: MultiSelectOption) =>\n    Boolean(option.disabled) || (atLimit && !selectedSet.has(option.value));\n\n  const canCreate =\n    creatable &&\n    needle.length > 0 &&\n    !flat.some((option) => option.label.toLowerCase() === needle);\n\n  // Navigable rows in visual order: ungrouped first, then each group, then create.\n  const rows = useMemo<Row[]>(() => {\n    const ordered: Row[] = [];\n    const push = (option: FlatOption) =>\n      ordered.push({\n        kind: \"option\",\n        option,\n        id: `${listId}-${encodeURIComponent(option.value)}`,\n      });\n    for (const option of visible) if (option.group === null) push(option);\n    groups.forEach((_, index) => {\n      for (const option of visible) if (option.group === index) push(option);\n    });\n    if (canCreate)\n      ordered.push({ kind: \"create\", label: query.trim(), id: `${listId}-create` });\n    return ordered;\n  }, [visible, groups, canCreate, query, listId]);\n\n  const navigable = rows.filter(\n    (row) => row.kind === \"create\" || !isDisabled(row.option),\n  );\n  const activeRow =\n    rows.find((row) => row.id === activeId && navigable.includes(row)) ?? null;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (disabled && next) return;\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n      if (!next) {\n        setQuery(\"\");\n        setActiveId(null);\n      }\n    },\n    [disabled, onOpenChange, openControlled],\n  );\n\n  const dismiss = useCallback(() => setOpen(false), [setOpen]);\n  useDismiss(open, dismiss, rootRef, { escape: false });\n\n  // Keep the active row in view while arrowing through a long list.\n  useEffect(() => {\n    if (!open || !activeRow) return;\n    const list = listRef.current;\n    const item = document.getElementById(activeRow.id);\n    if (!list || !item) return;\n    const itemRect = item.getBoundingClientRect();\n    const listRect = list.getBoundingClientRect();\n    if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top + 4;\n    else if (itemRect.bottom > listRect.bottom)\n      list.scrollTop += itemRect.bottom - listRect.bottom + 4;\n  }, [activeRow, open]);\n\n  const commit = (next: string[], message: string) => {\n    if (!controlled) setInternal(next);\n    onValueChange?.(next);\n    setAnnouncement(message);\n  };\n\n  const refuse = () => {\n    setAnnouncement(`Up to ${max} selections`);\n    if (fieldRef.current && !reduce)\n      animate(fieldRef.current, SHAKE, { duration: 0.35, ease: EASE_OUT });\n  };\n\n  const toggle = (option: MultiSelectOption) => {\n    if (option.disabled) return;\n    if (selectedSet.has(option.value)) {\n      commit(\n        selected.filter((v) => v !== option.value),\n        `Removed ${option.label}`,\n      );\n      return;\n    }\n    if (atLimit) {\n      refuse();\n      return;\n    }\n    commit([...selected, option.value], `Added ${option.label}`);\n  };\n\n  const create = (raw: string) => {\n    const text = raw.trim();\n    if (!text) return;\n    if (atLimit) {\n      refuse();\n      return;\n    }\n    const newValue = onCreate?.(text) ?? text;\n    if (!byValue.has(newValue))\n      setCreated((current) => [...current, { value: newValue, label: text }]);\n    if (!selectedSet.has(newValue))\n      commit([...selected, newValue], `Created and added ${text}`);\n    setQuery(\"\");\n  };\n\n  const activate = (row: Row) => {\n    if (row.kind === \"create\") create(row.label);\n    else toggle(row.option);\n  };\n\n  const removeValue = (v: string) => {\n    commit(\n      selected.filter((item) => item !== v),\n      `Removed ${labelOf(v)}`,\n    );\n  };\n\n  const selectAll = () => {\n    const additions: string[] = [];\n    const room = max === undefined ? Number.POSITIVE_INFINITY : max - selected.length;\n    for (const option of visible) {\n      if (additions.length >= room) break;\n      if (!option.disabled && !selectedSet.has(option.value))\n        additions.push(option.value);\n    }\n    if (!additions.length) {\n      if (room <= 0) refuse();\n      return;\n    }\n    commit([...selected, ...additions], `Added ${additions.length} options`);\n  };\n\n  const clearAll = () => {\n    if (!selected.length) return;\n    commit(\n      selected.filter((v) => byValue.get(v)?.disabled),\n      \"Cleared selection\",\n    );\n  };\n\n  const move = (direction: 1 | -1 | \"first\" | \"last\") => {\n    if (!navigable.length) {\n      setActiveId(null);\n      return;\n    }\n    if (direction === \"first\") return setActiveId(navigable[0].id);\n    if (direction === \"last\") return setActiveId(navigable[navigable.length - 1].id);\n    const index = activeRow ? navigable.indexOf(activeRow) : -1;\n    const next =\n      index < 0\n        ? direction === 1\n          ? 0\n          : navigable.length - 1\n        : (index + direction + navigable.length) % navigable.length;\n    setActiveId(navigable[next].id);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled || event.nativeEvent.isComposing) return;\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        event.preventDefault();\n        const direction = event.key === \"ArrowDown\" ? 1 : -1;\n        if (!open) {\n          setOpen(true);\n          setActiveId(null);\n          return;\n        }\n        move(direction);\n        return;\n      }\n      case \"Home\":\n      case \"End\":\n        if (!open || query) return;\n        event.preventDefault();\n        move(event.key === \"Home\" ? \"first\" : \"last\");\n        return;\n      case \"Enter\":\n        event.preventDefault();\n        if (!open) setOpen(true);\n        else if (activeRow) activate(activeRow);\n        else if (canCreate) create(query);\n        return;\n      case \"Escape\":\n        if (open) {\n          event.preventDefault();\n          setOpen(false);\n        } else if (query) {\n          setQuery(\"\");\n        }\n        return;\n      case \"Backspace\":\n        if (query || !selected.length) return;\n        event.preventDefault();\n        removeValue(selected[selected.length - 1]);\n        return;\n      case \"Tab\":\n        if (open) setOpen(false);\n        return;\n    }\n  };\n\n  // Clicks on the field's empty space land in the input and toggle the list.\n  const onFieldMouseDown = (event: MouseEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    const target = event.target as HTMLElement;\n    if (target.closest(\"button\")) return;\n    if (target !== inputRef.current) event.preventDefault();\n    inputRef.current?.focus();\n    setOpen(!open || target === inputRef.current);\n  };\n\n  const visibleChips = selected.slice(0, maxVisibleChips);\n  const hidden = selected.slice(maxVisibleChips);\n  const chipTransition = reduce ? { duration: 0.15 } : SPRING_SWAP;\n  const chipInitial = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 };\n  const chipExit = reduce\n    ? { opacity: 0, transition: { duration: 0.1 } }\n    : { opacity: 0, scale: 0.6, transition: { duration: 0.14, ease: EASE_OUT } };\n\n  const renderRow = (row: Row) => {\n    const isActive = activeRow?.id === row.id;\n    const highlight = isActive ? (\n      <motion.span\n        aria-hidden\n        layoutId={`${uid}-active`}\n        transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n        className=\"absolute inset-0 -z-10 rounded-lg bg-muted\"\n      />\n    ) : null;\n\n    if (row.kind === \"create\") {\n      return (\n        <button\n          key={row.id}\n          id={row.id}\n          type=\"button\"\n          role=\"option\"\n          tabIndex={-1}\n          aria-selected={false}\n          onMouseDown={(event) => event.preventDefault()}\n          onPointerMove={() => setActiveId(row.id)}\n          onClick={() => create(row.label)}\n          className={cn(\n            \"relative flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm outline-none\",\n            isActive ? \"text-foreground\" : \"text-muted-foreground\",\n          )}\n        >\n          {highlight}\n          <span className=\"grid size-4 shrink-0 place-items-center\">\n            <Plus className=\"size-3.5\" aria-hidden />\n          </span>\n          <span className=\"min-w-0 flex-1 truncate\">\n            Create <span className=\"font-medium text-foreground\">“{row.label}”</span>\n          </span>\n        </button>\n      );\n    }\n\n    const { option } = row;\n    const checked = selectedSet.has(option.value);\n    const off = isDisabled(option);\n    return (\n      <button\n        key={row.id}\n        id={row.id}\n        type=\"button\"\n        role=\"option\"\n        tabIndex={-1}\n        aria-selected={checked}\n        aria-disabled={off || undefined}\n        data-active={isActive || undefined}\n        onMouseDown={(event) => event.preventDefault()}\n        onPointerMove={() => {\n          if (!off) setActiveId(row.id);\n        }}\n        onClick={() => {\n          if (off) {\n            if (atLimit && !option.disabled) refuse();\n            return;\n          }\n          setActiveId(row.id);\n          toggle(option);\n        }}\n        className={cn(\n          \"relative flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left text-sm outline-none transition-colors duration-150\",\n          isActive || checked ? \"text-foreground\" : \"text-muted-foreground\",\n          off && \"cursor-not-allowed opacity-45\",\n        )}\n      >\n        {highlight}\n        <CheckBox checked={checked} reduce={Boolean(reduce)} />\n        {option.icon ? (\n          <span\n            aria-hidden\n            className=\"grid shrink-0 place-items-center [&_svg]:size-4\"\n          >\n            {option.icon}\n          </span>\n        ) : null}\n        <span className=\"min-w-0 flex-1 truncate\">{option.label}</span>\n      </button>\n    );\n  };\n\n  const ungroupedRows = rows.filter(\n    (row) => row.kind === \"option\" && row.option.group === null,\n  );\n  const createRow = rows.find((row) => row.kind === \"create\");\n\n  return (\n    <div ref={rootRef} className={cn(\"relative flex w-full flex-col gap-1.5\", className)}>\n      {label ? (\n        <label\n          id={labelId}\n          htmlFor={inputId}\n          className=\"px-1 text-sm font-medium text-foreground\"\n        >\n          {label}\n        </label>\n      ) : null}\n\n      {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer convenience only — the combobox input inside owns focus and keyboard. */}\n      <div\n        ref={fieldRef}\n        onMouseDown={onFieldMouseDown}\n        data-state={open ? \"open\" : \"closed\"}\n        className={cn(\n          \"flex min-h-11 w-full cursor-text items-center gap-2 rounded-2xl border border-border bg-background py-1.5 pr-2 pl-2 transition-colors duration-200 hover:border-foreground/20\",\n          \"focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/40\",\n          disabled && \"pointer-events-none opacity-60\",\n        )}\n      >\n        <div className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1.5\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {visibleChips.map((v) => (\n              <motion.span\n                key={v}\n                layout={!reduce}\n                initial={chipInitial}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={chipExit}\n                transition={{ default: chipTransition, layout: SPRING_LAYOUT }}\n                className=\"inline-flex h-7 max-w-40 items-center gap-1 rounded-full bg-muted pr-1 pl-2.5 text-sm text-foreground\"\n              >\n                <span className=\"truncate\">{labelOf(v)}</span>\n                <button\n                  type=\"button\"\n                  tabIndex={-1}\n                  disabled={disabled}\n                  aria-label={`Remove ${labelOf(v)}`}\n                  onMouseDown={(event) => event.preventDefault()}\n                  onClick={() => removeValue(v)}\n                  className=\"grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-foreground/10 hover:text-foreground\"\n                >\n                  <X className=\"size-3\" aria-hidden />\n                </button>\n              </motion.span>\n            ))}\n            {hidden.length > 0 ? (\n              <motion.span\n                key=\"overflow\"\n                layout={!reduce}\n                initial={chipInitial}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={chipExit}\n                transition={{ default: chipTransition, layout: SPRING_LAYOUT }}\n                title={hidden.map(labelOf).join(\", \")}\n                className=\"inline-flex h-7 items-center rounded-full border border-border px-2.5 text-sm tabular-nums text-muted-foreground\"\n              >\n                <span aria-hidden>+{hidden.length}</span>\n                <span className=\"sr-only\">\n                  and {hidden.length} more: {hidden.map(labelOf).join(\", \")}\n                </span>\n              </motion.span>\n            ) : null}\n            <motion.input\n              key=\"input\"\n              layout={!reduce ? \"position\" : false}\n              transition={{ layout: SPRING_LAYOUT }}\n              ref={inputRef}\n              id={inputId}\n              type=\"text\"\n              role=\"combobox\"\n              aria-label={label ? undefined : (ariaLabel ?? \"Select options\")}\n              aria-autocomplete=\"list\"\n              aria-expanded={open}\n              aria-controls={listId}\n              aria-activedescendant={open && activeRow ? activeRow.id : undefined}\n              autoComplete=\"off\"\n              spellCheck={false}\n              disabled={disabled}\n              value={query}\n              placeholder={selected.length ? \"Search…\" : placeholder}\n              onChange={(event) => {\n                setQuery(event.target.value);\n                setActiveId(null);\n                if (!open) setOpen(true);\n              }}\n              onKeyDown={onKeyDown}\n              className=\"h-7 min-w-20 flex-1 bg-transparent px-1 text-sm text-foreground caret-foreground outline-none placeholder:text-muted-foreground/70 disabled:cursor-not-allowed\"\n            />\n          </AnimatePresence>\n        </div>\n        <span aria-hidden className=\"shrink-0 self-center text-muted-foreground\">\n          <ChevronsUpDown className=\"size-4\" />\n        </span>\n      </div>\n\n      <AnimatePresence>\n        {open ? (\n          <motion.div\n            key=\"panel\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.1 } }\n                : {\n                    opacity: 0,\n                    y: -4,\n                    scale: 0.98,\n                    transition: { duration: 0.12, ease: EASE_OUT },\n                  }\n            }\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            style={{ transformOrigin: \"top\" }}\n            className=\"absolute inset-x-0 top-full z-30 mt-1.5 overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-lg\"\n          >\n            {actions ? (\n              <div className=\"flex items-center justify-between gap-2 border-b border-border px-3 py-1.5 text-xs\">\n                <span className=\"tabular-nums text-muted-foreground\">\n                  {selected.length}\n                  {max !== undefined ? `/${max}` : \"\"} selected\n                </span>\n                <div className=\"flex items-center gap-1\">\n                  <button\n                    type=\"button\"\n                    onMouseDown={(event) => event.preventDefault()}\n                    onClick={selectAll}\n                    className=\"rounded-md px-2 py-1 font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted\"\n                  >\n                    Select all\n                  </button>\n                  <button\n                    type=\"button\"\n                    disabled={!selected.length}\n                    onMouseDown={(event) => event.preventDefault()}\n                    onClick={clearAll}\n                    className=\"rounded-md px-2 py-1 text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-40\"\n                  >\n                    Clear\n                  </button>\n                </div>\n              </div>\n            ) : null}\n            <div\n              ref={listRef}\n              id={listId}\n              role=\"listbox\"\n              aria-multiselectable\n              aria-labelledby={label ? labelId : undefined}\n              aria-label={label ? undefined : (ariaLabel ?? \"Options\")}\n              className=\"relative isolate max-h-60 overflow-y-auto overscroll-contain p-1.5 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\"\n            >\n              {ungroupedRows.map(renderRow)}\n              {groups.map((groupLabel, index) => {\n                const groupRows = rows.filter(\n                  (row) => row.kind === \"option\" && row.option.group === index,\n                );\n                if (!groupRows.length) return null;\n                return (\n                  <fieldset\n                    // biome-ignore lint/suspicious/noArrayIndexKey: group order is the identity here.\n                    key={index}\n                    className=\"m-0 min-w-0 border-0 p-0 py-0.5\"\n                  >\n                    <legend className=\"w-full px-2 py-1.5 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted-foreground\">\n                      {groupLabel}\n                    </legend>\n                    {groupRows.map(renderRow)}\n                  </fieldset>\n                );\n              })}\n              {createRow ? renderRow(createRow) : null}\n              {rows.length === 0 ? (\n                <p className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n                  {emptyMessage}\n                </p>\n              ) : null}\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction CheckBox({ checked, reduce }: { checked: boolean; reduce: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"grid size-4 shrink-0 place-items-center rounded-[5px] border transition-colors duration-150\",\n        checked\n          ? \"border-primary bg-primary text-primary-foreground\"\n          : \"border-muted-foreground/40 bg-transparent text-transparent\",\n      )}\n    >\n      <svg viewBox=\"0 0 24 24\" fill=\"none\" className=\"size-3\" aria-hidden=\"true\">\n        <motion.path\n          d=\"M5 12.5l4.5 4.5L19 7.5\"\n          stroke=\"currentColor\"\n          strokeWidth={3.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          initial={false}\n          animate={{ pathLength: checked ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }}\n        />\n      </svg>\n    </span>\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/hooks/use-dismiss.ts","type":"registry:hook","target":"@lib/hooks/use-dismiss.ts","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\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"}]}