Component Porchlight CSS

Combobox

Framework-neutral combobox, autocomplete, and async lookup patterns for SaaS entity selection.

52 components 51 stable 1 experimental

Command

Search Porchlight

Combobox

The .c-combobox component styles a WAI-ARIA-style combobox/listbox contract for modern app lookup fields: assignees, customers, process definitions, vendors, statuses, and other remote entities. Porchlight provides the control, popup, option rows, loading/empty states, invalid state, disabled state, and optional multi-select chip layout. The app owns filtering, async requests, keyboard behavior, selection, hidden values, validation timing, and DOM updates.

Use this component when a native <select> is not enough because the option set is large, remote, richly rendered, or filtered as the user types. Prefer a native <select> for short static choices.

Semantic HTML

<div class="c-field">
  <label class="c-field__label" for="assignee-lookup">Assignee</label>
  <div class="c-combobox">
    <div class="c-combobox__control">
      <input
        id="assignee-lookup"
        class="c-combobox__input"
        name="assignee_label"
        role="combobox"
        type="search"
        autocomplete="off"
        aria-autocomplete="list"
        aria-expanded="true"
        aria-controls="assignee-list"
        aria-activedescendant="assignee-nia"
        aria-describedby="assignee-help"
        value="Nia"
      />
      <svg class="c-combobox__chevron" aria-hidden="true">...</svg>
    </div>
    <div
      class="c-combobox__popup"
      id="assignee-list"
      role="listbox"
      aria-label="Assignee suggestions"
    >
      <button
        class="c-combobox__option"
        id="assignee-nia"
        type="button"
        role="option"
        aria-selected="true"
      >
        <span class="c-combobox__label">Nia Gomez</span>
        <span class="c-combobox__meta">Ops</span>
        <span class="c-combobox__description"
          >Process owner - 14 open cases</span
        >
      </button>
      <button
        class="c-combobox__option"
        id="assignee-lee"
        type="button"
        role="option"
        aria-selected="false"
      >
        <span class="c-combobox__label">Lee Chen</span>
        <span class="c-combobox__meta">Finance</span>
      </button>
    </div>
    <input type="hidden" name="assignee_id" value="usr_124" />
  </div>
  <span class="c-field__hint" id="assignee-help"
    >Type to search users, groups, and queues.</span
  >
</div>

Keep DOM focus on the input or trigger while arrow keys move the active option. Synchronize aria-expanded, aria-activedescendant, and each option’s aria-selected. The popup should use role="listbox" and each selectable row should use role="option". Submitted values should be stored in hidden inputs or in application state.

Class contract

SelectorRole
.c-comboboxRelative wrapper for the control and popup.
.c-combobox--multiMulti-select layout with wrapping chips.
.c-combobox__controlBordered control shell, or a trigger with role="combobox".
.c-combobox__inputText/search input with role="combobox".
.c-combobox__valueLabel stack for button-triggered select-only comboboxes.
.c-combobox__chevronOptional trailing disclosure icon.
.c-combobox__popupPositioned popup containing the listbox.
.c-combobox__listOptional list wrapper when options are grouped in a list.
.c-combobox__optionSelectable row with role="option".
.c-combobox__labelPrimary option text.
.c-combobox__descriptionSecondary option detail.
.c-combobox__metaRight-aligned option metadata.
.c-combobox__statusLoading, empty, or helper row inside the popup.
.c-combobox__chipsWrapping selected-value chips for multi-select.
[data-active]Optional active descendant styling hook.
[data-loading]Loading indicator on .c-combobox__status.
[aria-selected="true"]Selected or active option styling.
[aria-invalid="true"]Server/framework invalid state on the input or trigger.

Common patterns

Single lookup

Use an editable input when the user filters a large or remote set.

