Multi Select
A dropdown with a filter field and checkboxes; picks collapse into chips with a +N overflow counter.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/multi-select.json'use client'
import { useId, useRef, useState } from 'react'
import './multi-select.css'
export interface MultiSelectProps {
options: string[]
value?: string[]
defaultValue?: string[]
onValueChange?: (v: string[]) => void
placeholder?: string
maxChips?: number
label?: string
fill?: 'dark' | 'light'
className?: string
}
/** Filterable multi-select with chips. */
export function MultiSelect({ options, value, defaultValue = [], onValueChange, placeholder = 'Add labels…', maxChips = 2, label = 'Labels', fill = 'dark', className }: MultiSelectProps) {
const [inner, setInner] = useState(defaultValue)
const v = value ?? inner
const [open, setOpen] = useState(false)
const [q, setQ] = useState('')
const [act, setAct] = useState(0)
const input = useRef<HTMLInputElement>(null)
const id = useId()
const set = (n: string[]) => { setInner(n); onValueChange?.(n) }
const toggle = (o: string) => set(v.includes(o) ? v.filter((x) => x !== o) : [...v, o])
const list = options.filter((o) => o.toLowerCase().includes(q.toLowerCase())).sort((a, b) => Number(v.includes(b)) - Number(v.includes(a)))
return (
<div className={['sd-mls', className].filter(Boolean).join(' ')} data-fill={fill} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false) }}>
<div className="sd-mls__f" data-open={open || undefined} onClick={() => { input.current?.focus(); setOpen(true) }}>
{v.slice(0, maxChips).map((o) => (
<span key={o} className="sd-mls__chip"><span>{o}</span><button type="button" aria-label={`Remove ${o}`} onClick={(e) => { e.stopPropagation(); toggle(o) }}><svg viewBox="0 0 8 8" aria-hidden><path d="m1.5 1.5 5 5m0-5-5 5" /></svg></button></span>
))}
{v.length > maxChips && <span className="sd-mls__more" aria-label={`${v.length - maxChips} more selected`}>+{v.length - maxChips}</span>}
<input
ref={input}
className="sd-mls__in"
role="combobox"
aria-label={label}
aria-expanded={open}
aria-controls={`${id}-l`}
aria-activedescendant={open && list[act] ? `${id}-${act}` : undefined}
placeholder={v.length ? '' : placeholder}
value={q}
onFocus={() => setOpen(true)}
onChange={(e) => { setQ(e.target.value); setAct(0); setOpen(true) }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') (e.preventDefault(), setOpen(true), setAct((a) => Math.min(list.length - 1, a + 1)))
else if (e.key === 'ArrowUp') (e.preventDefault(), setAct((a) => Math.max(0, a - 1)))
else if (e.key === 'Enter' && list[act]) (e.preventDefault(), toggle(list[act]))
else if (e.key === 'Backspace' && !q && v.length) set(v.slice(0, -1))
else if (e.key === 'Escape') setOpen(false)
}}
/>
<svg className="sd-mls__caret" viewBox="0 0 12 12" aria-hidden><path d="m3 4.5 3 3 3-3" /></svg>
</div>
{open && (
<div className="sd-mls__pop">
<div className="sd-mls__list" role="listbox" id={`${id}-l`} aria-multiselectable="true" aria-label={label}>
{list.map((o, i) => (
<div key={o} id={`${id}-${i}`} role="option" aria-selected={v.includes(o)} className="sd-mls__opt" data-active={i === act || undefined} onMouseEnter={() => setAct(i)} onMouseDown={(e) => { e.preventDefault(); toggle(o) }}>
<span className="sd-mls__box" aria-hidden>{v.includes(o) && <svg viewBox="0 0 10 10"><path d="m2 5.5 2 2 4-5" /></svg>}</span>{o}
</div>
))}
{!list.length && <div className="sd-mls__none">No matches</div>}
</div>
<div className="sd-mls__foot">
<button type="button" onMouseDown={(e) => { e.preventDefault(); set([...new Set([...v, ...list])]) }}>Select all</button>
<span aria-live="polite">{v.length} selected</span>
<button type="button" onMouseDown={(e) => { e.preventDefault(); set([]) }}>Clear</button>
</div>
</div>
)}
</div>
)
}.sd-mls { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-mls[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-mls button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-mls :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-mls .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-mls { position: relative; width: min(100%, 320px); align-self: start; margin-top: 30px; }
.sd-mls__f { display: flex; align-items: center; gap: 5px; min-height: 44px; padding: 5px 34px 5px 6px; border-radius: 12px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); cursor: text; position: relative; flex-wrap: nowrap; overflow: hidden; }
.sd-mls__f[data-open] { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-mls__chip { display: inline-flex; align-items: center; gap: 4px; flex: none; height: 28px; padding: 0 4px 0 9px; border-radius: 8px; background: var(--soft); font-size: 12.5px; font-weight: 500; animation: sd-mls-pop 220ms cubic-bezier(0.3, 1.6, 0.4, 1); max-width: 110px; }
.sd-mls__chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-mls__chip button { display: grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; color: var(--mute); flex: none; }
.sd-mls__chip button:hover { color: var(--fg); background: var(--soft) !important; }
.sd-mls__chip svg { width: 8px; height: 8px; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.sd-mls__more { flex: none; height: 28px; padding: 0 8px; border-radius: 8px; box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; display: inline-grid; place-items: center; }
@keyframes sd-mls-pop { from { transform: scale(0.7); opacity: 0; } }
.sd-mls__in { flex: 1; min-width: 40px; height: 30px; border: 0; outline: 0; background: none; color: var(--fg); font: inherit; font-size: 14px; padding: 0 6px; }
.sd-mls__in::placeholder { color: var(--mute); }
.sd-mls__caret { position: absolute; right: 12px; top: 50%; width: 12px; height: 12px; margin-top: -6px; fill: none; stroke: var(--mute); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: transform 200ms; pointer-events: none; }
.sd-mls__f[data-open] .sd-mls__caret { transform: rotate(180deg); }
.sd-mls__pop { position: absolute; top: calc(100% + 6px); left: 0; right: 0; padding: 5px; border-radius: 13px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line), 0 20px 40px -18px rgb(0 0 0 / 0.55); z-index: 4; animation: sd-mls-in 160ms ease; }
@keyframes sd-mls-in { from { opacity: 0; transform: translateY(-4px); } }
.sd-mls__list { max-height: 196px; overflow: auto; }
.sd-mls__opt { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 9px; border-radius: 8px; font-size: 13.5px; cursor: pointer; }
.sd-mls__opt[data-active] { background: var(--soft); }
.sd-mls__box { display: grid; place-items: center; width: 16px; height: 16px; border-radius: 5px; box-shadow: inset 0 0 0 1.5px var(--mute); flex: none; transition: background-color 150ms; }
.sd-mls__opt[aria-selected='true'] .sd-mls__box { background: var(--strong); box-shadow: none; color: var(--on); }
.sd-mls__box svg { width: 10px; height: 10px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.sd-mls__foot { display: flex; justify-content: space-between; padding: 8px 9px 4px; margin-top: 4px; border-top: 1px solid var(--line); font-size: 12px; color: var(--mute); }
.sd-mls__foot button:hover { color: var(--fg); }
.sd-mls__none { padding: 12px 9px; font-size: 13px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-mls *, .sd-mls *::before, .sd-mls *::after { animation: none !important; transition: none !important; } }Overview
Open it to filter and tick options; selected ones float to the top of the list. Closed, the field shows chips for what fits and a +N chip for the rest.
Backspace in an empty filter removes the last pick, and Select all / Clear sit in the menu footer.
When to use
- Assigning labels, choosing recipients, filter fields with many options.
When not to use
- Under five options: show checkboxes or chips directly.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/multi-select.json$
npx shadcn@latest add https://sundaeui.dev/r/multi-select.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/multi-select.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/multi-select.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy multi-select.tsx and multi-select.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { MultiSelect } from '@/components/sundae/multi-select'
<MultiSelect />Props
| Prop | Type | Default |
|---|---|---|
options*Options. | string[] | — |
value / defaultValueSelected. | string[] | — |
onValueChangeCalled on change. | (v: string[]) => void | — |
placeholderEmpty text. | string | — |
maxChipsChips before +N. | number | 2 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A combobox with aria-multiselectable listbox and aria-selected options.
- Arrow keys move the active option, Enter toggles, Escape closes.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-mls; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Move
- Enter
- Toggle option
- Backspace
- Remove last pick
- Esc
- Close
FAQ
- Is Multi Select free?
- Yes. Multi Select is free for personal and commercial projects, with full source on this page.
- How do I install Multi Select?
- Run npx shadcn@latest add https://sundaeui.dev/r/multi-select.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Multi Select support keyboard and screen readers?
- A combobox with aria-multiselectable listbox and aria-selected options. Arrow keys move the active option, Enter toggles, Escape closes.
- Does Multi Select respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Filter Chips
Forms · RefinedCombobox
Forms · UnderstatedSearch Field
Forms · RefinedRadio Cards
Forms · RefinedCheckbox Tree
Forms · RefinedNotification Matrix
Forms · Refined