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
{# @prop id string Unique identifier for ARIA references and internal element IDs. #}
{# @prop name string|null If set, renders a hidden input for form submission. #}
{# @prop value string The initially selected value. Must match a choices entry. #}
{# @prop choices array List of choices: `[{value: '...', label: '...'}]` or grouped `[{label: '...', choices: [{value: '...', label: '...'}]}]`. #}
{# @prop placeholder string Text shown on the trigger when nothing is selected. #}
{# @prop searchPlaceholder string Placeholder inside the search input. #}
{# @prop emptyMessage string Shown when the filter matches nothing. #}
{# @prop disabled boolean Whether the widget is disabled. #}
{# @prop required boolean Adds required to the hidden input (only applies when name is set). #}
{# @prop clearable boolean Shows a clear button when a value is selected. #}
{%- props id, name = null, value = '', choices = [], placeholder = 'Select option...', searchPlaceholder = 'Search...', emptyMessage = 'No results found.', disabled = false, required = false, 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="mr-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>

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