ComponentPorchlight CSS

Combobox

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

51 components9 patterns46 stable5 experimental

Command

Search Porchlight

Combobox

The .pl-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="pl-c-field">
  <label class="pl-c-field__label" for="assignee-lookup">Assignee</label>
  <div class="pl-c-combobox">
    <div class="pl-c-combobox__control">
      <input
        id="assignee-lookup"
        class="pl-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="pl-c-combobox__chevron" aria-hidden="true">...</svg>
    </div>
    <div
      class="pl-c-combobox__popup"
      id="assignee-list"
      role="listbox"
      aria-label="Assignee suggestions"
    >
      <button
        class="pl-c-combobox__option"
        id="assignee-nia"
        type="button"
        role="option"
        aria-selected="true"
      >
        <span class="pl-c-combobox__label">Nia Gomez</span>
        <span class="pl-c-combobox__meta">Ops</span>
        <span class="pl-c-combobox__description"
          >Process owner - 14 open cases</span
        >
      </button>
      <button
        class="pl-c-combobox__option"
        id="assignee-lee"
        type="button"
        role="option"
        aria-selected="false"
      >
        <span class="pl-c-combobox__label">Lee Chen</span>
        <span class="pl-c-combobox__meta">Finance</span>
      </button>
    </div>
    <input type="hidden" name="assignee_id" value="usr_124" />
  </div>
  <span class="pl-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

Selector Role
.pl-c-combobox Relative wrapper for the control and popup.
.pl-c-combobox--multi Multi-select layout with wrapping chips.
.pl-c-combobox__control Bordered control shell, or a trigger with role="combobox".
.pl-c-combobox__input Text/search input with role="combobox".
.pl-c-combobox__value Label stack for button-triggered select-only comboboxes.
.pl-c-combobox__chevron Optional trailing disclosure icon.
.pl-c-combobox__popup Positioned popup containing the listbox.
.pl-c-combobox__list Optional list wrapper when options are grouped in a list.
.pl-c-combobox__option Selectable row with role="option".
.pl-c-combobox__label Primary option text.
.pl-c-combobox__description Secondary option detail.
.pl-c-combobox__meta Right-aligned option metadata.
.pl-c-combobox__status Loading, empty, or helper row inside the popup.
.pl-c-combobox__chips Wrapping selected-value chips for multi-select.
[data-active] Optional active descendant styling hook.
[data-loading] Loading indicator on .pl-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="pl-c-combobox">
  <div class="pl-c-combobox__control">
    <input
      class="pl-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="pl-c-combobox__popup" id="customer-list" role="listbox">
    <button
      class="pl-c-combobox__option"
      id="customer-acme"
      type="button"
      role="option"
      aria-selected="true"
    >
      <span class="pl-c-combobox__label">Acme Operations</span>
      <span class="pl-c-combobox__meta">Customer</span>
      <span class="pl-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="pl-c-combobox">
  <div class="pl-c-combobox__control">
    <input
      class="pl-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="pl-c-combobox__popup" id="vendor-list" role="listbox">
    <div
      class="pl-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="pl-c-combobox">
  <div class="pl-c-combobox__control">
    <input
      class="pl-c-combobox__input"
      role="combobox"
      type="search"
      aria-autocomplete="list"
      aria-expanded="true"
      aria-controls="process-list"
      value="offboard"
    />
  </div>
  <div class="pl-c-combobox__popup" id="process-list" role="listbox">
    <div class="pl-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="pl-c-combobox">
  <button
    class="pl-c-combobox__control"
    type="button"
    role="combobox"
    aria-expanded="true"
    aria-controls="status-list"
    aria-activedescendant="status-review"
  >
    <span class="pl-c-combobox__value">
      <span class="pl-c-combobox__label">Needs review</span>
      <span class="pl-c-combobox__description">Paused before approval</span>
    </span>
    <svg class="pl-c-combobox__chevron" aria-hidden="true">...</svg>
  </button>
  <div class="pl-c-combobox__popup" id="status-list" role="listbox">
    <button
      class="pl-c-combobox__option"
      id="status-review"
      type="button"
      role="option"
      aria-selected="true"
    >
      <span class="pl-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="pl-c-field">
  <label class="pl-c-field__label" for="required-process">
    Process
    <span class="pl-c-field__required" aria-hidden="true">*</span>
  </label>
  <div class="pl-c-combobox">
    <div class="pl-c-combobox__control">
      <input
        id="required-process"
        class="pl-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="pl-c-combobox__popup"
      id="required-process-list"
      role="listbox"
      hidden
    ></div>
  </div>
  <span
    class="pl-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="pl-c-combobox pl-c-combobox--multi">
  <div class="pl-c-combobox__control">
    <div class="pl-c-combobox__chips">
      <span class="pl-c-chip">
        Legal
        <button
          class="pl-c-chip__remove"
          type="button"
          aria-label="Remove Legal"
        >
          x
        </button>
      </span>
      <input
        class="pl-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="pl-c-combobox__popup" id="team-list" role="listbox">
    <button
      class="pl-c-combobox__option"
      id="team-finance"
      type="button"
      role="option"
      aria-selected="false"
    >
      <span class="pl-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="pl-c-combobox">
  <div class="pl-c-combobox__control">
    <input
      class="pl-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="pl-c-combobox__popup" id="entity-list" role="listbox">
    <button
      class="pl-c-combobox__option"
      id="entity-1"
      type="button"
      role="option"
      aria-selected="true"
    >
      <span class="pl-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)

Token Default Purpose
--pl-c-combobox-min-inline 16rem Minimum desired popup inline size.
--pl-c-combobox-popup-max-block min(18rem, 50dvb) Popup scroll limit.
--pl-c-combobox-popup-offset --pl-space-1 Gap between control and popup.
--pl-c-combobox-option-gap --pl-space-1 Gap 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-pl-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.