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" with aria-haspopup="listbox", aria-expanded and an aria-controls pointing at the listbox, so assistive tech announces the widget and its state.
  • The list renders role="listbox", each entry role="option" with aria-selected, and a grouped list wraps its entries in role="group" labelled by the group heading.
  • The filter input renders role="searchbox" with aria-autocomplete="list", so typing is announced as filtering rather than free text entry.
  • ArrowDown and ArrowUp move through the options, Home and End jump to the first and last, Enter selects the highlighted option and Escape closes the list and returns focus to the trigger.
  • Set id so the internal ARIA references are unique, and give the combobox a visible Label bound with for, 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 
string -
name 
string|null null
value 
string ''
choices 
array []
placeholder 
string 'Select option...'
searchPlaceholder 
string 'Search...'
emptyMessage 
string 'No results found.'
disabled 
boolean false
required 
boolean false
clearable 
boolean false