{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/multi-select/multi-select.tsx","type":"registry:component","target":"@components/arc/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 styles from \"./multi-select.module.css\";\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\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: { width: \"auto\", opacity: 1, transition: { width: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: enter } } },\n gone: { width: 0, opacity: 0, transition: { width: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant, ease: standard } } },\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: { scale: 1, filter: \"blur(0px)\", transition: { ...motionTokens.spring.snappy, filter: { duration: motionTokens.duration.standard, ease: enter } } },\n gone: { scale: 0.9, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },\n};\n/** Reduced motion keeps short crossfades; resting states match the moving variants so server and client markup agree. */\nconst fade: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };\nconst slotFade: Variants = { hidden: { opacity: 0 }, shown: { width: \"auto\", opacity: 1, transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };\nconst chipStill: Variants = { hidden: { scale: 1, filter: \"blur(0px)\" }, shown: { scale: 1, filter: \"blur(0px)\" }, gone: { scale: 1, filter: \"blur(0px)\" } };\n/** The overflow count rolls: a larger number rises from below, a smaller one drops from above. */\nconst roll: Variants = {\n hidden: (direction: number) => ({ opacity: 0, y: `${direction * 0.5}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),\n shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: enter } },\n gone: (direction: number) => ({ opacity: 0, y: `${direction * -0.5}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } }),\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 \n ;\n}\n\nexport function MultiSelect({ label, options, value, defaultValue = [], onValueChange, placeholder = \"Select options\", description, maxVisible = 2, disabled = false, className }: MultiSelectProps) {\n const id = useId();\n const labelId = `${id}-label`;\n const valueId = `${id}-value`;\n const rootRef = useRef(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) { setWasOpen(open); if (!open) setActiveIndex(-1); }\n const [previousRemaining, setPreviousRemaining] = useState(remaining);\n const [countDirection, setCountDirection] = useState(1);\n if (previousRemaining !== remaining) { setPreviousRemaining(remaining); setCountDirection(remaining > previousRemaining ? 1 : -1); }\n\n useEffect(() => {\n const close = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); };\n document.addEventListener(\"pointerdown\", close);\n return () => document.removeEventListener(\"pointerdown\", close);\n }, []);\n\n const update = (next: string[]) => { if (value === undefined) setInternal(next); onValueChange?.(next); };\n const toggle = (option: MultiSelectOption) => {\n if (disabled || option.disabled) return;\n update(selectedSet.has(option.value) ? selected.filter((item) => item !== option.value) : [...selected, option.value]);\n };\n const clear = () => { update([]); setOpen(false); };\n const enabled = options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0);\n const onKeyDown = (event: React.KeyboardEvent) => {\n if (disabled) return;\n if (event.key === \"Enter\" && open && activeIndex >= 0) { event.preventDefault(); toggle(options[activeIndex]); return; }\n if (event.key === \"Enter\" || event.key === \" \") { event.preventDefault(); setOpen((current) => !current); return; }\n if (event.key === \"Escape\") { setOpen(false); return; }\n if ((event.key === \"ArrowDown\" || event.key === \"ArrowUp\") && enabled.length) {\n event.preventDefault(); setOpen(true);\n const current = enabled.indexOf(activeIndex);\n const next = current < 0 ? (event.key === \"ArrowDown\" ? 0 : enabled.length - 1) : event.key === \"ArrowDown\" ? (current + 1) % enabled.length : (current - 1 + enabled.length) % enabled.length;\n setActiveIndex(enabled[next]);\n }\n };\n const reduce = useReducedMotion();\n\n return
\n {label}\n
\n {selected.length > 0 && !disabled && }\n \n {open && \n {options.map((option, index) => )}\n }\n \n
\n {description && {description}}\n
;\n}\n"},{"path":"registry/components/multi-select/multi-select.module.css","type":"registry:component","target":"@components/arc/multi-select/multi-select.module.css","content":".field { position: relative; display: grid; gap: var(--space-2); min-width: 0; }\n.control { position: relative; min-width: 0; }\n.label { font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n/* The trigger anchors the menu, so press feedback stays in color; it never scales. */\n.trigger { position: relative; display: flex; width: 100%; min-height: var(--control-height-md); align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: var(--radius-control); padding: 5px 11px 5px 12px; background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; 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); }\n@media (hover: hover) and (pointer: fine) { .trigger:hover:not(:disabled) { border-color: var(--border-strong); background: var(--surface-muted); } }\n.trigger:active:not(:disabled), .trigger[aria-expanded=\"true\"] { border-color: var(--border-strong); background: var(--surface-muted); }\n.trigger:disabled { opacity: .5; cursor: not-allowed; }\n/* The chevron stays pinned to the trailing edge; a selection only reserves room for the clear button beside it, so nothing jumps. */\n.hasClear .chevron { margin-left: 26px; }\n.value { position: relative; display: flex; min-width: 0; min-height: 26px; flex: 1; align-items: center; overflow: hidden; }\n.placeholder, .description { color: var(--text-muted); }\n/* The placeholder sits outside the flow so the first chip can open its slot from the leading edge. */\n.placeholder { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; white-space: nowrap; }\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. */\n.slot { display: flex; flex: 0 1 auto; min-width: 0; overflow: hidden; }\n.slot:has(.more) { flex-shrink: 0; }\n.chip, .more { box-sizing: border-box; flex: 0 0 auto; max-width: 9rem; overflow: hidden; margin-right: 5px; border-radius: var(--radius-pill); padding: 4px 8px; background: var(--surface-muted); box-shadow: inset 0 0 0 1px var(--border-subtle); color: var(--foreground); text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-xs); transition: background-color var(--duration-fast) var(--ease-standard); }\n.trigger[aria-expanded=\"true\"] .chip, .trigger[aria-expanded=\"true\"] .more { background: var(--surface); }\n@media (hover: hover) and (pointer: fine) { .trigger:hover:not(:disabled) .chip, .trigger:hover:not(:disabled) .more { background: var(--surface); } }\n.more { display: inline-flex; color: var(--text-secondary); font-variant-numeric: tabular-nums; }\n.count { display: inline-grid; }\n.count > span { grid-area: 1 / 1; }\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n.clear { position: absolute; top: 0; bottom: 0; right: 36px; display: grid; width: 22px; height: 22px; margin: auto 0; place-items: center; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .clear:hover { background: var(--surface); color: var(--foreground); } }\n.chevron { flex: 0 0 auto; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring); }\n.trigger[aria-expanded=\"true\"] .chevron { transform: rotate(180deg); }\n.description { font-size: var(--text-xs); }\n.menu { position: absolute; z-index: 80; top: calc(100% + 8px); right: 0; left: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); padding: 5px; background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: top center; will-change: transform, opacity; }\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. */\n.option { display: flex; width: 100%; min-height: 38px; align-items: center; justify-content: space-between; gap: 12px; border: 0; border-radius: calc(var(--radius-panel) - 5px); padding: 0 11px; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; transition: background-color 80ms var(--ease-standard), color 80ms var(--ease-standard); }\n.option > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.option[data-active=\"true\"] { background: var(--surface-muted); }\n.option:disabled { opacity: .42; cursor: not-allowed; }\n.check { flex: 0 0 auto; }\n@media (prefers-reduced-motion: reduce) { .trigger, .chevron, .option, .clear, .chip, .more { transition: none; } }\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/multi-select","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/multi-select","markdown":"https://uiarc.dev/components/multi-select/markdown","tags":["field","choice"]}}