Forms and inputsMulti-select
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callMulti-select
A combobox that filters as you type and keeps your picks as chips. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add multi-selectReact
npx designme add multi-select --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Multi-select is a combobox for picking several things from a long list. Typing filters the list, the arrow keys move through it and Enter picks, and each pick becomes a chip in the box with its own remove button. Backspace in an empty box takes the last chip off, so a wrong pick is one key away from gone.
- When people pick several items from a list too long to show at once, like tags, teammates or labels.
- When people know what they want and would rather type a few letters than scroll.
- When the picks should stay visible as chips that are easy to remove one by one.
- Not for picking one item: use a select or a combobox with a single value then.
optionsare the choices, each with avalue, an optionallabeland quietmetaat the end of the row, like how many files carry a tag.value,defaultValueandonValueChangehold the picks in the order they were made, shown as chips before the text box.- Typing filters the list by name;
queryandonQueryChangehold the text, andemptyis the line when nothing matches. - Each row has a checkbox: Enter or a click picks a row that isn't picked and unpicks one that is. The box clears for the next search.
- Each chip has a remove button, and Backspace in an empty box removes the last chip. While anything is picked, Clear all beside the label takes every chip off and puts the cursor back in the box;
clearLabelchanges its words. - The list opens under the box and moves down as the chips wrap onto more lines.
open,defaultOpenandonOpenChangehold whether it's open; a click outside closes it.
- The text box has
role="combobox"witharia-expanded,aria-controlsandaria-autocomplete="list", and the label is tied to it. - The list is a
listboxwitharia-multiselectable, and each row anoptionwitharia-selected. - The focus stays in the text box: ↑ and ↓ move the highlight, which
aria-activedescendantpoints to, Enter picks, and Escape closes the list. - Each chip's remove button is named for its tag, like "Remove Pricing".
Usage
Import it and drive it from your own state.
Import
import { MultiSelect } from '@/components/designme/multi-select/MultiSelect';Example
The demo above as a React component, 39 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { MultiSelect } from './MultiSelect';
import { useStory } from '../lib/useScript';
/* ---------- Multi-select: add Pricing, pick Tooltips by mistake and take it off with Backspace, then add Tokens ---------- */
// tags for the pricing page, and how many files carry each
const TAGS: [string, number][] = [['Accessibility', 14], ['Billing', 9], ['Copy', 22], ['Design system', 31], ['Launch', 12], ['Mobile', 18], ['Onboarding', 7], ['Pricing', 16], ['Research', 11], ['Tokens', 25], ['Tooltips', 6]];
export default function MultiSelectDemo() {
const ref = useRef<HTMLDivElement>(null);
const [tags, setTags] = useState(['Launch']);
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
// the preview's story types and presses keys in the box without taking the focus
useStory(ref, async ({ wait, taken }) => {
const key = (k: string) => ref.current?.querySelector('[role="combobox"]')?.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
// taking over takes back the letters it was typing
taken(() => setQuery(''));
const type = async (t: string) => { for (const ch of t) { setQuery(q => q + ch); await wait(200); } };
await wait(1500); setOpen(true);
await wait(1000); await type('pri'); await wait(500); key('Enter');
await wait(1000); await type('to'); await wait(500); key('ArrowDown');
await wait(1000); key('Enter');
await wait(1500); key('Backspace');
await wait(1000); await type('tok'); await wait(500); key('Enter');
await wait(1000); key('Escape');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<MultiSelect label="Tags" options={TAGS.map(([t, n]) => ({ value: t, meta: String(n) }))} value={tags} onValueChange={setTags}
query={query} onQueryChange={setQuery} open={open} onOpenChange={setOpen} />
</div>
);
}
Props
Everything MultiSelect takes.
| Prop | Type | What it does |
|---|---|---|
labelrequired | string | Like "Tags" |
optionsrequired | MultiSelectOption[] | |
value | string[] | What's picked, in the order it was picked |
defaultValue | string[] | |
onValueChange | (value: string[]) => void | |
query | string | What's typed in the box. It filters the list |
defaultQuery | string | |
onQueryChange | (query: string) => void | |
open | boolean | |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
placeholder | string | Defaults to "Add a tag" |
hint | string | The quiet line under the box. Defaults to "Type to filter. Backspace removes the last tag." |
empty | string | Shown when nothing matches. Defaults to "No tags match" |
clearLabel | string | The button beside the label that takes every pick off, shown while something is picked. Defaults to "Clear all" |
className | string |
Code
The full source, as the CLI installs it.
MultiSelect.tsxReact · 120 lines
'use client';
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Icon } from '../lib/icons';
import '../designme.css';
import './multi-select.css';
export interface MultiSelectOption {
value: string;
/** Defaults to the value */
label?: string;
/** Quiet text at the end of the row, like how many files carry the tag */
meta?: string;
}
export interface MultiSelectProps {
/** Like "Tags" */
label: string;
options: MultiSelectOption[];
/** What's picked, in the order it was picked */
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
/** What's typed in the box. It filters the list */
query?: string;
defaultQuery?: string;
onQueryChange?: (query: string) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Defaults to "Add a tag" */
placeholder?: string;
/** The quiet line under the box. Defaults to "Type to filter. Backspace removes the last tag." */
hint?: string;
/** Shown when nothing matches. Defaults to "No tags match" */
empty?: string;
/** The button beside the label that takes every pick off, shown while something is picked. Defaults to "Clear all" */
clearLabel?: string;
className?: string;
}
/** A combobox that keeps what you pick as chips. Typing filters the list, the arrow keys move through it, Enter picks
or unpicks, Escape closes it, and Backspace in an empty box takes the last chip off. */
export function MultiSelect({ label, options, value, defaultValue = [], onValueChange, query, defaultQuery = '', onQueryChange, open, defaultOpen = false, onOpenChange,
placeholder = 'Add a tag', hint = 'Type to filter. Backspace removes the last tag.', empty = 'No tags match', clearLabel = 'Clear all', className }: MultiSelectProps) {
const id = 'dm-msc' + useId().replace(/:/g, '');
const [picked, setPicked] = useControllable(value, defaultValue, onValueChange);
const [q, setQ] = useControllable(query, defaultQuery, onQueryChange);
const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
const [hi, setHi] = useState(0);
// typing (or a new query from outside) starts the highlight at the first match again
const [seenQ, setSeenQ] = useState(q);
if (seenQ !== q) { setSeenQ(q); if (q) setHi(0); }
const field = useRef<HTMLDivElement>(null), input = useRef<HTMLInputElement>(null), list = useRef<HTMLDivElement>(null), root = useRef<HTMLDivElement>(null);
const [top, setTop] = useState(76);
const name = (v: string) => options.find(o => o.value === v)?.label ?? v;
const shown = options.filter(o => !q.trim() || (o.label ?? o.value).toLowerCase().includes(q.trim().toLowerCase()));
const at = Math.min(hi, Math.max(0, shown.length - 1));
// the list sits under the box, which grows as the chips wrap
useIsoLayoutEffect(() => { const f = field.current; if (f) setTop(f.offsetTop + f.offsetHeight + 6); }, [picked.length, isOpen]);
// the highlighted row stays in view
useEffect(() => { const o = list.current?.querySelector<HTMLElement>(`#${id}-o${at}`), l = list.current; if (!o || !l) return;
if (o.offsetTop < l.scrollTop) l.scrollTop = o.offsetTop; else if (o.offsetTop + o.offsetHeight > l.scrollTop + l.clientHeight) l.scrollTop = o.offsetTop + o.offsetHeight - l.clientHeight; }, [at, id, isOpen]);
// a click outside closes it
useEffect(() => {
if (!isOpen) return;
const off = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); };
document.addEventListener('pointerdown', off); return () => document.removeEventListener('pointerdown', off);
}, [isOpen, setOpen]);
const toggle = (v: string) => {
setPicked(picked.includes(v) ? picked.filter(x => x !== v) : [...picked, v]);
setQ(''); setHi(Math.max(0, options.findIndex(o => o.value === v)));
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!isOpen) { setOpen(true); setHi(0); return; }
if (shown.length) setHi((at + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % shown.length);
}
else if (e.key === 'Enter') { e.preventDefault(); if (isOpen && shown[at]) toggle(shown[at].value); else setOpen(true); }
else if (e.key === 'Escape') { if (isOpen) { e.preventDefault(); setOpen(false); } }
else if (e.key === 'Backspace' && !q && picked.length) setPicked(picked.slice(0, -1));
else if (e.key === 'Tab') setOpen(false);
};
return (
<div ref={root} data-dm="" className={cx('dm-msc', className)}>
{/* Clear all takes every chip off at once; the cursor goes back to the box, so the next tag can be typed straight away */}
<div className="dm-msc-top">
<label className="dm-msc-l" htmlFor={`${id}-q`}>{label}</label>
{picked.length > 0 && <button type="button" className="dm-msc-clr" onMouseDown={e => e.preventDefault()} onClick={() => { setPicked([]); input.current?.focus(); }}>{clearLabel}</button>}
</div>
<div ref={field} className="dm-msc-f" onClick={e => { if ((e.target as Element).closest('.dm-msc-c')) return; input.current?.focus(); setOpen(true); }}>
{picked.map(v => (
<span key={v} className="dm-msc-c">{name(v)}
<button type="button" aria-label={`Remove ${name(v)}`} onClick={e => { e.stopPropagation(); setPicked(picked.filter(x => x !== v)); }}><Icon name="x" size="xs" /></button>
</span>
))}
<input ref={input} id={`${id}-q`} className="dm-msc-q" role="combobox" aria-expanded={isOpen} aria-controls={`${id}-l`} aria-autocomplete="list" aria-haspopup="listbox"
aria-activedescendant={isOpen && shown.length ? `${id}-o${at}` : undefined} value={q} placeholder={placeholder} spellCheck={false} autoComplete="off"
onChange={e => { setQ(e.target.value); setHi(0); setOpen(true); }} onKeyDown={onKey} />
</div>
<div className={cx('dm-msc-pop', isOpen && 'dm-open')} style={{ top }}>
<div ref={list} id={`${id}-l`} className="dm-msc-list" role="listbox" aria-multiselectable="true" aria-label={label}>
{shown.length ? shown.map((o, i) => (
<div key={o.value} id={`${id}-o${i}`} role="option" aria-selected={picked.includes(o.value)} className={cx('dm-msc-o', i === at && 'dm-hl')}
onMouseMove={() => { if (i !== at) setHi(i); }} onMouseDown={e => e.preventDefault()} onClick={() => toggle(o.value)}>
<span className="dm-bx"><Icon name="check" size="xs" /></span>{o.label ?? o.value}{o.meta && <span className="dm-n dm-num">{o.meta}</span>}
</div>
)) : <div className="dm-msc-none">{empty}</div>}
</div>
</div>
{hint && <span className="dm-muted dm-msc-h">{hint}</span>}
</div>
);
}
multi-select.cssReact · 37 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Multi-select ---------- */
/* a combobox that keeps what you pick as chips: typing filters the list, the arrows move through it, Enter picks or
unpicks, Escape closes it, and Backspace in an empty box takes the last chip off */
.dm-msc{position:relative;width:100%;max-width:320px;margin:0 auto;display:flex;flex-direction:column;gap:6px}
.dm-msc-top{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2);min-height:22px}
.dm-msc-l{font-size:12.5px;font-weight:500}
.dm-msc-clr{height:22px;padding:0 6px;margin-right:-6px;border-radius:6px;font-size:12px;color:var(--dm-muted);transition:color .2s,background .2s}
@media (hover:hover) and (pointer:fine){.dm-msc-clr:hover{color:var(--dm-fg);background:var(--dm-row)}}
.dm-msc-f{position:relative;display:flex;flex-wrap:wrap;align-items:center;gap:6px;min-height:var(--dm-field-h);padding:6px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);cursor:text;transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-msc-f{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
/* the edge steps up while its search box has the focus; a tag's remove button rings on its own, never inside the field */
.dm-msc-f:has(.dm-msc-q:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-msc-q{flex:1;min-width:80px;height:26px;border:0;outline:0;background:transparent;padding:0 6px;font:inherit;font-size:13.5px;color:var(--dm-fg)}
.dm-msc-q::placeholder{color:var(--dm-muted)}
.dm-msc-c{display:inline-flex;align-items:center;gap:2px;height:26px;padding:0 3px 0 var(--dm-s-2);border-radius:var(--dm-r-tag);background:var(--dm-chip);font-size:12.5px;white-space:nowrap}
@supports (corner-shape:squircle){.dm-msc-c{corner-shape:squircle;border-radius:var(--dm-r-tag-sq)}}
.dm-msc-c button{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;color:var(--dm-muted);transition:background .2s,color .2s}
@media (hover:hover) and (pointer:fine){.dm-msc-c button:hover{background:var(--dm-wash);color:var(--dm-fg)}}
.dm-msc-c.dm-go{animation:dm-mscout .22s var(--dm-ease) forwards}
@keyframes dm-mscout{to{opacity:0;transform:scale(.85)}}
/* the key just pressed, while the preview plays */
.dm-msc-key{position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0;transition:opacity .2s}
.dm-msc-key.dm-on{opacity:1}
.dm-msc-pop{position:absolute;top:76px;left:0;right:0;z-index:5;padding:6px;border-radius:14px;background:var(--dm-raised);box-shadow:var(--dm-shadow-pop);opacity:0;visibility:hidden;transform:translateY(-4px) scale(.98);transform-origin:top;filter:blur(3px);pointer-events:none;transition:opacity .2s,transform .35s var(--dm-ease),filter .2s,top var(--dm-t) var(--dm-ease),visibility 0s .2s}
.dm-msc-pop.dm-open{opacity:1;visibility:visible;transform:none;filter:none;pointer-events:auto;transition-property:opacity,transform,filter,top}
.dm-msc-list{max-height:210px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
.dm-msc-o{display:flex;align-items:center;gap:10px;min-height:34px;padding:6px var(--dm-s-2);border-radius:9px;font-size:13px;cursor:pointer;flex:none}
.dm-msc-o.dm-hl{background:var(--dm-row)}
.dm-msc-o .dm-bx{width:16px;height:16px;border-radius:5px;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--dm-muted) 80%,var(--dm-surface));display:grid;place-items:center;color:transparent;flex:none;transition:background .2s,box-shadow .2s,color .2s}
.dm-msc-o[aria-selected="true"] .dm-bx{background:var(--dm-accent);box-shadow:inset 0 0 0 1px var(--dm-accent-mark,var(--dm-accent));color:var(--dm-on-accent)}
.dm-msc-o .dm-n{margin-left:auto;font-size:12px;color:var(--dm-muted)}
.dm-msc-none{padding:var(--dm-s-2);font-size:12.5px;color:var(--dm-muted)}
.dm-msc-h{font-size:12px}
/* the open list covers the hint under the box, so the hint steps aside instead of peeking out above the list */
.dm-msc:has(.dm-msc-pop.dm-open) .dm-msc-h{visibility:hidden}
multi-select.htmlHTML · 11 lines
<div class="card" data-c="multi-select">
<div class="stage-in">
<div class="msc">
<div class="msc-top"><label class="msc-l">Tags</label><button class="msc-clr" hidden>Clear all</button></div>
<div class="msc-f"><input class="msc-q" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-haspopup="listbox" placeholder="Add a tag" spellcheck="false" autocomplete="off"><span class="kbd msc-key" aria-hidden="true"></span></div>
<div class="msc-pop"><div class="msc-list" role="listbox" aria-multiselectable="true" aria-label="Tags"></div></div>
<span class="muted msc-h">Type to filter. Backspace removes the last tag.</span>
</div>
</div>
</div>
multi-select.cssHTML · 36 lines
/* ---------- Multi-select ---------- */
/* a combobox that keeps what you pick as chips: typing filters the list, the arrows move through it, Enter picks or
unpicks, Escape closes it, and Backspace in an empty box takes the last chip off */
.msc{position:relative;width:100%;max-width:320px;margin:0 auto;display:flex;flex-direction:column;gap:6px}
.msc-top{display:flex;align-items:center;justify-content:space-between;gap:var(--s-2);min-height:22px}
.msc-l{font-size:12.5px;font-weight:500}
.msc-clr{height:22px;padding:0 6px;margin-right:-6px;border-radius:6px;font-size:12px;color:var(--muted);transition:color .2s,background .2s}
@media (hover:hover) and (pointer:fine){.msc-clr:hover{color:var(--fg);background:var(--row)}}
.msc-f{position:relative;display:flex;flex-wrap:wrap;align-items:center;gap:6px;min-height:var(--field-h);padding:6px;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);cursor:text;transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.msc-f{corner-shape:squircle;border-radius:var(--r-field-sq)}}
/* the edge steps up while its search box has the focus; a tag's remove button rings on its own, never inside the field */
.msc-f:has(.msc-q:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.msc-q{flex:1;min-width:80px;height:26px;border:0;outline:0;background:transparent;padding:0 6px;font:inherit;font-size:13.5px;color:var(--fg)}
.msc-q::placeholder{color:var(--muted)}
.msc-c{display:inline-flex;align-items:center;gap:2px;height:26px;padding:0 3px 0 var(--s-2);border-radius:var(--r-tag);background:var(--chip);font-size:12.5px;white-space:nowrap}
@supports (corner-shape:squircle){.msc-c{corner-shape:squircle;border-radius:var(--r-tag-sq)}}
.msc-c button{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;color:var(--muted);transition:background .2s,color .2s}
@media (hover:hover) and (pointer:fine){.msc-c button:hover{background:var(--wash);color:var(--fg)}}
.msc-c.go{animation:mscout .22s var(--ease) forwards}
@keyframes mscout{to{opacity:0;transform:scale(.85)}}
/* the key just pressed, while the preview plays */
.msc-key{position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0;transition:opacity .2s}
.msc-key.on{opacity:1}
.msc-pop{position:absolute;top:76px;left:0;right:0;z-index:5;padding:6px;border-radius:14px;background:var(--raised);box-shadow:var(--shadow-pop);opacity:0;visibility:hidden;transform:translateY(-4px) scale(.98);transform-origin:top;filter:blur(3px);pointer-events:none;transition:opacity .2s,transform .35s var(--ease),filter .2s,top var(--t) var(--ease),visibility 0s .2s}
.msc-pop.open{opacity:1;visibility:visible;transform:none;filter:none;pointer-events:auto;transition-property:opacity,transform,filter,top}
.msc-list{max-height:210px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
.msc-o{display:flex;align-items:center;gap:10px;min-height:34px;padding:6px var(--s-2);border-radius:9px;font-size:13px;cursor:pointer;flex:none}
.msc-o.hl{background:var(--row)}
.msc-o .bx{width:16px;height:16px;border-radius:5px;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--muted) 80%,var(--surface));display:grid;place-items:center;color:transparent;flex:none;transition:background .2s,box-shadow .2s,color .2s}
.msc-o[aria-selected="true"] .bx{background:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-mark,var(--accent));color:var(--on-accent)}
.msc-o .n{margin-left:auto;font-size:12px;color:var(--muted)}
.msc-none{padding:var(--s-2);font-size:12.5px;color:var(--muted)}
.msc-h{font-size:12px}
/* the open list covers the hint under the box, so the hint steps aside instead of peeking out above the list */
.msc:has(.msc-pop.open) .msc-h{visibility:hidden}
multi-select.jsHTML · 75 lines
DM.def('multi-select', (r, c) => {
const uid = Math.random().toString(36).slice(2, 7);
// tags for the pricing page, and how many files carry each
const T = [['Accessibility', 14], ['Billing', 9], ['Copy', 22], ['Design system', 31], ['Launch', 12], ['Mobile', 18], ['Onboarding', 7], ['Pricing', 16], ['Research', 11], ['Tokens', 25], ['Tooltips', 6]];
const f = $('.msc-f', r), q = $('.msc-q', r), pop = $('.msc-pop', r), list = $('.msc-list', r), key = $('.msc-key', r), label = $('.msc-l', r);
q.id = 'msc-q-' + uid; label.htmlFor = q.id; list.id = 'msc-l-' + uid; q.setAttribute('aria-controls', list.id);
let picked = ['Launch'], hi = 0, opts = [];
const clr = $('.msc-clr', r);
// the chips sit in the box before the input, each with its own remove button
function chips(){
$$('.msc-c', f).forEach(ch => ch.remove());
picked.forEach(t => { const ch = html(`<span class="msc-c" data-t="${t}">${t}<button aria-label="Remove ${t}">${I('x','xs')}</button></span>`); $('button', ch).onclick = e => { e.stopPropagation(); remove(t); }; f.insertBefore(ch, q); });
clr.hidden = !picked.length;
place();
}
const place = () => { pop.style.top = (f.offsetTop + f.offsetHeight + 6) + 'px'; };
function paint(){
const v = q.value.trim().toLowerCase(), m = T.filter(([t]) => !v || t.toLowerCase().includes(v));
list.innerHTML = m.length ? m.map(([t, n], i) => `<div class="msc-o" role="option" id="msc-o-${i}-${uid}" aria-selected="${picked.includes(t)}" data-t="${t}"><span class="bx">${I('check','xs')}</span>${t}<span class="n num">${n}</span></div>`).join('') : '<div class="msc-none">No tags match</div>';
opts = $$('.msc-o', list); hi = Math.min(hi, Math.max(0, opts.length - 1)); move(0);
opts.forEach((o, i) => { o.onmousemove = () => { if (hi !== i){ hi = i; move(0); } }; o.onmousedown = e => e.preventDefault(); o.onclick = () => toggle(o.dataset.t); });
}
function move(d){
if (!opts.length){ q.removeAttribute('aria-activedescendant'); return; }
hi = (hi + d + opts.length) % opts.length; opts.forEach((o, i) => o.classList.toggle('hl', i === hi));
q.setAttribute('aria-activedescendant', opts[hi].id);
const o = opts[hi], top = o.offsetTop, bot = top + o.offsetHeight;
if (top < list.scrollTop) list.scrollTop = top; else if (bot > list.scrollTop + list.clientHeight) list.scrollTop = bot - list.clientHeight;
}
const isOpen = () => pop.classList.contains('open');
// the box sits centred on its own, and glides up once as the list opens; it makes room for the list at its tallest, so it
// holds still while typing filters the list. The list's place comes from the box, not its top mid-move, so chips
// wrapping onto a new line move both together
const box = $('.msc', r), full = () => pop.offsetHeight - list.offsetHeight + parseFloat(getComputedStyle(list).maxHeight);
const centred = centre(c, box, () => isOpen() ? Math.max(box.offsetHeight, f.offsetTop + f.offsetHeight + 6 + full()) : box.offsetHeight);
function open(){ if (isOpen()) return; place(); pop.classList.add('open'); q.setAttribute('aria-expanded', 'true'); hi = 0; paint(); centred(); }
// on closing, the box waits for the list to fade before it glides back to the middle
function close(){ pop.classList.remove('open'); q.setAttribute('aria-expanded', 'false'); q.removeAttribute('aria-activedescendant'); c.later(REDUCE ? 0 : 180, centred); }
// Enter or a click picks a tag, or unpicks one already picked; the box clears for the next one
function toggle(t){ picked = picked.includes(t) ? picked.filter(x => x !== t) : [...picked, t]; q.value = ''; chips(); paint(); hi = Math.max(0, opts.findIndex(o => o.dataset.t === t)); move(0); }
function remove(t){ const ch = $(`.msc-c[data-t="${t}"]`, f); picked = picked.filter(x => x !== t); if (ch && !REDUCE){ ch.classList.add('go'); c.later(220, () => { chips(); if (isOpen()) paint(); }); } else { chips(); if (isOpen()) paint(); } }
// a click on the box opens the list; only a real click takes the focus, so the preview never moves the page
f.onclick = e => { if (e.target.closest('.msc-c')) return; if (e.isTrusted) q.focus({ preventScroll:true }); open(); };
q.oninput = () => { open(); hi = 0; paint(); };
q.onkeydown = e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){ e.preventDefault(); if (!isOpen()) return open(); move(e.key === 'ArrowDown' ? 1 : -1); }
else if (e.key === 'Enter'){ e.preventDefault(); if (isOpen() && opts[hi]) toggle(opts[hi].dataset.t); else open(); }
else if (e.key === 'Escape'){ if (isOpen()){ e.preventDefault(); close(); } }
else if (e.key === 'Backspace' && !q.value && picked.length) remove(picked[picked.length - 1]);
else if (e.key === 'Tab') close();
};
c.on(document, 'pointerdown', e => { if (!r.contains(e.target)) close(); });
// Clear all takes every chip off at once; the cursor goes back to the box, so the next tag can be typed straight away
clr.onmousedown = e => e.preventDefault();
clr.onclick = e => { picked = []; chips(); if (isOpen()) paint(); if (e.isTrusted) q.focus({ preventScroll:true }); };
chips();
// the preview's story types and presses keys in the box without taking the focus, and shows each key as it goes
const press = async (s, k, label) => { key.textContent = label || k; key.classList.add('on'); q.dispatchEvent(new KeyboardEvent('keydown', { key:k, bubbles:true })); await s.beat(2); key.classList.remove('on'); };
const type = (s, text) => s.type(q, text, { n:.4, each:() => q.oninput(), keep:true });
// the preview's story: add Pricing, pick Tooltips by mistake and take it off with Backspace, then add Tokens
c.story(async s => {
await s.beat(3);
await s.tap(f); open(); s.away(); await s.beat(2);
await type(s, 'pri'); await s.beat(1);
await press(s, 'Enter');
await type(s, 'to'); await s.beat(1);
await press(s, 'ArrowDown', '↓');
await press(s, 'Enter'); await s.beat(1);
await press(s, 'Backspace');
await type(s, 'tok'); await s.beat(1);
await press(s, 'Enter');
await press(s, 'Escape', 'Esc'); await s.beat(2);
});
});
Questions
What people ask about it.
- Can people add a tag that isn't in the list?
- Not by default. Add a row for the typed text to
optionswhen nothing matches, and pick it inonValueChange. - Does it search a server?
- It filters the
optionsyou pass. To search a server, passqueryandonQueryChange, fetch as the query changes, and pass the results asoptions. - Can I limit how many are picked?
- Yes. In
onValueChange, ignore a change that would go over the limit, and say so in thehint. - Why does the list stay open after a pick?
- People usually pick several in a row. Escape, Tab or a click outside closes it.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.