Lunia DS

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.

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 combobox

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
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.
itemsArray<{ 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.
valuestring | null | string[]undefinedPicked value (controlled): a string or null for search, an array for tags and multi-select.
defaultValuestring | null | string[]null or []Picked value when uncontrolled.
size"sm" | "md" | "lg""md"Field height 40, 44 or 48px, matching Input and Select.
labelstringundefinedVisible label above the field.
placeholderstring"Search"Text in the empty field. Multi-select demos use a noun, for example "Select teams".
searchPlaceholderstring"Search"Placeholder of the search field inside the Multi-select list.
shortcutstringundefinedKey hint shown at the end of a search field, for example "⌘K". Only a hint: the app binds the key and calls focus.
summarystringundefinedMulti-select only: extra text after the count, for example "16 users".
emptyTitlestring"No results found"Title of the empty state when no option matches the search.
emptyDescriptionstring"Try a different search."Line under the empty state title.
hintstringundefinedHint line under the field; shows the error text when invalid.
helpTextstringundefinedText for the help icon after the label (from input).
requiredbooleanfalseShows the required mark and sets aria-required.
invalidbooleanfalseError border, error icon and red hint, as in Input.
disabledbooleanfalseTurns the field off (50% opacity).
maxHeightnumber320Largest list height in px before it scrolls.
namestringundefinedForm field name; picked values are sent as hidden inputs.
EventDescription
valueChangeThe picked value changed (a string or null, or an array).
inputValueChangeThe search text changed. Use it to load options from a server.
openChangeThe list opened or closed.
SlotDescription
emptyReplaces the default empty state.
itemCustom content of one option row; receives the item and whether it is picked.
leadingReplaces 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
CBX1hover the fieldfield borderborder-colorborder-control → border-control_hoverduration-sm (200)ease-outSwaps at once
CBX2focus, or openfieldborder-color, box-shadowborder-control, shadow-xs → border-brand, focus-ring-shadow-xsduration-2xs (120)ease-outShows at once
CBX3open (typing, arrow key, chevron or click)list panelopacity0 → 1duration-sm (200)ease-outShows at once
CBX4openlist panel (transform origin at the field)scale, translate toward the fieldscale-press, -distance-rise → 1, 0duration-md (260)ease-settleShows at once
CBX5openoptions, search field, footeropacity0 → 1duration-sm (200)ease-outdelay 60msShows at once
CBX6close (pick in search, Escape, outside click, Tab)list panel and its contentopacity, scale1, 1 → 0, scale-pressduration-xs (160)ease-exitHides at once
CBX7open -> true / false (Multi-select)chevronrotate0deg → 180degduration-lg (320)ease-outSwaps at once
CBX8an option is highlighted (pointer or arrow keys)option rowbackground-colortransparent → bg-primary_hoverduration-2xs (120)ease-outSwaps at once
CBX9the search text changeslist panelheightheight of the old matches → height of the new matches (rows themselves show and hide at once)duration-sm (200)ease-settleResizes at once
CBX10no option matchesempty stateopacity0 → 1duration-sm (200)ease-outShows at once
CBX11an option is picked (Search, Tags)check iconopacity, scale0, scale-swap → 1, 1duration-sm (200)ease-outShows at once
CBX12an option is picked (Tags)new tag in the fieldopacity0 → 1 (tag TAG3)duration-sm (200)ease-outShows at once
CBX13a tag is removed (X or Backspace in the empty input)tag, then the tags after itopacity, then gap1, tag width → 0, closed (tag TAG4 and TAG5)duration-xs (160)ease-exitHides at once
CBX14the count changes (Multi-select)count summaryopacity (crossfade)old count 1 -> 0 → new count 0 -> 1duration-sm (200)ease-outSwaps at once
CBX15invalid -> truefield border, error icon, hintborder-color, opacity, colorborder-control, 0, text-tertiary → border-error, 1, text-error-primaryduration-sm (200)ease-outSwaps 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.