Skip to content
Star on GitHub
Forms and inputsMulti-select
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Multi-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.

$npx designme add multi-select
Type to filter. Backspace removes the last tag.
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add multi-select

React

$npx designme add multi-select --react

The 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.

02

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.
03

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>
  );
}
04

Props

Everything MultiSelect takes.

PropTypeWhat it does
labelrequiredstringLike "Tags"
optionsrequiredMultiSelectOption[]
valuestring[]What's picked, in the order it was picked
defaultValuestring[]
onValueChange(value: string[]) => void
querystringWhat's typed in the box. It filters the list
defaultQuerystring
onQueryChange(query: string) => void
openboolean
defaultOpenboolean
onOpenChange(open: boolean) => void
placeholderstringDefaults to "Add a tag"
hintstringThe quiet line under the box. Defaults to "Type to filter. Backspace removes the last tag."
emptystringShown when nothing matches. Defaults to "No tags match"
clearLabelstringThe button beside the label that takes every pick off, shown while something is picked. Defaults to "Clear all"
classNamestring
05

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);
  });
});
06

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 options when nothing matches, and pick it in onValueChange.
Does it search a server?
It filters the options you pass. To search a server, pass query and onQueryChange, fetch as the query changes, and pass the results as options.
Can I limit how many are picked?
Yes. In onValueChange, ignore a change that would go over the limit, and say so in the hint.
Why does the list stay open after a pick?
People usually pick several in a row. Escape, Tab or a click outside closes it.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Search inputSearch with scoped filters, recent queries and an agent answer.
Country selectA searchable select with country codes, currency and keyboard support.
Social sign-inOne-tap providers that remember the last one you used.
Sign inEmail first, then the one-time code from the email, copied or typed in.
OTP fieldA one-time code, one box per digit, that fills from a paste.
All 11 in Forms and inputs →