MultiSelect
Preview
Basic
Loading…
Preview
Code
ts
import MultiSelect from '@/components/form/MultiSelect';src/components/form/MultiSelect.tsx
AI prompt
text
Build a searchable checkbox multi-select dropdown with removable chips in React + TypeScript + Tailwind CSS.
## Look
- Trigger: a button with the house input recipe, `flex items-center gap-2 text-left min-h-[34px]`. Left, a wrapping chip area (`flex-1 flex flex-wrap gap-1 min-w-0`) — the placeholder in slate-400 when empty — so the current selection is readable without opening. Right, a 14px ChevronDown in slate-400 that rotates 180° while open.
- Chip: `inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-indigo-50 text-indigo-700`, dark `bg-indigo-500/15 text-indigo-300`, ending in a 10px X (hover indigo-900 / dark indigo-100) that removes it without opening the panel (stop propagation). A disabled option's chip has no X.
- Panel: absolute `z-50 mt-1 w-full`, opaque floating surface, `p-2 max-h-72 overflow-y-auto` with a thin scrollbar.
- A filter input at the top ONLY when searchable and there are more than 6 options: 12px Search icon at `left-2.5`, input `pl-7 py-1.5`, placeholder "Filter…", `mb-2`.
- Options `space-y-1.5`, each a checkbox row with `px-1`: a 16px square (`rounded border-2`, unchecked white / slate-300 border, dark slate-800 / slate-600; checked indigo-600 fill with a white 12px Check), label `text-xs font-medium text-slate-700 dark:text-slate-200`, and an optional ⓘ hint tooltip beside the label.
- No matches: "No options" (11px slate-400).
## Behaviour
- Clicking an option toggles it; the panel stays open for more picks. Filter is a case-insensitive substring on the label.
- A `disabled` option stays VISIBLE (at 50% opacity, not toggleable, chip not removable) rather than being filtered out — "exists, not yours to give", e.g. a role above the actor's rank.
- Values may be numbers or strings (`type OptionValue = string | number`), so numeric ids and string slugs both work.
## API
`options: { value: OptionValue; label: string; hint?: string; disabled?: boolean }[]`, `selected: OptionValue[]`, `onChange(next: OptionValue[])`, `placeholder = 'Select…'`, `searchable = true`, `emptyLabel = 'No options'`. Export the `Option` and `OptionValue` types.
## Demo
"Select teams" over Engineering, Research, Design, Support, Operations — one instance keyed by numeric ids (seeded [1, 3]) and one by lowercase slugs (seeded ['engineering']), each with the current value echoed below.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useMemo, useRef, useState } from 'react';
import { ChevronDown, Search, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
import Checkbox from './Checkbox';
/**
* MERGED from two copies. This is bonus-adjustment's `MultiSelect`, which keys
* on numbers; marketing-stats' `MultiSelectDropdown` keyed on strings, and each
* was unusable for the other's data. The value type is now `OptionValue`, so a
* numeric id and a string slug both work and neither call site had to change
* shape to migrate.
*/
export type OptionValue = string | number;
export type Option = {
value: OptionValue;
label: string;
hint?: string;
/** Rendered but not selectable — used for roles above the actor's rank. */
disabled?: boolean;
};
/**
* Checkbox dropdown for assigning a set of things (roles to a user, permissions
* to a role). Selected values show as removable chips on the closed control, so
* the current selection is readable without opening it.
*
* A disabled option stays VISIBLE rather than being filtered out — a role the
* actor may not grant should be legible as "exists, not yours to give", which is
* the same rank rule the server enforces.
*/
export default function MultiSelect({
options,
selected,
onChange,
placeholder = 'Select…',
searchable = true,
emptyLabel = 'No options',
}: {
options: Option[];
selected: OptionValue[];
onChange: (next: OptionValue[]) => void;
placeholder?: string;
searchable?: boolean;
emptyLabel?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
useDismiss(ref, open, () => setOpen(false));
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
}, [options, query]);
const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
const toggle = (value: OptionValue) => {
const option = byValue.get(value);
if (option?.disabled) return;
onChange(selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]);
};
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="field-input flex items-center gap-2 text-left min-h-[34px]"
>
<span className="flex-1 flex flex-wrap gap-1 min-w-0">
{selected.length === 0 && <span className="text-slate-400">{placeholder}</span>}
{selected.map((value) => {
const option = byValue.get(value);
if (!option) return null;
return (
<span
key={value}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-indigo-50 dark:bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 text-[10px] font-semibold"
>
{option.label}
{!option.disabled && (
<X
className="w-2.5 h-2.5 hover:text-indigo-900 dark:hover:text-indigo-100"
onClick={(e) => {
e.stopPropagation();
toggle(value);
}}
/>
)}
</span>
);
})}
</span>
<ChevronDown
className={cn('w-3.5 h-3.5 text-slate-400 shrink-0 transition-transform', open && 'rotate-180')}
/>
</button>
{open && (
<div data-overlay="picker" className="absolute z-50 mt-1 w-full panel panel-solid p-2 max-h-72 overflow-y-auto custom-scrollbar">
{searchable && options.length > 6 && (
<div className="relative mb-2">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter…"
className="field-input pl-7 py-1.5"
/>
</div>
)}
{filtered.length === 0 && (
<p className="px-1 py-2 text-[11px] text-slate-400">{emptyLabel}</p>
)}
<div className="space-y-1.5">
{filtered.map((option) => (
<Checkbox
key={option.value}
id={`ms-${option.value}`}
checked={selected.includes(option.value)}
disabled={option.disabled}
onChange={() => toggle(option.value)}
label={option.label}
hint={option.hint}
className="px-1"
/>
))}
</div>
</div>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
options* | Option[] | — | |
selected* | OptionValue[] | — | |
onChange* | (next: OptionValue[]) => void | — | |
placeholder | string | 'Select…' | |
searchable | boolean | true | |
emptyLabel | string | 'No options' |