Combobox
A select-only combobox. A button opens a panel with a list of options, and an optional search field in the panel filters it. Supports single and multiple selection, option groups, and signal forms.
Example
A single select with a clear button beside the trigger.
<div buiCombobox class="flex w-fit items-center gap-x-1" [(value)]="assignee">
<button matButton buiComboboxTrigger>
<mat-icon iconPositionEnd svgIcon="chevron-down"/>
{{ nameOf(assignee()[0]) || 'Unassigned' }}
</button>
@if (assignee().length) {
<button
matIconButton
class="small"
aria-label="Clear assignee"
(click)="assignee.set([])"
>
<mat-icon svgIcon="x"/>
</button>
}
<ng-template buiComboboxPortal matchWidth="false">
<div buiComboboxContent class="w-64">
<div
buiComboboxListbox
focusMode="activedescendant"
selectionMode="explicit"
>
@for (person of people; track person.id) {
<div
buiComboboxOption
[value]="person.id"
[label]="person.name"
[disabled]="person.disabled"
>
<span class="flex-auto truncate">{{ person.name }}</span>
<span buiComboboxOptionIndicator></span>
</div>
}
</div>
</div>
</ng-template>
</div>Structure
The combobox is fully directive-based. You compose it by applying attribute directives to standard HTML elements. The listbox and its options build on the Angular Aria listbox.
<div buiCombobox [(value)]="value">
<button matButton buiComboboxTrigger>Label</button>
<ng-template buiComboboxPortal>
<div buiComboboxContent>
<input buiComboboxSearch placeholder="Search"/>
<div
buiComboboxListbox
focusMode="activedescendant"
selectionMode="explicit"
>
<div buiComboboxGroup>
<div buiComboboxGroupLabel>Group</div>
<div buiComboboxOption value="one" label="One">
One
<span buiComboboxOptionIndicator></span>
</div>
</div>
<div buiComboboxEmpty>No results</div>
</div>
</div>
</ng-template>
</div>The building blocks are:
| Directive | Description |
|---|---|
buiCombobox | Root container. Holds the open state, the value, and the form state. |
buiComboboxTrigger | The button that opens the panel. Keeps the focus and relays the keyboard to the listbox. |
buiComboboxPortal | Wraps the content that is rendered inside a CDK overlay while the combobox is open. |
buiComboboxContent | The floating panel itself. Handles the entry animation. |
buiComboboxSearch | Search field at the top of the panel. Takes the focus on open. Optional. |
buiComboboxListbox | The list of options. Wraps the Angular Aria listbox. |
buiComboboxGroup | Labeled section of options inside the listbox. Optional. |
buiComboboxGroupLabel | Label of a group. Optional. |
buiComboboxOption | A selectable option. Wraps the Angular Aria option. |
buiComboboxOptionIndicator | Check mark shown on a selected option. Optional. |
buiComboboxOptionCustomIndicator | Shows your own element, such as an icon, on a selected option. Optional. |
buiComboboxEmpty | Message shown when the list has no options. Optional. |
buiCombobox
The root directive. It holds the open state and the selected values, and carries the form state. The value is always an array, also in a single select, where it holds at most one value. The root exposes data-disabled and data-invalid for CSS hooks.
<!-- Two-way value binding -->
<div buiCombobox [(value)]="selected">...</div>
<!-- Two-way open binding -->
<div buiCombobox [(expanded)]="isOpen">...</div>
<!-- Signal form field -->
<div buiCombobox [formField]="form.reviewers">...</div>
<!-- States -->
<div buiCombobox required [disabled]="locked()">...</div>| Prop | Type | Default | Description |
|---|---|---|---|
expanded | model<boolean> | false | Two-way binding for the open state. |
value | model<V[]> | [] | Two-way binding for the selected values. A single select holds at most one. |
disabled | boolean | false | Disables the trigger. The combobox ignores presses and keys. |
required | boolean | false | Sets aria-required on the trigger. |
invalid | boolean | false | Marks the value invalid. The error state shows once touched is also true. |
touched | boolean | false | Whether the user has interacted with the combobox. |
class | ClassValue | '' | Classes merged onto the root. Available on every directive. |
(touch) | void | — | Emits when the panel closes, or when the trigger loses focus while the panel is closed. |
Trigger
Apply buiComboboxTrigger to a button. A press toggles the panel, and the trigger keeps the focus while the panel is open. It carries role="combobox", aria-haspopup="listbox", aria-expanded, aria-controls, and aria-activedescendant pointing at the active option. data-state is open or closed. The label is yours to render, usually the selected name or a count.
<button matButton buiComboboxTrigger>
<mat-icon iconPositionEnd svgIcon="chevron-down"/>
{{ nameOf(value()[0]) || 'Unassigned' }}
</button>Portal
The buiComboboxPortal directive goes on an ng-template. Its content is projected into a CDK overlay attached to the trigger while the combobox is open. The panel closes on a press outside the trigger and the panel, or when focus moves outside them. It repositions when the trigger moves or resizes, for example when its label changes with the value.
<!-- As wide as the trigger (default) -->
<ng-template buiComboboxPortal>
<div buiComboboxContent>...</div>
</ng-template>
<!-- Own width, opening upwards -->
<ng-template buiComboboxPortal matchWidth="false" position="top-start">
<div buiComboboxContent class="w-64">...</div>
</ng-template>| Prop | Type | Default | Description |
|---|---|---|---|
position | ComboboxPosition | 'bottom-start' | Preferred placement relative to the trigger. Flips to the opposite side if there is not enough space. |
offset | number | 4 | Distance in pixels between the trigger and the panel. |
matchWidth | boolean | true | Sizes the panel to the trigger width when it opens. Set it to false and give buiComboboxContent its own width for a narrow trigger. |
The position input accepts one of 6 placement values:
top,top-start,top-endbottom,bottom-start,bottom-end
Content
The buiComboboxContent directive marks the floating panel. It provides the visual styling (border, shadow, background) and the entry animation. Place the search and the listbox inside it.
<div buiComboboxContent class="w-64">
<input buiComboboxSearch placeholder="Search"/>
<div buiComboboxListbox ...>...</div>
</div>Listbox
The buiComboboxListbox directive wraps the Angular Aria listbox and forwards its inputs. The combobox is built around two of them, so set them on every listbox: focusMode and selectionMode. Add multi for a multiple select. The list scrolls once it grows past its maximum height and keeps the active option in view.
<!-- Single select -->
<div
buiComboboxListbox
focusMode="activedescendant"
selectionMode="explicit"
>...</div>
<!-- Multiple select -->
<div
buiComboboxListbox
multi
focusMode="activedescendant"
selectionMode="explicit"
>...</div>| Prop | Type | Default | Description |
|---|---|---|---|
focusMode | 'activedescendant' | 'roving' | 'roving' | Set it to 'activedescendant'. Focus stays on the trigger or the search while the active option moves. |
selectionMode | 'explicit' | 'follow' | 'follow' | Set it to 'explicit'. An option is picked on Enter, Space, or a click, never by moving to it. |
wrap | boolean | true | The arrows wrap from the last option to the first and back. |
multi | boolean | false | Allows more than one selected option. |
typeaheadDelay | number | 500 | Time in milliseconds before the typeahead search resets. |
Selection
The combobox selects explicitly. Opening the panel activates the selected option, or nothing when there is none. A single select commits on pick and closes. Picking its selected option again confirms it rather than clearing it. A multi select toggles options and stays open. Enter with nothing active closes either one.
Clearing a single select is explicit too. Put a button beside the trigger that sets the value to an empty array, as in the example above.
<div buiCombobox class="flex w-fit items-center gap-x-1" [(value)]="assignee">
<button matButton buiComboboxTrigger>...</button>
@if (assignee().length) {
<button
matIconButton
class="small"
aria-label="Clear assignee"
(click)="assignee.set([])"
>
<mat-icon svgIcon="x"/>
</button>
}
...
</div>Options
The buiComboboxOption directive wraps the Angular Aria option. Its value is what lands in the combobox value, and its label is the text the typeahead matches. A disabled option stays visible but cannot be picked. The pointer and the keyboard share one highlight: hovering an option makes it the active one.
<div
buiComboboxOption
[value]="person.id"
[label]="person.name"
[disabled]="person.disabled"
>
<span class="flex-auto truncate">{{ person.name }}</span>
<span buiComboboxOptionIndicator></span>
</div>| Prop | Type | Default | Description |
|---|---|---|---|
value | V | — | The value the option adds to the selection. |
label | string | undefined | The text matched by the typeahead. |
disabled | boolean | false | Dims the option and keeps it from being picked. |
Indicators
Add buiComboboxOptionIndicator inside an option to show a check mark when it is selected. For a different mark, apply buiComboboxOptionCustomIndicator to your own element, such as a mat-icon. Both keep their space while hidden, so selecting an option never shifts its text.
<!-- Check mark at the end -->
<div buiComboboxOption [value]="person.id" [label]="person.name">
<span class="flex-auto truncate">{{ person.name }}</span>
<span buiComboboxOptionIndicator></span>
</div>
<!-- Your own icon at the start -->
<div buiComboboxOption [value]="person.id" [label]="person.name">
<mat-icon buiComboboxOptionCustomIndicator svgIcon="check"/>
<span class="flex-auto truncate">{{ person.name }}</span>
</div>Search
Add an input with buiComboboxSearch at the top of the content. It takes the focus when the panel opens and relays the arrows, Home, End, and Enter to the listbox, while other keys edit the text. Filtering is yours: keep the query in a signal, render the matching options, and reset the query on (expandedChange). Selected values the search hides stay selected.
Place buiComboboxEmpty inside the listbox, in the @empty block of the options loop, to show a message when nothing matches.
<div
buiCombobox
class="w-fit"
[(value)]="selected"
(expandedChange)="query.set('')"
>
<button matButton buiComboboxTrigger>
<mat-icon iconPositionEnd svgIcon="chevron-down"/>
{{ countOf(selected()) }}
</button>
<ng-template buiComboboxPortal matchWidth="false">
<div buiComboboxContent class="w-64">
<input
buiComboboxSearch
autocomplete="off"
placeholder="Search people"
[value]="query()"
(input)="query.set(search.value)"
#search
/>
<div
buiComboboxListbox
multi
focusMode="activedescendant"
selectionMode="explicit"
>
@for (person of results(); track person.id) {
<div buiComboboxOption [value]="person.id" [label]="person.name">
<span class="flex-auto truncate">{{ person.name }}</span>
<span buiComboboxOptionIndicator></span>
</div>
} @empty {
<div buiComboboxEmpty>No people found</div>
}
</div>
</div>
</ng-template>
</div>Multiple selection
With multi on the listbox, the panel stays open while options toggle. The trigger does not have to show the selection. Here the selected people render as chips beside a chip trigger, and removing a chip writes the value directly.
<div
buiCombobox
class="flex w-full max-w-80 flex-wrap items-center gap-2"
[(value)]="watchers"
(expandedChange)="query.set('')"
>
<mat-chip-set aria-label="Watchers">
@for (person of watcherPeople(); track person.id) {
<mat-chip (removed)="removeWatcher(person.id)">
{{ person.name }}
<button matChipRemove>
<mat-icon svgIcon="x"/>
</button>
</mat-chip>
}
</mat-chip-set>
<button mat-chip buiComboboxTrigger>
<mat-icon matChipAvatar svgIcon="plus"/>
Add
</button>
<ng-template buiComboboxPortal matchWidth="false">
<div buiComboboxContent class="w-64">
<input buiComboboxSearch ... />
<div
buiComboboxListbox
multi
focusMode="activedescendant"
selectionMode="explicit"
>...</div>
</div>
</ng-template>
</div>Panel actions
The panel can hold controls besides the options, such as a clear button for a multi select filter. Place them after the listbox, inside the content, so the listbox keeps its own padding. A mat-divider separates them, and the button carries the margin.
Tab reaches them from the trigger or the search, and Escape on them closes the panel. Activating one returns focus to the search, or to the trigger when there is none, so the arrows keep working. Removing the button once there is nothing to clear is fine, as focus still returns.
<div buiCombobox class="w-fit" [(value)]="filter">
<button matButton buiComboboxTrigger>
<mat-icon iconPositionEnd svgIcon="chevron-down"/>
{{ countOf(filter()) }}
</button>
<ng-template buiComboboxPortal matchWidth="false">
<div buiComboboxContent class="w-64">
<div buiComboboxListbox multi ...>...</div>
@if (filter().length) {
<mat-divider/>
<button
matButton
class="tertiary m-1 justify-start rounded-[calc(var(--theme-border-radius)*2/3)] px-2"
(click)="filter.set([])"
>
<mat-icon svgIcon="x"/>
Clear filter
</button>
}
</div>
</ng-template>
</div>Groups
Groups label sections of the one listbox. The options stay in a single list with a single value, so the arrows and the typeahead run across groups. buiComboboxGroup renders role="group", labeled by its buiComboboxGroupLabel. Separate groups with a full bleed mat-divider carrying -mx-1 my-0.5, and leave out the groups a search empties.
<div
buiComboboxListbox
focusMode="activedescendant"
selectionMode="explicit"
>
@for (team of results(); track team.label) {
@if (!$first) {
<mat-divider class="-mx-1 my-0.5"/>
}
<div buiComboboxGroup>
<div buiComboboxGroupLabel>{{ team.label }}</div>
@for (person of team.people; track person.id) {
<div buiComboboxOption [value]="person.id" [label]="person.name">
<span class="flex-auto truncate">{{ person.name }}</span>
<span buiComboboxOptionIndicator></span>
</div>
}
</div>
} @empty {
<div buiComboboxEmpty>No people found</div>
}
</div>Forms
Bind a signal form field with [formField] on the buiCombobox root. The field drives the value and the disabled, required, invalid, and touched states, and the touch output marks it touched. Without a form, bind the value with [(value)] instead. Reactive and template-driven forms are not supported.
errorState on the root is true once the combobox is both invalid and touched, and sets aria-invalid on the trigger. Read it through a template reference to switch between a hint and an error.
<div
buiCombobox
class="flex flex-col items-start gap-y-1"
[formField]="reviewersForm.reviewers"
#reviewers="buiCombobox"
>
<button matButton buiComboboxTrigger>
<mat-icon iconPositionEnd svgIcon="chevron-down"/>
{{ countOf(reviewersForm.reviewers().value()) }}
</button>
@if (reviewers.errorState()) {
@for (error of reviewersForm.reviewers().errors(); track error) {
<span class="text-xs text-error">{{ error.message }}</span>
}
} @else {
<span class="text-xs text-neutral-a11">
Everyone listed gets a review request
</span>
}
<ng-template buiComboboxPortal matchWidth="false">
<div buiComboboxContent class="w-64">
<div buiComboboxListbox multi ...>...</div>
</div>
</ng-template>
</div>Programmatic control
Use the expanded model for two-way binding of the open state, or grab a template reference via #ref="buiCombobox" and set it directly.
<!-- Two-way binding -->
<div buiCombobox [(expanded)]="isComboboxOpen">...</div>
<!-- Template reference -->
<div buiCombobox #myCombobox="buiCombobox">...</div>
<button matButton (click)="myCombobox.expanded.set(true)">Open</button>
<button matButton (click)="myCombobox.expanded.set(false)">Close</button>Keyboard
The keyboard works from the trigger and from the search. Both relay navigation to the listbox, so moving through the options never takes focus off them.
| Key | Behavior |
|---|---|
| ArrowDown, Enter, Space | Open the panel from the closed trigger. |
| ArrowDown, ArrowUp | Move the active option and wrap around, starting at the first or last option from nothing active. |
| Home, End | Move to the first or the last option. |
| Enter | Picks the active option. A single select closes, a multi select stays open. With nothing active, closes the panel. |
| Space | On the trigger, picks the active option and keeps the panel open. |
| Characters | On the trigger, move to the next option whose label matches. In the search, filter the list. |
| Ctrl+A, Cmd+A | On the trigger of a multi select, selects every option, or clears them when all are selected. |
| Escape | Closes the panel. An enclosing dialog stays open. |
| Tab | Steps through the other controls in the panel, then moves on and closes it. |
Focus and accessibility
The combobox follows the ARIA combobox pattern with a listbox popup. Focus stays on the trigger, or on the search while the panel shows one, and aria-activedescendant points at the active option. Options carry aria-selected and aria-disabled, and groups are labeled by their group label.
Closing the panel returns focus to the trigger when it was inside the panel. The panel also closes as soon as focus moves outside the trigger and the panel, so Tab never strands it open. Tab reaches controls placed in the panel and Escape closes from them, while activating one hands focus back to the search or the trigger.
See it in blocks
- Simple table filters the member table by role and status.
- With search filters pairs a searchable assignee filter and a status filter with a sort select.
- With filters opens a combobox from each filter chip, with search on the owner list.
- With view switcher pairs the same filter chips with a table and grid toggle.
- With dynamic filters adds and removes filter chips, each opening its own combobox.