<div class="c-combobox">
  <div class="c-combobox__control">
    <input
      class="c-combobox__input"
      role="combobox"
      type="search"
      aria-autocomplete="list"
      aria-expanded="true"
      aria-controls="customer-list"
      aria-activedescendant="customer-acme"
      value="Acme"
    />
  </div>
  <div class="c-combobox__popup" id="customer-list" role="listbox">
    <button
      class="c-combobox__option"
      id="customer-acme"
      type="button"
      role="option"
      aria-selected="true"
    >
      <span class="c-combobox__label">Acme Operations</span>
      <span class="c-combobox__meta">Customer</span>
      <span class="c-combobox__description">Tier 1 account - Net 30</span>
    </button>
  </div>
</div>

Async loading

While results are being fetched, keep the combobox expanded and render a status row. The app should also expose loading state through live regions or other product-appropriate announcements when the async timing matters.

<div class="c-combobox">
  <div class="c-combobox__control">
    <input
      class="c-combobox__input"
      role="combobox"
      type="search"
      aria-autocomplete="list"
      aria-expanded="true"
      aria-controls="vendor-list"
      aria-busy="true"
      value="North"
    />
  </div>
  <div class="c-combobox__popup" id="vendor-list" role="listbox">
    <div
      class="c-combobox__status"
      data-loading
      role="option"
      aria-disabled="true"
    >
      Searching vendors...
    </div>
  </div>
</div>

Empty results

When there are no selectable options, keep the listbox present and render a status row. Do not leave aria-activedescendant pointing at a missing option.

<div class="c-combobox">
  <div class="c-combobox__control">
    <input
      class="c-combobox__input"
      role="combobox"
      type="search"
      aria-autocomplete="list"
      aria-expanded="true"
      aria-controls="process-list"
      value="offboard"
    />
  </div>
  <div class="c-combobox__popup" id="process-list" role="listbox">
    <div class="c-combobox__status" role="option" aria-disabled="true">
      No matching processes.
    </div>
  </div>
</div>

Select-only trigger

For a closed set with custom row rendering, a button can be the combobox trigger. It still uses role="combobox", aria-expanded, aria-controls, and aria-activedescendant.

<div class="c-combobox">
  <button
    class="c-combobox__control"
    type="button"
    role="combobox"
    aria-expanded="true"
    aria-controls="status-list"
    aria-activedescendant="status-review"
  >
    <span class="c-combobox__value">
      <span class="c-combobox__label">Needs review</span>
      <span class="c-combobox__description">Paused before approval</span>
    </span>
    <svg class="c-combobox__chevron" aria-hidden="true">...</svg>
  </button>
  <div class="c-combobox__popup" id="status-list" role="listbox">
    <button
      class="c-combobox__option"
      id="status-review"
      type="button"
      role="option"
      aria-selected="true"
    >
      <span class="c-combobox__label">Needs review</span>
    </button>
  </div>
</div>

Invalid, required, and disabled

Use native required when the combobox uses an input. For server or framework validation, set aria-invalid="true" on the input or trigger and connect the message with aria-describedby. Disabled editable comboboxes use disabled; select-only triggers use aria-disabled="true" plus app logic that prevents opening.

<div class="c-field">
  <label class="c-field__label" for="required-process">
    Process
    <span class="c-field__required" aria-hidden="true">*</span>
  </label>
  <div class="c-combobox">
    <div class="c-combobox__control">
      <input
        id="required-process"
        class="c-combobox__input"
        role="combobox"
        required
        aria-expanded="false"
        aria-controls="required-process-list"
        aria-invalid="true"
        aria-describedby="required-process-error"
      />
    </div>
    <div
      class="c-combobox__popup"
      id="required-process-list"
      role="listbox"
      hidden
    ></div>
  </div>
  <span
    class="c-field__hint"
    id="required-process-error"
    data-tone="danger"
    role="alert"
    >Choose a process before routing this case.</span
  >
</div>

Multi-select chips

Multi-select is optional. Render selected values as chips inside the control and keep an input for filtering. The app owns chip removal, duplicate prevention, hidden submitted values, and active descendant state.

