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
| Selector | Role |
|---|---|
.c-combobox | Relative wrapper for the control and popup. |
.c-combobox--multi | Multi-select layout with wrapping chips. |
.c-combobox__control | Bordered control shell, or a trigger with role="combobox". |
.c-combobox__input | Text/search input with role="combobox". |
.c-combobox__value | Label stack for button-triggered select-only comboboxes. |
.c-combobox__chevron | Optional trailing disclosure icon. |
.c-combobox__popup | Positioned popup containing the listbox. |
.c-combobox__list | Optional list wrapper when options are grouped in a list. |
.c-combobox__option | Selectable row with role="option". |
.c-combobox__label | Primary option text. |
.c-combobox__description | Secondary option detail. |
.c-combobox__meta | Right-aligned option metadata. |
.c-combobox__status | Loading, empty, or helper row inside the popup. |
.c-combobox__chips | Wrapping 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; updatearia-expandedwhenever the popup opens or closes. - For editable comboboxes, use
aria-autocomplete="list"or"both"when the app provides inline completion. - Keep
aria-activedescendantin 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 adddata-active. - Connect help or error text through
aria-describedby; usearia-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 |
|---|---|---|
--c-combobox-min-inline | 16rem | Minimum desired popup inline size. |
--c-combobox-popup-max-block | min(18rem, 50dvb) | Popup scroll limit. |
--c-combobox-popup-offset | --pl-space-1 | Gap between control and popup. |
--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-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.