{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "type": "registry:ui",
  "title": "Multi Select",
  "description": "Chips that fit the field with a rolling +N, search, select all and clear.",
  "categories": [
    "pickers"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/multi-select.tsx",
      "content": "\"use client\";\nimport { Combobox } from \"@base-ui/react/combobox\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronsUpDown, Search, X } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\n\nexport type MultiSelectOption = {\n  value: string;\n  label: string;\n  /** Decorative: an avatar, a flag, a status dot. Shown in the list and on the chip. */\n  icon?: React.ReactNode;\n  description?: string;\n  disabled?: boolean;\n};\n\n// The \"Select all\" row is a real list item so arrow keys reach it; it never stays in the value.\nconst ALL: MultiSelectOption = { value: \"\\u0000all\", label: \"Select all\" };\n\nexport type MultiSelectProps = {\n  options: MultiSelectOption[];\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  /** Visible label. Without one, pass aria-label. */\n  label?: React.ReactNode;\n  \"aria-label\"?: string;\n  placeholder?: string;\n  searchPlaceholder?: string;\n  /** Noun for the count and the empty state, e.g. \"people\". */\n  noun?: string;\n  /** Show the \"Select all\" row, which acts on what the search currently matches. */\n  selectAll?: boolean;\n  disabled?: boolean;\n  invalid?: boolean;\n  name?: string;\n  /** Portal target for the popup. Defaults to document.body. */\n  container?: Combobox.Portal.Props[\"container\"];\n  className?: string;\n};\n\nconst normalize = (s: string) => s.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLocaleLowerCase();\n\nexport function MultiSelect({\n  options,\n  value,\n  defaultValue = [],\n  onValueChange,\n  label,\n  \"aria-label\": ariaLabel,\n  placeholder = \"Select…\",\n  searchPlaceholder = \"Search\",\n  noun = \"items\",\n  selectAll = true,\n  disabled,\n  invalid,\n  name,\n  container,\n  className,\n}: MultiSelectProps) {\n  const reduce = !!useReducedMotion();\n  const [inner, setInner] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const ids = controlled ? value : inner;\n  const setIds = (next: string[]) => {\n    if (!controlled) setInner(next);\n    onValueChange?.(next);\n  };\n\n  const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n  const selected = useMemo(() => ids.map((id) => byValue.get(id)).filter((o): o is MultiSelectOption => !!o), [ids, byValue]);\n  const [query, setQuery] = useState(\"\");\n  const q = normalize(query.trim());\n  const matches = useMemo(() => (q ? options.filter((o) => normalize(o.label).includes(q) || normalize(o.description ?? \"\").includes(q)) : options), [options, q]);\n  const pickable = matches.filter((o) => !o.disabled);\n  const pickedInMatches = pickable.filter((o) => ids.includes(o.value)).length;\n  const allState = pickable.length > 0 && pickedInMatches === pickable.length ? \"checked\" : pickedInMatches > 0 ? \"mixed\" : \"unchecked\";\n  const filtered = matches.length ? (selectAll && pickable.length > 1 ? [ALL, ...matches] : matches) : [];\n\n  const describedBy = useId();\n  const { rowRef, measureRef, fit } = useFit(selected.map((o) => o.value).join(\"|\"));\n  const shown = selected.slice(0, fit);\n  const overflow = selected.length - shown.length;\n  const { setList, y, height, opacity } = useGlide(reduce);\n\n  // The resting state is the same either way so server and client markup agree; only the travel differs.\n  const chipMotion = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: \"blur(2px)\" },\n    animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.8, filter: \"blur(2px)\", transition: { duration: 0.12 } },\n    transition: reduce ? { duration: 0.12 } : spring.snappy,\n  };\n\n  return (\n    <Combobox.Root<MultiSelectOption, true>\n      multiple\n      items={selectAll ? [ALL, ...options] : options}\n      filteredItems={filtered}\n      value={selected}\n      onValueChange={(next) => {\n        if (next.includes(ALL)) {\n          const pickableIds = pickable.map((o) => o.value);\n          setIds(allState === \"checked\" ? ids.filter((id) => !pickableIds.includes(id)) : [...ids, ...pickableIds.filter((id) => !ids.includes(id))]);\n          return;\n        }\n        setIds(next.map((o) => o.value));\n      }}\n      inputValue={query}\n      onInputValueChange={(next, details) => {\n        // Keep the search after a pick so several results can be chosen from one query.\n        if (details.reason === \"input-clear\" && details.isItemPress) return details.cancel();\n        setQuery(next);\n      }}\n      onOpenChangeComplete={(open) => !open && setQuery(\"\")}\n      isItemEqualToValue={(a, b) => a.value === b.value}\n      itemToStringLabel={(o) => o.label}\n      itemToStringValue={(o) => o.value}\n      disabled={disabled}\n      name={name}\n    >\n      <div className={cn(\"flex w-full min-w-0 flex-col gap-1.5\", className)}>\n        {label && <Combobox.Label className=\"w-fit cursor-default text-[12.5px] font-medium text-fg-2 select-none\">{label}</Combobox.Label>}\n        <div className=\"group/field relative\">\n          <Combobox.Trigger\n            aria-label={label ? undefined : ariaLabel}\n            aria-describedby={selected.length ? describedBy : undefined}\n            aria-invalid={invalid || undefined}\n            className={cn(\n              \"flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-line-2 bg-raised pr-2 pl-1 text-left text-[13px] text-fg shadow-[var(--shadow)] select-none\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              \"transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.99] active:duration-75\",\n              \"hover:border-fg-4 data-popup-open:border-fg-4\",\n              \"aria-invalid:border-danger/70 data-disabled:pointer-events-none data-disabled:opacity-50\",\n              \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1.5\",\n              selected.length ? \"pr-[3.25rem]\" : \"pl-2.5\",\n            )}\n          >\n            <span ref={rowRef} className=\"relative flex h-full min-w-0 flex-1 items-center gap-1 overflow-hidden\">\n              {selected.length === 0 && <span className=\"truncate text-fg-4\">{placeholder}</span>}\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {shown.map((o, i) => (\n                  <motion.span key={o.value} layout={!reduce} {...chipMotion} className={cn(i === 0 ? \"min-w-0 shrink\" : \"shrink-0\", \"flex\")}>\n                    <Chip option={o} />\n                  </motion.span>\n                ))}\n                {overflow > 0 && (\n                  <motion.span key=\"more\" layout={!reduce} {...chipMotion} className=\"flex h-[22px] shrink-0 items-center rounded-[5px] bg-line px-1.5 text-[12px] text-fg-2\">\n                    +<NumberFlow value={overflow} className=\"tabular\" />\n                  </motion.span>\n                )}\n              </AnimatePresence>\n              {/* Every chip laid out off-screen, so the row knows how many fit before it shows them. */}\n              <span ref={measureRef} aria-hidden className=\"pointer-events-none invisible absolute top-0 left-0 flex h-full items-center gap-1\">\n                {selected.map((o) => (\n                  <span key={o.value} className=\"flex shrink-0\">\n                    <Chip option={o} />\n                  </span>\n                ))}\n                <span className=\"flex h-[22px] shrink-0 items-center rounded-[5px] px-1.5 text-[12px] tabular\">+{selected.length}</span>\n              </span>\n            </span>\n            <ChevronsUpDown size={16} className=\"absolute right-2 shrink-0 text-fg-3 transition-colors group-hover/field:text-fg-2\" />\n          </Combobox.Trigger>\n          <span id={describedBy} className=\"sr-only\">\n            {selected.length} selected: {selected.map((o) => o.label).join(\", \")}\n          </span>\n\n          <AnimatePresence initial={false}>\n            {selected.length > 0 && !disabled && (\n              <motion.button\n                type=\"button\"\n                aria-label={`Clear ${noun}`}\n                onClick={() => setIds([])}\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.6, transition: { duration: 0.1 } }}\n                transition={spring.pop}\n                className={cn(\n                  \"absolute top-1/2 right-7 -mt-2.5 grid size-5 place-items-center rounded-md text-fg-3 hover:bg-hover hover:text-fg\",\n                  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                  \"transition-colors active:scale-[0.9]\",\n                  \"after:absolute after:-inset-2.5 after:content-['']\",\n                )}\n              >\n                <X size={14} />\n              </motion.button>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <Combobox.Portal container={container}>\n        <Combobox.Positioner align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <Combobox.Popup\n            aria-label={typeof label === \"string\" ? label : ariaLabel}\n            className={cn(\n              \"flex w-(--anchor-width) max-w-(--available-width) min-w-64 origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <div className=\"flex h-10 shrink-0 items-center gap-2 border-b border-line px-3\">\n              <Search size={14} className=\"shrink-0 text-fg-3\" />\n              <Combobox.Input\n                aria-label={searchPlaceholder}\n                placeholder={searchPlaceholder}\n                onKeyDown={(e) => {\n                  // Backspace in an empty search takes back the last pick.\n                  if (e.key === \"Backspace\" && query === \"\" && ids.length) setIds(ids.slice(0, -1));\n                }}\n                className=\"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n              />\n            </div>\n\n            <Combobox.Empty className=\"empty:hidden\">\n              <div className=\"flex flex-col items-center gap-2 px-3 py-5 text-center text-[12.5px] text-fg-3\">\n                <p className=\"max-w-full truncate\">\n                  No {noun} match <span className=\"text-fg-2\">“{query.trim()}”</span>\n                </p>\n                <button\n                  type=\"button\"\n                  onClick={() => setQuery(\"\")}\n                  className=\"h-6 rounded-md border border-line-2 px-2 text-[12px] font-medium text-fg-2 transition-[background-color,color,scale] duration-150 outline-none hover:bg-hover hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 active:scale-[0.96]\"\n                >\n                  Clear search\n                </button>\n              </div>\n            </Combobox.Empty>\n\n            <Combobox.List ref={setList} className=\"relative max-h-[min(calc(var(--available-height)-6rem),16.5rem)] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none empty:hidden\">\n              <motion.div aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line\" />\n              <Combobox.Collection>\n              {(o: MultiSelectOption) =>\n                o === ALL ? (\n                  <Combobox.Item key={o.value} value={o} className={cn(itemClass, \"mb-1 after:absolute after:inset-x-1 after:-bottom-[3px] after:h-px after:bg-line\")}>\n                    <Box state={allState} reduce={reduce} />\n                    <span className=\"min-w-0 flex-1 truncate\">{allState === \"checked\" ? \"Deselect all\" : q ? `Select all ${pickable.length} matches` : \"Select all\"}</span>\n                    <span className=\"font-mono text-2xs text-fg-4 tabular\">{pickable.length}</span>\n                  </Combobox.Item>\n                ) : (\n                  <Combobox.Item key={o.value} value={o} disabled={o.disabled} className={itemClass}>\n                    <Box state={ids.includes(o.value) ? \"checked\" : \"unchecked\"} reduce={reduce} />\n                    {o.icon && <span className=\"flex shrink-0 text-fg-3\">{o.icon}</span>}\n                    <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n                      <span className=\"truncate\">{o.label}</span>\n                      {o.description && <span className=\"min-w-0 truncate text-[12px] text-fg-3\">{o.description}</span>}\n                    </span>\n                  </Combobox.Item>\n                )\n              }\n              </Combobox.Collection>\n            </Combobox.List>\n\n            <div className=\"flex h-9 shrink-0 items-center justify-between gap-2 border-t border-line pr-1.5 pl-3 text-[12px] text-fg-3\">\n              <span className=\"tabular\">\n                <NumberFlow value={selected.length} className=\"text-fg-2\" /> of {options.length} selected\n              </span>\n              <button\n                type=\"button\"\n                disabled={!selected.length}\n                onClick={() => setIds([])}\n                className={cn(\n                  \"h-6 rounded-md px-2 text-[12px] font-medium text-fg-2 hover:bg-hover hover:text-fg disabled:pointer-events-none disabled:opacity-40\",\n                  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                  \"transition-[background-color,color,scale] duration-150 active:scale-[0.96]\",\n                )}\n              >\n                Clear\n              </button>\n            </div>\n          </Combobox.Popup>\n        </Combobox.Positioner>\n      </Combobox.Portal>\n    </Combobox.Root>\n  );\n}\n\nconst itemClass = cn(\n  \"group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2.5 rounded-lg px-2 text-[13px] text-fg outline-none select-none pointer-coarse:h-10\",\n  \"data-disabled:text-fg-4 data-disabled:[&_span]:text-fg-4\",\n);\n\nfunction Chip({ option }: { option: MultiSelectOption }) {\n  return (\n    <span className=\"flex h-[22px] min-w-0 items-center gap-1 rounded-[5px] border border-line-2 bg-frame px-1.5 text-[12px] leading-none text-fg\">\n      {option.icon && <span className=\"flex shrink-0 [&>*]:size-3.5\">{option.icon}</span>}\n      <span className=\"min-w-0 truncate\">{option.label}</span>\n    </span>\n  );\n}\n\n// A checkbox that squashes a little on press and draws its tick when it turns on.\nfunction Box({ state, reduce }: { state: \"checked\" | \"unchecked\" | \"mixed\"; reduce: boolean }) {\n  const on = state !== \"unchecked\";\n  return (\n    <span\n      aria-hidden\n      data-state={state}\n      className={cn(\n        \"grid size-3.5 shrink-0 place-items-center rounded-[4px] border transition-[background-color,border-color,scale] duration-150 ease-out group-active/item:scale-[0.88]\",\n        on ? \"border-fg bg-fg text-frame\" : \"border-line-2 bg-frame group-data-highlighted/item:border-fg-4\",\n      )}\n    >\n      <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        {state === \"mixed\" ? (\n          <path d=\"M2.5 5h5\" />\n        ) : (\n          <motion.path\n            d=\"M2 5.2 4.1 7.2 8 2.8\"\n            initial={false}\n            animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}\n            transition={reduce ? { duration: 0 } : { pathLength: { duration: 0.2, ease: [0.16, 1, 0.3, 1] }, opacity: { duration: 0.05 } }}\n          />\n        )}\n      </svg>\n    </span>\n  );\n}\n\n// Works out how many chips fit on one line beside a \"+N\" badge, from an invisible copy of the row.\nfunction useFit(key: string) {\n  const rowRef = useRef<HTMLSpanElement>(null);\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [fit, setFit] = useState(99);\n\n  useEffect(() => {\n    const row = rowRef.current;\n    const measure = measureRef.current;\n    if (!row || !measure) return;\n    let frame = 0;\n    const compute = () => {\n      const items = [...measure.children] as HTMLElement[];\n      const badge = items.pop();\n      const available = row.clientWidth;\n      const gap = 4;\n      let used = 0;\n      let n = 0;\n      for (let i = 0; i < items.length; i++) {\n        const width = items[i].offsetWidth + (i ? gap : 0);\n        const reserve = i < items.length - 1 ? gap + (badge?.offsetWidth ?? 0) : 0;\n        if (used + width + reserve > available) break;\n        used += width;\n        n = i + 1;\n      }\n      // The first chip always shows, truncating if it must, so the field never reads as empty.\n      setFit(Math.max(1, n));\n    };\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(compute);\n    };\n    const observer = new ResizeObserver(schedule);\n    observer.observe(row);\n    observer.observe(measure);\n    schedule();\n    return () => {\n      observer.disconnect();\n      cancelAnimationFrame(frame);\n    };\n  }, [key]);\n\n  return { rowRef, measureRef, fit };\n}\n\n// One highlight for the list: glides after the pointer, jumps for arrow keys.\nfunction useGlide(reduce: boolean) {\n  const [list, setList] = useState<HTMLDivElement | null>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(32);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    if (!list) return;\n    let keyboard = false;\n    let shown = false;\n    let running: AnimationPlaybackControls[] = [];\n    const stop = () => {\n      running.forEach((c) => c.stop());\n      running = [];\n    };\n    const place = () => {\n      const el = list.querySelector<HTMLElement>(\"[data-highlighted]\");\n      stop();\n      if (!el) {\n        shown = false;\n        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));\n        return;\n      }\n      const target = el.hasAttribute(\"data-disabled\") ? 0.45 : 1;\n      let top = 0;\n      for (let n: HTMLElement | null = el; n && n !== list; n = n.offsetParent as HTMLElement | null) top += n.offsetTop;\n      if (!shown || keyboard || reduce) {\n        y.jump(top);\n        height.jump(el.offsetHeight);\n        if (keyboard || reduce) opacity.jump(target);\n        else running.push(animate(opacity, target, { duration: 0.08 }));\n      } else {\n        running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));\n        opacity.jump(target);\n      }\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onPointer = () => (keyboard = false);\n    const observer = new MutationObserver(place);\n    observer.observe(list, { subtree: true, childList: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    document.addEventListener(\"keydown\", onKey, true);\n    list.addEventListener(\"pointermove\", onPointer);\n    place();\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"keydown\", onKey, true);\n      list.removeEventListener(\"pointermove\", onPointer);\n      stop();\n    };\n  }, [list, reduce, y, height, opacity]);\n\n  return { setList, y, height, opacity };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/multi-select"
}