<div class="c-combobox c-combobox--multi">
  <div class="c-combobox__control">
    <div class="c-combobox__chips">
      <span class="c-chip">
        Legal
        <button class="c-chip__remove" type="button" aria-label="Remove Legal">
          x
        </button>
      </span>
      <input
        class="c-combobox__input"
        role="combobox"
        type="search"
        aria-autocomplete="list"
        aria-expanded="true"
        aria-controls="team-list"
        aria-activedescendant="team-finance"
        placeholder="Add team..."
      />
    </div>
  </div>
  <div class="c-combobox__popup" id="team-list" role="listbox">
    <button
      class="c-combobox__option"
      id="team-finance"
      type="button"
      role="option"
      aria-selected="false"
    >
      <span class="c-combobox__label">Finance</span>
    </button>
  </div>
  <input type="hidden" name="team_ids[]" value="team_legal" />
</div>

Framework-neutral compatibility

Porchlight does not bless an integration. Plain HTML, server-rendered pages, HATEOAS responses, htmx-like fragment replacement, Alpine-like state bindings, Vue, React, and other app frameworks should all emit the same HTML contract:

<div class="c-combobox">
  <div class="c-combobox__control">
    <input
      class="c-combobox__input"
      role="combobox"
      type="search"
      autocomplete="off"
      aria-autocomplete="list"
      aria-expanded="true"
      aria-controls="entity-list"
      aria-activedescendant="entity-1"
    />
  </div>
  <div class="c-combobox__popup" id="entity-list" role="listbox">
    <button
      class="c-combobox__option"
      id="entity-1"
      type="button"
      role="option"
      aria-selected="true"
    >
      <span class="c-combobox__label">Rendered entity</span>
    </button>
  </div>
</div>

Framework-specific attributes can exist in application templates, but Porchlight selectors do not depend on them. Fragment replacement should replace the popup, option rows, hidden values, or validation message while preserving IDs referenced by aria-controls, aria-activedescendant, and aria-describedby. Component frameworks should render the same attributes as plain HTML, not a Porchlight-specific adapter API.

Accessibility

  • Use a real <input> for editable autocomplete and a real <button> for select-only comboboxes.
  • Connect the combobox to its popup with aria-controls; update aria-expanded whenever the popup opens or closes.
  • For editable comboboxes, use aria-autocomplete="list" or "both" when the app provides inline completion.
  • Keep aria-activedescendant in sync with the option currently reached by keyboard navigation. Remove it when no option is active.
  • Set aria-selected="true" on the selected option. For transient keyboard focus before commit, apps may also add data-active.
  • Connect help or error text through aria-describedby; use aria-invalid="true" for server/framework validation errors.
  • Do not rely on CSS for behavior. The app must implement arrow keys, Home, End, Enter, Escape, pointer selection, outside-click closing, async request cancellation, and hidden value synchronization.

Tokens consumed

--pl-color-{border,surface,text,text-muted,accent,danger}, --pl-overlay-popover-bg, --pl-focus-color, --pl-focus-glow-opacity, --pl-control-{block-size,padding-inline,gap,radius,border-width}, --pl-menu-row-{min-block-size,padding-inline,radius,hover-bg}, --pl-accent-bar-width, --pl-shadow-3, --pl-z-overlay, --pl-duration-{1,2}, --pl-ease-standard.

Tokens exposed (component-local)

TokenDefaultPurpose
--c-combobox-min-inline16remMinimum desired popup inline size.
--c-combobox-popup-max-blockmin(18rem, 50dvb)Popup scroll limit.
--c-combobox-popup-offset--pl-space-1Gap between control and popup.
--c-combobox-option-gap--pl-space-1Gap between option rows.

Theme, density, RTL, motion

  • Light/dark: all colors resolve through framework tokens.
  • Density: control and option sizing follow --pl-control-* and --pl-menu-row-*, so ancestor [data-density] settings apply.
  • RTL: logical properties are used throughout.
  • Motion: only border, focus, and chevron transitions are present.
  • Forced colors: control and popup borders fall back to system colors.