{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "type": "registry:ui",
  "title": "Multi-select",
  "description": "Select several values while keeping the field readable.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/multi-select/multi-select.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { ChevronDown, X } from \"lucide-react\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface MultiSelectOption {\n  value: string;\n  label: string;\n  disabled?: boolean;\n}\n\nexport interface MultiSelectProps {\n  label: string;\n  options: MultiSelectOption[];\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  placeholder?: string;\n  description?: string;\n  maxVisible?: number;\n  disabled?: boolean;\n  className?: string;\n}\n\n/* The trigger anchors the menu, so press feedback stays in colour; it never scales. */\nconst triggerClass =\n  \"group/trigger relative flex min-h-control-md w-full items-center gap-2 rounded-control border border-line bg-surface py-[5px] pr-[11px] pl-3 text-left text-(length:--text-sm) text-ink [transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard),box-shadow_var(--duration-fast)_var(--ease-standard)] enabled:hover:border-line-strong enabled:hover:bg-hover enabled:active:border-line-strong enabled:active:bg-hover aria-expanded:border-line-strong aria-expanded:bg-hover disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none\";\n/* The slot clips while its width springs open or closed; the chip keeps its natural width so the label never re-truncates mid-flight. */\nconst slotClass = \"flex min-w-0 flex-[0_1_auto] overflow-hidden\";\nconst chipClass =\n  \"mr-[5px] box-border max-w-36 flex-[0_0_auto] overflow-hidden rounded-full bg-hover px-2 py-1 text-(length:--text-xs) text-ellipsis whitespace-nowrap text-ink shadow-[inset_0_0_0_1px_var(--line-soft)] transition-[background-color] duration-fast ease-standard group-aria-expanded/trigger:bg-surface group-hover/trigger:group-enabled/trigger:bg-surface motion-reduce:transition-none\";\n/* Arrow keys move the highlight often, so it changes almost instantly. Selection is shown by the check, not by weight, so labels never reflow. */\nconst optionClass =\n  \"flex min-h-[38px] w-full items-center justify-between gap-3 rounded-[calc(var(--radius-panel)-5px)] bg-transparent px-[11px] text-left text-(length:--text-sm) text-ink [transition:background-color_80ms_var(--ease-standard),color_80ms_var(--ease-standard)] data-[active=true]:bg-hover disabled:cursor-not-allowed disabled:opacity-[.42] motion-reduce:transition-none [&>span:first-child]:min-w-0 [&>span:first-child]:truncate\";\n\nconst enter = motionTokens.ease.enter;\nconst standard = motionTokens.ease.standard;\n/** Each chip sits in a slot whose width opens and collapses on a spring, so neighbours travel with it and nothing overlaps. */\nconst slot: Variants = {\n  hidden: { width: 0, opacity: 0 },\n  shown: {\n    width: \"auto\",\n    opacity: 1,\n    transition: { width: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: enter } },\n  },\n  gone: {\n    width: 0,\n    opacity: 0,\n    transition: {\n      width: motionTokens.spring.smooth,\n      opacity: { duration: motionTokens.duration.instant, ease: standard },\n    },\n  },\n};\n/** The chip itself grows in from .9 with a soft blur and shrinks back as it leaves. */\nconst chip: Variants = {\n  hidden: { scale: 0.9, filter: `blur(${motionTokens.blur.soft}px)` },\n  shown: {\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: { ...motionTokens.spring.snappy, filter: { duration: motionTokens.duration.standard, ease: enter } },\n  },\n  gone: {\n    scale: 0.9,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.instant, ease: standard },\n  },\n};\n/** Reduced motion keeps short crossfades; resting states match the moving variants so server and client markup agree. */\nconst fade: Variants = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n  gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } },\n};\nconst slotFade: Variants = {\n  hidden: { opacity: 0 },\n  shown: { width: \"auto\", opacity: 1, transition: { duration: motionTokens.duration.instant } },\n  gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } },\n};\nconst chipStill: Variants = {\n  hidden: { scale: 1, filter: \"blur(0px)\" },\n  shown: { scale: 1, filter: \"blur(0px)\" },\n  gone: { scale: 1, filter: \"blur(0px)\" },\n};\n/** The overflow count rolls: a larger number rises from below, a smaller one drops from above. */\nconst roll: Variants = {\n  hidden: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * 0.5}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n  }),\n  shown: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: motionTokens.duration.standard, ease: enter },\n  },\n  gone: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * -0.5}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.instant, ease: standard },\n  }),\n};\n\n/** A check that draws itself when an option is picked and retracts when it is removed. */\nfunction CheckMark({ reduce }: { reduce: boolean | null }) {\n  return (\n    <svg\n      className=\"flex-[0_0_auto]\"\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.75\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <motion.path\n        d=\"M4 12.5 9 17.5 20 6.5\"\n        initial={reduce ? { opacity: 0 } : { pathLength: 0, opacity: 0 }}\n        animate={{ pathLength: 1, opacity: 1 }}\n        exit={reduce ? { opacity: 0 } : { pathLength: 0, opacity: 0 }}\n        transition={\n          reduce\n            ? { duration: motionTokens.duration.instant }\n            : {\n                pathLength: { duration: motionTokens.duration.standard, ease: enter },\n                opacity: { duration: motionTokens.duration.instant },\n              }\n        }\n      />\n    </svg>\n  );\n}\n\nexport function MultiSelect({\n  label,\n  options,\n  value,\n  defaultValue = [],\n  onValueChange,\n  placeholder = \"Select options\",\n  description,\n  maxVisible = 2,\n  disabled = false,\n  className,\n}: MultiSelectProps) {\n  const id = useId();\n  const labelId = `${id}-label`;\n  const valueId = `${id}-value`;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [internal, setInternal] = useState(defaultValue);\n  const [open, setOpen] = useState(false);\n  const [activeIndex, setActiveIndex] = useState(-1);\n  const selected = value ?? internal;\n  const selectedSet = useMemo(() => new Set(selected), [selected]);\n  const labelFor = (item: string) => options.find((option) => option.value === item)?.label ?? item;\n  const visible = selected.slice(0, maxVisible).map((item) => ({ value: item, label: labelFor(item) }));\n  const remaining = Math.max(0, selected.length - visible.length);\n  // A closed menu forgets its highlight, so the next open starts clean instead of on a stale hovered row.\n  const [wasOpen, setWasOpen] = useState(open);\n  if (wasOpen !== open) {\n    setWasOpen(open);\n    if (!open) setActiveIndex(-1);\n  }\n  const [previousRemaining, setPreviousRemaining] = useState(remaining);\n  const [countDirection, setCountDirection] = useState(1);\n  if (previousRemaining !== remaining) {\n    setPreviousRemaining(remaining);\n    setCountDirection(remaining > previousRemaining ? 1 : -1);\n  }\n\n  useEffect(() => {\n    const close = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", close);\n    return () => document.removeEventListener(\"pointerdown\", close);\n  }, []);\n\n  const update = (next: string[]) => {\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n  const toggle = (option: MultiSelectOption) => {\n    if (disabled || option.disabled) return;\n    update(\n      selectedSet.has(option.value) ? selected.filter((item) => item !== option.value) : [...selected, option.value],\n    );\n  };\n  const clear = () => {\n    update([]);\n    setOpen(false);\n  };\n  const enabled = options.map((option, index) => (option.disabled ? -1 : index)).filter((index) => index >= 0);\n  const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (disabled) return;\n    if (event.key === \"Enter\" && open && activeIndex >= 0) {\n      event.preventDefault();\n      toggle(options[activeIndex]!);\n      return;\n    }\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      setOpen((current) => !current);\n      return;\n    }\n    if (event.key === \"Escape\") {\n      setOpen(false);\n      return;\n    }\n    if ((event.key === \"ArrowDown\" || event.key === \"ArrowUp\") && enabled.length) {\n      event.preventDefault();\n      setOpen(true);\n      const current = enabled.indexOf(activeIndex);\n      const next =\n        current < 0\n          ? event.key === \"ArrowDown\"\n            ? 0\n            : enabled.length - 1\n          : event.key === \"ArrowDown\"\n            ? (current + 1) % enabled.length\n            : (current - 1 + enabled.length) % enabled.length;\n      setActiveIndex(enabled[next]!);\n    }\n  };\n  const reduce = useReducedMotion();\n\n  return (\n    <div ref={rootRef} className={cn(\"relative grid min-w-0 gap-2\", className)}>\n      <span id={labelId} className=\"text-sm leading-body font-medium\">\n        {label}\n      </span>\n      <div className=\"relative min-w-0\">\n        <button\n          type=\"button\"\n          className={triggerClass}\n          disabled={disabled}\n          aria-labelledby={`${labelId} ${valueId}`}\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          aria-controls={`${id}-listbox`}\n          onClick={() => setOpen((current) => !current)}\n          onKeyDown={onKeyDown}\n        >\n          <span id={valueId} className=\"sr-only\">\n            {selected.length ? selected.map(labelFor).join(\", \") : placeholder}\n          </span>\n          <span className=\"relative flex min-h-[26px] min-w-0 flex-1 items-center overflow-hidden\" aria-hidden=\"true\">\n            <AnimatePresence initial={false}>\n              {visible.map((item) => (\n                <motion.span\n                  key={`chip-${item.value}`}\n                  className={slotClass}\n                  variants={reduce ? slotFade : slot}\n                  initial=\"hidden\"\n                  animate=\"shown\"\n                  exit=\"gone\"\n                >\n                  <motion.span className={chipClass} variants={reduce ? chipStill : chip}>\n                    {item.label}\n                  </motion.span>\n                </motion.span>\n              ))}\n              {remaining > 0 && (\n                <motion.span\n                  key=\"more\"\n                  className={cn(slotClass, \"shrink-0\")}\n                  variants={reduce ? slotFade : slot}\n                  initial=\"hidden\"\n                  animate=\"shown\"\n                  exit=\"gone\"\n                >\n                  <motion.span\n                    className={cn(chipClass, \"inline-flex text-ink-2 tabular-nums\")}\n                    variants={reduce ? chipStill : chip}\n                  >\n                    +\n                    <span className=\"inline-grid *:[grid-area:1/1]\">\n                      <AnimatePresence initial={false} custom={countDirection}>\n                        <motion.span\n                          key={remaining}\n                          custom={countDirection}\n                          variants={reduce ? fade : roll}\n                          initial=\"hidden\"\n                          animate=\"shown\"\n                          exit=\"gone\"\n                        >\n                          {remaining}\n                        </motion.span>\n                      </AnimatePresence>\n                    </span>\n                  </motion.span>\n                </motion.span>\n              )}\n              {!selected.length && (\n                <motion.span\n                  key=\"placeholder\"\n                  className=\"absolute inset-0 flex items-center overflow-hidden whitespace-nowrap text-ink-3\"\n                  variants={fade}\n                  initial=\"hidden\"\n                  animate=\"shown\"\n                  exit=\"gone\"\n                >\n                  {placeholder}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </span>\n          {/* The chevron stays pinned to the trailing edge; a selection only reserves room for the clear button beside it, so nothing jumps. */}\n          <ChevronDown\n            className={cn(\n              \"flex-[0_0_auto] text-ink-3 transition-transform duration-spring ease-spring motion-reduce:transition-none\",\n              selected.length > 0 && \"ml-[26px]\",\n              open && \"rotate-180\",\n            )}\n            size={16}\n            aria-hidden=\"true\"\n          />\n        </button>\n        <AnimatePresence initial={false}>\n          {selected.length > 0 && !disabled && (\n            <motion.button\n              type=\"button\"\n              aria-label=\"Clear selections\"\n              className=\"absolute top-0 right-9 bottom-0 my-auto grid size-[22px] place-items-center rounded-button border-0 bg-transparent text-ink-3 [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard)] hover:bg-surface hover:text-ink motion-reduce:transition-none\"\n              onClick={clear}\n              initial={\n                reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }\n              }\n              animate={{\n                opacity: 1,\n                scale: 1,\n                filter: \"blur(0px)\",\n                transition: reduce\n                  ? { duration: motionTokens.duration.instant }\n                  : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } },\n              }}\n              exit={{\n                opacity: 0,\n                ...(reduce ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }),\n                transition: { duration: motionTokens.duration.instant, ease: standard },\n              }}\n              whileTap={{\n                scale: reduce ? 1 : 0.96,\n                transition: { duration: motionTokens.duration.instant, ease: standard },\n              }}\n            >\n              <X size={14} aria-hidden=\"true\" />\n            </motion.button>\n          )}\n        </AnimatePresence>\n        <AnimatePresence initial={false}>\n          {open && (\n            <motion.div\n              id={`${id}-listbox`}\n              className=\"absolute top-[calc(100%+8px)] right-0 left-0 z-80 origin-top overflow-hidden rounded-panel border border-line bg-surface p-[5px] shadow-floating will-change-[transform,opacity]\"\n              role=\"listbox\"\n              aria-label={label}\n              aria-multiselectable=\"true\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}\n              animate={{\n                opacity: 1,\n                y: 0,\n                scale: 1,\n                transition: reduce\n                  ? { duration: motionTokens.duration.instant }\n                  : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } },\n              }}\n              exit={{\n                opacity: 0,\n                ...(reduce ? {} : { y: -4, scale: 0.98 }),\n                transition: { duration: motionTokens.duration.instant, ease: standard },\n              }}\n            >\n              {options.map((option, index) => (\n                <button\n                  type=\"button\"\n                  role=\"option\"\n                  aria-selected={selectedSet.has(option.value)}\n                  aria-disabled={option.disabled || undefined}\n                  key={option.value}\n                  className={optionClass}\n                  data-active={activeIndex === index}\n                  disabled={option.disabled}\n                  onPointerMove={() => {\n                    if (activeIndex !== index) setActiveIndex(index);\n                  }}\n                  onClick={() => toggle(option)}\n                >\n                  <span>{option.label}</span>\n                  <AnimatePresence initial={false}>\n                    {selectedSet.has(option.value) && <CheckMark key=\"check\" reduce={reduce} />}\n                  </AnimatePresence>\n                </button>\n              ))}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n      {description && <span className=\"text-(length:--text-xs) text-ink-3\">{description}</span>}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/multi-select/multi-select.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/multi-select",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "MultiSelect",
    "tags": [
      "field",
      "choice"
    ],
    "docs": "https://irisui.dev/components/multi-select"
  }
}
