View as Markdown
Combobox
Autocomplete input and command palette with a list of suggestions.
100%
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<div style="min-height: 220px">
<twig:Combobox
id="package"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
/>
</div>
Installation
php bin/console ux:install combobox --kit shadcn
Install the following Composer dependencies:
composer require symfony/ux-icons tales-from-a-dev/twig-tailwind-extra:^1.0.0
Copy the following file(s) into your app:
assets/controllers/combobox_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = [
'trigger',
'label',
'popover',
'search',
'option',
'empty',
'hiddenInput',
'group',
'clearButton',
];
static values = {
value: { type: String, default: '' },
placeholder: { type: String, default: 'Select option...' },
};
#activeIndex = -1;
#isOpen = false;
#outsideClickHandler = null;
connect() {
this.#outsideClickHandler = this.#onOutsideClick.bind(this);
}
disconnect() {
this.#close();
}
toggle() {
if (this.#isOpen) {
this.#close();
} else {
this.#open();
}
}
clear(event) {
event.stopPropagation();
this.valueValue = '';
}
onSearch(event) {
const query = event.target.value.toLowerCase();
let firstVisibleIndex = -1;
let visibleCount = 0;
const targets = this.optionTargets;
for (let i = 0; i < targets.length; i++) {
const matches = targets[i].dataset.label.toLowerCase().includes(query);
targets[i].hidden = !matches;
if (matches) {
if (firstVisibleIndex === -1) firstVisibleIndex = i;
visibleCount++;
}
}
for (const group of this.groupTargets) {
group.hidden = !targets.filter((o) => group.contains(o)).some((o) => !o.hidden);
}
this.emptyTarget.hidden = visibleCount > 0;
this.#setActive(firstVisibleIndex);
}
onSelect(event) {
this.#selectOption(event.currentTarget);
}
onOptionHover(event) {
const index = this.optionTargets.indexOf(event.currentTarget);
if (index !== -1 && !event.currentTarget.hidden) {
this.#setActive(index);
}
}
onTriggerKeydown(event) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.#open();
this.#setActive(this.#firstVisibleIndex());
break;
case 'ArrowUp':
event.preventDefault();
this.#open();
this.#setActive(this.#lastVisibleIndex());
break;
case 'Enter':
case ' ':
event.preventDefault();
this.#open();
break;
}
}
onSearchKeydown(event) {
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const next = this.#nextVisibleIndex(this.#activeIndex);
if (next !== -1) this.#setActive(next);
break;
}
case 'ArrowUp': {
event.preventDefault();
const prev = this.#prevVisibleIndex(this.#activeIndex);
if (prev !== -1) this.#setActive(prev);
break;
}
case 'Home':
event.preventDefault();
this.#setActive(this.#firstVisibleIndex());
break;
case 'End':
event.preventDefault();
this.#setActive(this.#lastVisibleIndex());
break;
case 'Enter':
event.preventDefault();
if (this.#activeIndex !== -1) {
this.#selectOption(this.optionTargets[this.#activeIndex]);
}
break;
case 'Escape':
event.preventDefault();
this.#close();
this.triggerTarget.focus();
break;
case 'Tab':
this.#close();
break;
}
}
valueValueChanged() {
this.#syncLabel();
this.#syncCheckIcons();
if (this.hasHiddenInputTarget) {
this.hiddenInputTarget.value = this.valueValue;
}
}
#open() {
if (this.#isOpen) return;
this.#isOpen = true;
this.searchTarget.value = '';
for (const option of this.optionTargets) {
option.hidden = false;
}
for (const group of this.groupTargets) {
group.hidden = false;
}
this.emptyTarget.hidden = true;
this.#setActive(-1);
const popover = this.popoverTarget;
popover.hidden = false;
popover.dataset.state = 'open';
this.#positionPopover();
this.triggerTarget.setAttribute('aria-expanded', 'true');
document.addEventListener('pointerdown', this.#outsideClickHandler);
requestAnimationFrame(() => {
this.searchTarget.focus();
});
}
#close() {
if (!this.#isOpen) return;
this.#isOpen = false;
const popover = this.popoverTarget;
popover.hidden = true;
popover.dataset.state = 'closed';
popover.style.cssText = '';
this.triggerTarget.setAttribute('aria-expanded', 'false');
document.removeEventListener('pointerdown', this.#outsideClickHandler);
}
#selectOption(option) {
const { value, label } = option.dataset;
this.valueValue = value;
this.dispatch('change', { detail: { value, label }, bubbles: true });
this.#close();
this.triggerTarget.focus();
}
#syncLabel() {
if (!this.hasLabelTarget) return;
const selected = this.hasOptionTarget
? this.optionTargets.find((o) => o.dataset.value === this.valueValue)
: null;
const label = selected ? selected.dataset.label : '';
this.labelTarget.textContent = label || this.placeholderValue;
this.labelTarget.classList.toggle('text-muted-foreground', !label);
if (this.hasClearButtonTarget) {
this.clearButtonTarget.hidden = !label;
}
}
#syncCheckIcons() {
if (!this.hasOptionTarget) return;
for (const option of this.optionTargets) {
const selected = option.dataset.value === this.valueValue;
option.setAttribute('aria-selected', String(selected));
const icon = option.querySelector('svg');
if (icon) {
icon.classList.toggle('opacity-0', !selected);
icon.classList.toggle('opacity-100', selected);
}
}
}
#setActive(index) {
if (this.#activeIndex !== -1 && this.optionTargets[this.#activeIndex]) {
delete this.optionTargets[this.#activeIndex].dataset.active;
}
this.#activeIndex = index;
if (index === -1) {
if (this.hasSearchTarget) {
this.searchTarget.removeAttribute('aria-activedescendant');
}
return;
}
const option = this.optionTargets[index];
if (!option) return;
option.dataset.active = '';
this.searchTarget.setAttribute('aria-activedescendant', option.id);
option.scrollIntoView({ block: 'nearest' });
}
#firstVisibleIndex() {
return this.optionTargets.findIndex((o) => !o.hidden);
}
#lastVisibleIndex() {
const targets = this.optionTargets;
for (let i = targets.length - 1; i >= 0; i--) {
if (!targets[i].hidden) return i;
}
return -1;
}
#nextVisibleIndex(from) {
const targets = this.optionTargets;
for (let i = from + 1; i < targets.length; i++) {
if (!targets[i].hidden) return i;
}
return from;
}
#prevVisibleIndex(from) {
const targets = this.optionTargets;
for (let i = from - 1; i >= 0; i--) {
if (!targets[i].hidden) return i;
}
return from;
}
#positionPopover() {
const triggerRect = this.triggerTarget.getBoundingClientRect();
const popover = this.popoverTarget;
const popoverHeight = popover.offsetHeight;
popover.style.position = 'fixed';
popover.style.width = `${triggerRect.width}px`;
popover.style.left = `${triggerRect.left}px`;
popover.style.zIndex = '50';
const spaceBelow = window.innerHeight - triggerRect.bottom;
if (spaceBelow < popoverHeight && triggerRect.top > spaceBelow) {
popover.style.top = `${Math.max(0, triggerRect.top - popoverHeight)}px`;
} else {
popover.style.top = `${triggerRect.bottom}px`;
}
}
#onOutsideClick(event) {
if (!this.element.contains(event.target) && !this.popoverTarget.contains(event.target)) {
this.#close();
}
}
}
templates/components/Combobox.html.twig
{%- props
## string Unique identifier for ARIA references and internal element IDs.
id,
## string|null If set, renders a hidden input for form submission.
name = null,
## string The initially selected value. Must match a choices entry.
value = '',
## array List of choices: `[{value: '...', label: '...'}]` or grouped `[{label: '...', choices: [{value: '...', label: '...'}]}]`.
choices = [],
## string Text shown on the trigger when nothing is selected.
placeholder = 'Select option...',
## string Placeholder inside the search input.
searchPlaceholder = 'Search...',
## string Shown when the filter matches nothing.
emptyMessage = 'No results found.',
## boolean Whether the widget is disabled.
disabled = false,
## boolean Adds required to the hidden input (only applies when name is set).
required = false,
## boolean Shows a clear button when a value is selected.
clearable = false
-%}
{%- set _is_grouped = choices is not empty and choices|first.choices is defined -%}
{%- set _selected_label = '' -%}
{%- if _is_grouped -%}
{%- for group in choices -%}
{%- for choice in group.choices -%}
{%- if choice.value == value -%}{%- set _selected_label = choice.label -%}{%- endif -%}
{%- endfor -%}
{%- endfor -%}
{%- else -%}
{%- for choice in choices -%}
{%- if choice.value == value -%}{%- set _selected_label = choice.label -%}{%- endif -%}
{%- endfor -%}
{%- endif -%}
<div
data-slot="combobox"
data-controller="combobox"
data-combobox-value-value="{{ value }}"
data-combobox-placeholder-value="{{ placeholder }}"
{{ attributes }}
>
<button
type="button"
role="combobox"
aria-expanded="false"
aria-controls="{{ id }}_listbox"
aria-haspopup="listbox"
data-combobox-target="trigger"
data-slot="combobox-trigger"
data-action="click->combobox#toggle keydown->combobox#onTriggerKeydown"
class="{{ ('flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50')|tailwind_merge }}"
{% if disabled %}disabled{% endif %}
>
<span
data-combobox-target="label"
class="{{ _selected_label ? '' : 'text-muted-foreground' }}"
>{{ _selected_label ?: placeholder }}</span>
<span class="flex shrink-0 items-center gap-1">
{% if clearable %}
<span
aria-label="Clear selection"
data-combobox-target="clearButton"
data-action="click->combobox#clear"
data-slot="combobox-clear"
{% if not _selected_label %}hidden{% endif %}
class="rounded-sm opacity-50 hover:opacity-100"
><twig:ux:icon name="lucide:x" class="size-4 shrink-0" /></span>
{% endif %}
<twig:ux:icon name="lucide:chevrons-up-down" class="size-4 shrink-0 opacity-50" />
</span>
</button>
{% if name is not null %}
<input
type="hidden"
name="{{ name }}"
value="{{ value }}"
data-combobox-target="hiddenInput"
{% if required %}required{% endif %}
/>
{% endif %}
<div
id="{{ id }}_popover"
data-combobox-target="popover"
data-slot="combobox-content"
data-state="closed"
hidden
class="z-50 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md"
>
<div class="flex items-center border-b border-border px-3" data-slot="combobox-input-wrapper">
<twig:ux:icon name="lucide:search" class="me-2 size-4 shrink-0 opacity-50" />
<input
type="text"
role="searchbox"
aria-controls="{{ id }}_listbox"
aria-autocomplete="list"
placeholder="{{ searchPlaceholder }}"
data-combobox-target="search"
data-action="input->combobox#onSearch keydown->combobox#onSearchKeydown"
class="{{ ('flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50')|tailwind_merge }}"
/>
</div>
<div
id="{{ id }}_listbox"
role="listbox"
data-slot="combobox-list"
class="max-h-[300px] overflow-x-hidden overflow-y-auto p-1"
>
{% if _is_grouped %}
{% for group in choices %}
<div
role="group"
aria-labelledby="{{ id }}_group_{{ loop.index0 }}"
data-combobox-target="group"
data-slot="combobox-group"
>
<div
id="{{ id }}_group_{{ loop.index0 }}"
data-slot="combobox-group-label"
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>{{ group.label }}</div>
{% for choice in group.choices %}
<div
role="option"
id="{{ id }}_option_{{ loop.parent.loop.index0 }}_{{ loop.index0 }}"
data-combobox-target="option"
data-value="{{ choice.value }}"
data-label="{{ choice.label }}"
data-slot="combobox-item"
aria-selected="{{ choice.value == value ? 'true' : 'false' }}"
data-action="click->combobox#onSelect mouseenter->combobox#onOptionHover"
class="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[active]:bg-accent data-[active]:text-accent-foreground"
>
<twig:ux:icon name="lucide:check" class="size-4 {{ choice.value == value ? 'opacity-100' : 'opacity-0' }}" />
{{ choice.label }}
</div>
{% endfor %}
</div>
{% endfor %}
{% else %}
{% for choice in choices %}
<div
role="option"
id="{{ id }}_option_{{ loop.index0 }}"
data-combobox-target="option"
data-value="{{ choice.value }}"
data-label="{{ choice.label }}"
data-slot="combobox-item"
aria-selected="{{ choice.value == value ? 'true' : 'false' }}"
data-action="click->combobox#onSelect mouseenter->combobox#onOptionHover"
class="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[active]:bg-accent data-[active]:text-accent-foreground"
>
<twig:ux:icon name="lucide:check" class="size-4 {{ choice.value == value ? 'opacity-100' : 'opacity-0' }}" />
{{ choice.label }}
</div>
{% endfor %}
{% endif %}
<div
data-combobox-target="empty"
data-slot="combobox-empty"
hidden
class="py-6 text-center text-sm text-muted-foreground"
>{{ emptyMessage }}</div>
</div>
</div>
</div>
Usage
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<twig:Combobox
id="package-usage"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
/>
Examples
With Default Value
100%
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<div style="min-height: 220px">
<twig:Combobox
id="package-default"
value="live-component"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
/>
</div>
With Form
100%
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<div style="min-height: 220px">
<form method="post" action="#" class="flex max-w-xs flex-col gap-4">
<twig:Combobox
id="package-form"
name="package"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
:required="true"
/>
<button
type="submit"
class="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-xs hover:bg-primary/90"
>Submit</button>
</form>
</div>
Disabled
100%
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
] %}
<twig:Combobox
id="package-disabled"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
:disabled="true"
/>
Clearable
100%
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<div style="min-height: 220px">
<twig:Combobox
id="package-clearable"
value="live-component"
placeholder="Select package..."
searchPlaceholder="Search packages..."
clearable
:choices="packages"
/>
</div>
With Groups
100%
Loading...
{% set packages = [
{label: 'Stimulus', choices: [
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'turbo', label: 'UX Turbo'},
]},
{label: 'Frontend Frameworks', choices: [
{value: 'react', label: 'UX React'},
{value: 'vue', label: 'UX Vue'},
{value: 'svelte', label: 'UX Svelte'},
]},
{label: 'UI & Visualization', choices: [
{value: 'icons', label: 'UX Icons'},
{value: 'chartjs', label: 'UX Chart.js'},
{value: 'map', label: 'UX Map'},
]},
] %}
<div style="min-height: 504px">
<twig:Combobox
id="package-grouped"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
/>
</div>
Empty State
100%
Loading...
{% set frameworks = [
{value: 'next', label: 'Next.js'},
{value: 'sveltekit', label: 'SvelteKit'},
] %}
<div style="min-height: 220px">
<twig:Combobox
id="framework-empty"
placeholder="Select framework..."
searchPlaceholder="Search framework..."
emptyMessage="No frameworks found. Try a different search."
:choices="frameworks"
/>
</div>
Long List
100%
Loading...
{% set languages = [
{value: 'php', label: 'PHP'},
{value: 'javascript', label: 'JavaScript'},
{value: 'typescript', label: 'TypeScript'},
{value: 'python', label: 'Python'},
{value: 'ruby', label: 'Ruby'},
{value: 'go', label: 'Go'},
{value: 'rust', label: 'Rust'},
{value: 'java', label: 'Java'},
{value: 'kotlin', label: 'Kotlin'},
{value: 'swift', label: 'Swift'},
{value: 'csharp', label: 'C#'},
{value: 'cpp', label: 'C++'},
{value: 'c', label: 'C'},
{value: 'scala', label: 'Scala'},
{value: 'elixir', label: 'Elixir'},
{value: 'erlang', label: 'Erlang'},
{value: 'haskell', label: 'Haskell'},
{value: 'ocaml', label: 'OCaml'},
{value: 'fsharp', label: 'F#'},
{value: 'clojure', label: 'Clojure'},
{value: 'dart', label: 'Dart'},
{value: 'r', label: 'R'},
{value: 'matlab', label: 'MATLAB'},
{value: 'perl', label: 'Perl'},
{value: 'lua', label: 'Lua'},
{value: 'julia', label: 'Julia'},
{value: 'groovy', label: 'Groovy'},
{value: 'shell', label: 'Shell'},
{value: 'powershell', label: 'PowerShell'},
{value: 'sql', label: 'SQL'},
{value: 'html', label: 'HTML'},
{value: 'css', label: 'CSS'},
{value: 'sass', label: 'Sass'},
{value: 'less', label: 'Less'},
{value: 'graphql', label: 'GraphQL'},
{value: 'yaml', label: 'YAML'},
{value: 'toml', label: 'TOML'},
{value: 'json', label: 'JSON'},
{value: 'xml', label: 'XML'},
{value: 'markdown', label: 'Markdown'},
{value: 'latex', label: 'LaTeX'},
{value: 'zig', label: 'Zig'},
{value: 'nim', label: 'Nim'},
{value: 'crystal', label: 'Crystal'},
{value: 'reason', label: 'ReasonML'},
{value: 'elm', label: 'Elm'},
{value: 'purescript', label: 'PureScript'},
{value: 'coffeescript', label: 'CoffeeScript'},
{value: 'objective-c', label: 'Objective-C'},
{value: 'vba', label: 'VBA'},
{value: 'cobol', label: 'COBOL'},
] %}
<div style="min-height: 454px">
<twig:Combobox
id="language"
placeholder="Select language..."
searchPlaceholder="Search languages..."
:choices="languages"
/>
</div>
Accessibility
- The trigger renders
role="combobox"witharia-haspopup="listbox",aria-expandedand anaria-controlspointing at the listbox, so assistive tech announces the widget and its state. - The list renders
role="listbox", each entryrole="option"witharia-selected, and a grouped list wraps its entries inrole="group"labelled by the group heading. - The filter input renders
role="searchbox"witharia-autocomplete="list", so typing is announced as filtering rather than free text entry. ArrowDownandArrowUpmove through the options,HomeandEndjump to the first and last,Enterselects the highlighted option andEscapecloses the list and returns focus to the trigger.- Set
idso the internal ARIA references are unique, and give the combobox a visibleLabelbound withfor, since the trigger's own text is only the current selection. - The clear button carries
aria-label="Clear selection". Translate it when your interface is not in English.
API Reference
<twig:Combobox>
| Prop | Type | Default |
|---|---|---|
id Unique identifier for ARIA references and internal element IDs.
|
string |
- |
name If set, renders a hidden input for form submission.
|
string|null |
null |
value The initially selected value. Must match a choices entry.
|
string |
'' |
choices List of choices:
[{value: '...', label: '...'}] or grouped [{label: '...', choices: [{value: '...', label: '...'}]}]. |
array |
[] |
placeholder Text shown on the trigger when nothing is selected.
|
string |
'Select option...' |
searchPlaceholder Placeholder inside the search input.
|
string |
'Search...' |
emptyMessage Shown when the filter matches nothing.
|
string |
'No results found.' |
disabled Whether the widget is disabled.
|
boolean |
false |
required Adds required to the hidden input (only applies when name is set).
|
boolean |
false |
clearable Shows a clear button when a value is selected.
|
boolean |
false |