Base components
Combobox
A field you type into to filter a list and pick one or many options, where the list grows out of the field like Select, matches update at once, and picked options show as tags or as a count.
Live preview
Loading preview
Three fields at size md: "Assign to" (Search, people with an @handle), "Reviewers" (Tags, people with avatars) and "Teams" (Multi-select with user counts, Reset and Select all). Typing "zz" in Teams shows the empty state with Clear search. Replay opens the Reviewers list, types "ka" and picks the first match.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add comboboxUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Combobox, type ComboboxOption } from './combobox';
const PEOPLE: ComboboxOption[] = [
{ value: 'olivia', label: 'Olivia Rhye', supportingText: '@olivia' },
{ value: 'phoenix', label: 'Phoenix Baker', supportingText: '@phoenix' },
{ value: 'lana', label: 'Lana Steiner', supportingText: '@lana' },
{ value: 'demi', label: 'Demi Wilkinson', supportingText: '@demi' },
{ value: 'candice', label: 'Candice Wu', supportingText: '@candice' },
{ value: 'kate', label: 'Kate Morrison', supportingText: '@kate' },
{ value: 'drew', label: 'Drew Cano', supportingText: '@drew' },
];
const TEAMS: (ComboboxOption & { users: number })[] = [
{ value: 'engineering', label: 'Engineering', supportingText: '12 users', users: 12 },
{ value: 'design', label: 'Design', supportingText: '10 users', users: 10 },
{ value: 'product', label: 'Product', supportingText: '6 users', users: 6 },
{ value: 'marketing', label: 'Marketing', supportingText: '8 users', users: 8 },
{ value: 'sales', label: 'Sales', supportingText: '12 users', users: 12 },
{ value: 'success', label: 'Customer success', supportingText: '4 users', users: 4 },
{ value: 'operations', label: 'Operations', supportingText: '2 users', users: 2 },
];
const REVIEWERS = ['olivia', 'phoenix'];
/** Types into an input the way the keyboard would, so the field filters and opens. */
function typeInto(input: HTMLInputElement, text: string) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, text);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function press(key: string) {
const target = document.activeElement ?? document.body;
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
export default function ComboboxDemo({ replay = 0 }: { replay?: number }) {
const [reviewers, setReviewers] = React.useState<string[]>(REVIEWERS);
const [reviewersOpen, setReviewersOpen] = React.useState(false);
const [teams, setTeams] = React.useState<string[]>(['design', 'product']);
const reviewersRef = React.useRef<HTMLDivElement>(null);
const users = TEAMS.filter((team) => teams.includes(team.value)).reduce((sum, team) => sum + team.users, 0);
React.useEffect(() => {
if (replay === 0) return;
setReviewers(REVIEWERS);
const input = () =>
reviewersRef.current?.querySelector('input:not([type="hidden"])') as HTMLInputElement | null;
const timers = [
window.setTimeout(() => input()?.focus(), 0),
window.setTimeout(() => {
setReviewersOpen(true);
const el = input();
if (el) typeInto(el, 'k');
}, 300),
window.setTimeout(() => {
const el = input();
if (el) typeInto(el, 'ka');
}, 550),
window.setTimeout(() => press('ArrowDown'), 950),
window.setTimeout(() => press('Enter'), 1300),
window.setTimeout(() => setReviewersOpen(false), 2000),
];
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<Combobox label="Assign to" items={PEOPLE} placeholder="Search" shortcut="⌘K" />
<div ref={reviewersRef}>
<Combobox
type="tags"
label="Reviewers"
items={PEOPLE}
itemLeading="avatar-leading"
value={reviewers}
open={reviewersOpen}
onOpenChange={setReviewersOpen}
onValueChange={(next) => setReviewers(next as string[])}
placeholder="Add a reviewer"
/>
</div>
<Combobox
type="multi-select"
label="Teams"
items={TEAMS}
value={teams}
onValueChange={(next) => setTeams(next as string[])}
summary={`${users} users`}
placeholder="Select teams"
searchPlaceholder="Search teams"
emptyTitle="No teams found"
hint="The teams get a message when you post."
/>
</div>
);
}
API
Parts: field (label row, help icon, hint from input), trigger (search icon, text input or count summary, shortcut key, chevron), tags (Tags type: one tag per picked option, then the text input), list panel (search field for Multi-select, option rows, scroll bar), option row (avatar, icon or dot, label, supporting text, check or checkbox), empty state (icon, title, description, Clear search link), footer (Multi-select: Reset and Select all buttons).
| Prop | Type | Default | Description |
|---|---|---|---|
| type | "search" | "tags" | "multi-select" | "search" | search: pick one option by typing in the field. tags: pick many, shown as tags in the field, typing in the field. multi-select: pick many with checkboxes; the field shows a count and the search field sits at the top of the list. |
| items | Array<{ value: string; label: string; supportingText?: string; avatarSrc?: string; dot?: string; disabled?: boolean }> | [] | Options. supportingText shows after the label (for example "@olivia" or "10 users"); dot is a utility color name. |
| value | string | null | string[] | undefined | Picked value (controlled): a string or null for search, an array for tags and multi-select. |
| defaultValue | string | null | string[] | null or [] | Picked value when uncontrolled. |
| size | "sm" | "md" | "lg" | "md" | Field height 40, 44 or 48px, matching Input and Select. |
| label | string | undefined | Visible label above the field. |
| placeholder | string | "Search" | Text in the empty field. Multi-select demos use a noun, for example "Select teams". |
| searchPlaceholder | string | "Search" | Placeholder of the search field inside the Multi-select list. |
| shortcut | string | undefined | Key hint shown at the end of a search field, for example "⌘K". Only a hint: the app binds the key and calls focus. |
| summary | string | undefined | Multi-select only: extra text after the count, for example "16 users". |
| emptyTitle | string | "No results found" | Title of the empty state when no option matches the search. |
| emptyDescription | string | "Try a different search." | Line under the empty state title. |
| hint | string | undefined | Hint line under the field; shows the error text when invalid. |
| helpText | string | undefined | Text for the help icon after the label (from input). |
| required | boolean | false | Shows the required mark and sets aria-required. |
| invalid | boolean | false | Error border, error icon and red hint, as in Input. |
| disabled | boolean | false | Turns the field off (50% opacity). |
| maxHeight | number | 320 | Largest list height in px before it scrolls. |
| name | string | undefined | Form field name; picked values are sent as hidden inputs. |
| Event | Description |
|---|---|
| valueChange | The picked value changed (a string or null, or an array). |
| inputValueChange | The search text changed. Use it to load options from a server. |
| openChange | The list opened or closed. |
| Slot | Description |
|---|---|
| empty | Replaces the default empty state. |
| item | Custom content of one option row; receives the item and whether it is picked. |
| leading | Replaces the search icon in the field. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| CBX1 | hover the field | field border | border-color | border-control → border-control_hover | duration-sm (200)ease-out | Swaps at once |
| CBX2 | focus, or open | field | border-color, box-shadow | border-control, shadow-xs → border-brand, focus-ring-shadow-xs | duration-2xs (120)ease-out | Shows at once |
| CBX3 | open (typing, arrow key, chevron or click) | list panel | opacity | 0 → 1 | duration-sm (200)ease-out | Shows at once |
| CBX4 | open | list panel (transform origin at the field) | scale, translate toward the field | scale-press, -distance-rise → 1, 0 | duration-md (260)ease-settle | Shows at once |
| CBX5 | open | options, search field, footer | opacity | 0 → 1 | duration-sm (200)ease-outdelay 60ms | Shows at once |
| CBX6 | close (pick in search, Escape, outside click, Tab) | list panel and its content | opacity, scale | 1, 1 → 0, scale-press | duration-xs (160)ease-exit | Hides at once |
| CBX7 | open -> true / false (Multi-select) | chevron | rotate | 0deg → 180deg | duration-lg (320)ease-out | Swaps at once |
| CBX8 | an option is highlighted (pointer or arrow keys) | option row | background-color | transparent → bg-primary_hover | duration-2xs (120)ease-out | Swaps at once |
| CBX9 | the search text changes | list panel | height | height of the old matches → height of the new matches (rows themselves show and hide at once) | duration-sm (200)ease-settle | Resizes at once |
| CBX10 | no option matches | empty state | opacity | 0 → 1 | duration-sm (200)ease-out | Shows at once |
| CBX11 | an option is picked (Search, Tags) | check icon | opacity, scale | 0, scale-swap → 1, 1 | duration-sm (200)ease-out | Shows at once |
| CBX12 | an option is picked (Tags) | new tag in the field | opacity | 0 → 1 (tag TAG3) | duration-sm (200)ease-out | Shows at once |
| CBX13 | a tag is removed (X or Backspace in the empty input) | tag, then the tags after it | opacity, then gap | 1, tag width → 0, closed (tag TAG4 and TAG5) | duration-xs (160)ease-exit | Hides at once |
| CBX14 | the count changes (Multi-select) | count summary | opacity (crossfade) | old count 1 -> 0 → new count 0 -> 1 | duration-sm (200)ease-out | Swaps at once |
| CBX15 | invalid -> true | field border, error icon, hint | border-color, opacity, color | border-control, 0, text-tertiary → border-error, 1, text-error-primary | duration-sm (200)ease-out | Swaps at once |
Accessibility
- Built on a combobox primitive: the text input has role combobox with aria-expanded, aria-controls and aria-activedescendant; the list is role listbox with role option rows; tags and multi-select set aria-multiselectable.
- Focus stays in the text input while the arrow keys move the highlight; Enter picks, Escape closes the list and a second Escape clears the text, Home and End move the caret.
- Tags: Backspace in the empty input removes the last tag; each tag remove button is named "Remove" plus the option label (from tag).
- Multi-select: the field is a button that opens the list; focus moves to the search field inside the list and returns to the field on close. Option checkboxes are part of the option, not separate tab stops.
- The number of matches is announced in a polite live region after typing stops (for example "4 results"); the empty state title is announced the same way.
- The label, hint and invalid state are linked as in Input; hidden native inputs take part in forms.
- Avatars, icons and dots are aria-hidden; the shortcut key hint is aria-hidden and the app sets aria-keyshortcuts when it binds the key.
- Reduced motion: the list shows and hides at once, its height changes at once, and the check, tags and count swap at once.