View as Markdown

Combobox

Autocomplete input with a list of suggestions.

100%
Loading...
{% set frameworks = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 280px">
    <twig:Combobox id="framework-demo">
        <twig:Combobox:Input placeholder="Select a framework" />
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No items found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for framework in frameworks %}
                    <twig:Combobox:Item value="{{ framework }}">{{ framework }}</twig:Combobox:Item>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Installation

Note

Available since UX Toolkit 3.3.

php bin/console ux:install combobox --kit shadcn

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 symfony/ux-icons symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0

Install the following front-end dependencies:

npm install shadcn tw-animate-css
# ...or with Symfony AssetMapper
php bin/console importmap:require tw-animate-css/dist/tw-animate.css shadcn/dist/tailwind.css

Copy the following file(s) into your app:

assets/controllers/combobox_controller.js
import { Controller } from '@hotwired/stimulus';

/**
 * Behavior of the `Combobox` component: filtering, keyboard navigation, single and multiple
 * selection, and the hidden inputs carrying the selection on form submission.
 *
 * The selection is always held as an array of values, even when `multiple` is off, so a single
 * code path drives the items, the chips and the hidden inputs. Labels are read back from the
 * items themselves, so the server only ever has to render values.
 *
 * @value  open          Whether the list is open.
 * @value  multiple      Whether several values can be selected at once, displayed as chips.
 * @value  autoHighlight Whether the first matching item is highlighted while filtering.
 * @value  required      Whether a value must be selected before the form can be submitted.
 * @value  name          The name of the hidden inputs carrying the selection.
 * @value  value         The selected values.
 * @target input         The text input filtering the list and reflecting the selection.
 * @target trigger       The element toggling the list and reflecting its expanded state.
 * @target clear         The element resetting the selection, hidden while nothing is selected.
 * @target content       The floating surface holding the list.
 * @target list          The listbox holding the items.
 * @target item          A selectable item of the list.
 * @target group         A group of items, hidden when the filter empties it.
 * @target chips         The container holding the chips of a multiple selection.
 * @target chip          A chip standing for one selected value.
 * @target chipTemplate  The template cloned to build a chip.
 * @target value         The element displaying the selection when there is no text input.
 * @action toggle        Opens the list, or closes it when it is already open.
 * @action open          Opens the list.
 * @action clear         Resets the selection.
 * @action filter        Narrows the list down to the items matching the typed text.
 * @action select        Adds the clicked item to the selection, or removes it when `multiple` is on.
 * @action highlight     Highlights the item under the pointer.
 * @action removeChip    Removes from the selection the value the clicked chip stands for.
 * @action focusInput    Moves the focus to the text input.
 * @action onInputKeydown Handles the keyboard navigation from the text input.
 * @action handleOutsideClick Closes the list when the click landed outside the component.
 * @action handleEscape  Closes the list and gives the focus back to the text input.
 */
export default class extends Controller {
    static targets = [
        'input',
        'trigger',
        'clear',
        'content',
        'list',
        'item',
        'group',
        'chips',
        'chip',
        'chipTemplate',
        'value',
    ];

    static values = {
        open: { type: Boolean, default: false },
        multiple: { type: Boolean, default: false },
        autoHighlight: { type: Boolean, default: false },
        required: { type: Boolean, default: false },
        name: { type: String, default: '' },
        value: { type: Array, default: [] },
    };

    connect() {
        this.#identifyItems();
        this.#reflectState();
        this.#reflectSelection();
    }

    toggle(event) {
        event?.preventDefault();

        if (this.openValue) {
            this.close();
        } else {
            this.open();
        }
    }

    open() {
        if (this.openValue) {
            return;
        }

        this.#resetFilter();
        this.#highlight(this.autoHighlightValue ? this.#visibleItems()[0] : null);
        this.openValue = true;

        if (this.#inputInContent) {
            // Stimulus calls `openValueChanged()` only once it observes the change: until then the content is hidden and its input cannot take the focus.
            this.#reflectState();
            this.focusInput();
        }
    }

    close() {
        if (!this.openValue) {
            return;
        }

        this.#highlight(null);
        this.openValue = false;
        this.#resetQuery();
        this.#resetFilter();
    }

    clear(event) {
        event?.preventDefault();
        event?.stopPropagation();

        this.valueValue = [];
        this.#resetQuery();
        this.#resetFilter();
        this.focusInput();
    }

    filter() {
        const query = this.#query;
        let matched = 0;

        for (const item of this.itemTargets) {
            const matches = '' === query || this.#labelOf(item).toLowerCase().includes(query);
            item.hidden = !matches;
            matched += matches ? 1 : 0;
        }

        for (const group of this.groupTargets) {
            group.hidden = !this.itemTargets.some((item) => !item.hidden && group.contains(item));
        }

        this.#markEmpty(0 === matched);
        this.openValue = true;
        this.#highlight(this.autoHighlightValue ? this.#visibleItems()[0] : null);
    }

    select(event) {
        const item = event.currentTarget;
        if ('true' === item.dataset.disabled) {
            return;
        }

        const value = item.dataset.value;

        if (!this.multipleValue) {
            this.valueValue = [value];
            this.close();
            this.#restoreFocus();

            return;
        }

        this.valueValue = this.valueValue.includes(value)
            ? this.valueValue.filter((selected) => selected !== value)
            : [...this.valueValue, value];

        this.#resetQuery();
        this.#resetFilter();
        this.focusInput();
    }

    highlight(event) {
        const item = event.currentTarget;
        if (item.hidden || 'true' === item.dataset.disabled) {
            return;
        }

        this.#highlight(item);
    }

    removeChip(event) {
        event.preventDefault();
        event.stopPropagation();

        const chip = event.currentTarget.closest('[data-slot="combobox-chip"]');
        if (null === chip) {
            return;
        }

        this.valueValue = this.valueValue.filter((value) => value !== chip.dataset.value);
        this.focusInput();
    }

    focusInput() {
        this.inputTargets[0]?.focus();
    }

    onInputKeydown(event) {
        const items = this.#visibleItems();
        const current = items.indexOf(this.#highlighted);

        switch (event.key) {
            case 'ArrowDown':
                event.preventDefault();
                this.open();
                this.#highlight(items[Math.min(current + 1, items.length - 1)] ?? items[0]);
                break;
            case 'ArrowUp':
                event.preventDefault();
                this.open();
                this.#highlight(-1 === current ? items[items.length - 1] : items[Math.max(current - 1, 0)]);
                break;
            case 'Home':
                event.preventDefault();
                this.#highlight(items[0]);
                break;
            case 'End':
                event.preventDefault();
                this.#highlight(items[items.length - 1]);
                break;
            case 'Enter':
                if (this.openValue && null !== this.#highlighted) {
                    event.preventDefault();
                    this.#highlighted.click();
                }
                break;
            case 'Escape':
                event.preventDefault();
                this.close();
                this.#restoreFocus();
                break;
            case 'Tab':
                this.close();
                break;
            case 'Backspace':
                if (this.multipleValue && '' === event.currentTarget.value && this.valueValue.length > 0) {
                    this.valueValue = this.valueValue.slice(0, -1);
                }
                break;
        }
    }

    handleOutsideClick(event) {
        if (!this.openValue || this.element.contains(event.target)) {
            return;
        }

        this.close();
    }

    handleEscape() {
        if (!this.openValue) {
            return;
        }

        this.close();
        this.#restoreFocus();
    }

    openValueChanged() {
        this.#reflectState();
    }

    valueValueChanged() {
        this.#reflectSelection();
    }

    get #query() {
        return (this.inputTargets[0]?.value ?? '').trim().toLowerCase();
    }

    // The text input carries the selection, unless a `Combobox:Value` or chips already display it,
    // in which case it is only a filter and must be left empty.
    get #inputDisplaysSelection() {
        return !this.multipleValue && !this.hasValueTarget;
    }

    // In a popup, the input sits in the content and the list is opened from a trigger.
    get #inputInContent() {
        return this.hasInputTarget && this.contentTargets.some((content) => content.contains(this.inputTarget));
    }

    get #highlighted() {
        return this.itemTargets.find((item) => item.hasAttribute('data-highlighted')) ?? null;
    }

    #visibleItems() {
        return this.itemTargets.filter((item) => !item.hidden && 'true' !== item.dataset.disabled);
    }

    #labelOf(item) {
        return item.dataset.label || item.textContent.trim();
    }

    #labelFor(value) {
        const item = this.itemTargets.find((candidate) => candidate.dataset.value === value);

        return null == item ? value : this.#labelOf(item);
    }

    // Items are addressed by `aria-activedescendant`, which needs an id; the server does not render
    // one because a value is free-form text and would not always make a valid id. The list id is
    // derived from the `id` prop, so it keeps the generated ids unique across several comboboxes.
    #identifyItems() {
        const prefix = this.listTargets[0]?.id || this.contentTargets[0]?.id || 'combobox';

        this.itemTargets.forEach((item, index) => {
            if ('' === item.id) {
                item.id = `${prefix}-item-${index}`;
            }
        });
    }

    #resetFilter() {
        for (const item of this.itemTargets) {
            item.hidden = false;
        }

        for (const group of this.groupTargets) {
            group.hidden = false;
        }

        this.#markEmpty(false);
    }

    #resetQuery() {
        const values = this.valueValue;
        const query = this.#inputDisplaysSelection && values.length > 0 ? this.#labelFor(values[0]) : '';

        for (const input of this.inputTargets) {
            input.value = query;
        }
    }

    #restoreFocus() {
        if (this.#inputInContent) {
            this.triggerTargets[0]?.focus();
        } else {
            this.focusInput();
        }
    }

    #markEmpty(empty) {
        for (const element of [...this.contentTargets, ...this.listTargets]) {
            element.toggleAttribute('data-empty', empty);
        }
    }

    #highlight(item) {
        for (const other of this.itemTargets) {
            other.removeAttribute('data-highlighted');
        }

        if (null == item) {
            for (const input of this.inputTargets) {
                input.removeAttribute('aria-activedescendant');
            }

            return;
        }

        item.setAttribute('data-highlighted', '');
        item.scrollIntoView({ block: 'nearest' });

        for (const input of this.inputTargets) {
            input.setAttribute('aria-activedescendant', item.id);
        }
    }

    #reflectState() {
        const open = this.openValue;
        const state = open ? 'open' : 'closed';

        this.element.dataset.state = state;

        for (const trigger of this.triggerTargets) {
            trigger.dataset.state = state;
            trigger.setAttribute('aria-expanded', String(open));
        }

        for (const input of this.inputTargets) {
            input.setAttribute('aria-expanded', String(open));
        }

        for (const content of this.contentTargets) {
            content.dataset.state = state;
            content.setAttribute('aria-hidden', String(!open));
        }
    }

    #reflectSelection() {
        const values = this.valueValue;
        const empty = 0 === values.length;

        for (const item of this.itemTargets) {
            const selected = values.includes(item.dataset.value);
            item.dataset.selected = String(selected);
            item.setAttribute('aria-selected', String(selected));
        }

        for (const value of this.valueTargets) {
            value.dataset.empty = String(empty);
            value.textContent = empty
                ? (value.dataset.placeholder ?? '')
                : values.map((selected) => this.#labelFor(selected)).join(', ');
        }

        for (const clear of this.clearTargets) {
            clear.hidden = empty;
        }

        if (this.multipleValue) {
            this.#reflectChips(values);
        } else {
            this.#resetQuery();
        }

        for (const input of this.inputTargets) {
            input.required = this.requiredValue && empty;
        }

        this.#reflectHiddenInputs(values);
    }

    #reflectChips(values) {
        if (!this.hasChipsTarget || !this.hasChipTemplateTarget) {
            return;
        }

        for (const chip of this.chipTargets) {
            chip.remove();
        }

        const template = this.chipTemplateTarget.content.firstElementChild;
        if (null === template) {
            return;
        }

        const chips = document.createDocumentFragment();

        for (const value of values) {
            const chip = template.cloneNode(true);
            chip.dataset.value = value;

            const label = chip.querySelector('[data-slot="combobox-chip-label"]');
            if (null !== label) {
                label.textContent = this.#labelFor(value);
            }

            chips.appendChild(chip);
        }

        this.chipsTarget.prepend(chips);
    }

    #reflectHiddenInputs(values) {
        for (const input of this.element.querySelectorAll('[data-combobox-hidden-input]')) {
            input.remove();
        }

        if ('' === this.nameValue) {
            return;
        }

        const name = this.multipleValue ? `${this.nameValue}[]` : this.nameValue;
        const submitted = this.multipleValue ? values : [values[0] ?? ''];

        for (const value of submitted) {
            const input = document.createElement('input');
            input.type = 'hidden';
            input.name = name;
            input.value = value;
            input.setAttribute('data-combobox-hidden-input', '');
            this.element.appendChild(input);
        }
    }
}
templates/components/Combobox.html.twig
{%- props
    ## string Unique identifier used to generate the internal Combobox IDs.
    id,
    ## string|null If set, renders the hidden inputs carrying the selection on form submission.
    name = null,
    ## string|array<string>|null The initially selected value, or values when `multiple` is enabled.
    value = null,
    ## boolean Whether several values can be selected at once, displayed as chips.
    multiple = false,
    ## boolean Whether the first matching item is highlighted while filtering.
    autoHighlight = false,
    ## boolean Whether the list is open on initial render.
    open = false,
    ## boolean Whether a value must be selected before the form can be submitted.
    required = false
-%}
{%- set _value = null == value ? [] : (value is iterable ? value : [value]) -%}
{%- do provide('combobox.id', id) -%}
{%- do provide('combobox.open', open) -%}
{%- do provide('combobox.multiple', multiple) -%}
{%- do provide('combobox.required', required) -%}
{%- do provide('combobox.value', _value) -%}
{%- do provide('combobox.inputId', 'combobox-' ~ id ~ '-input') -%}
{%- do provide('combobox.listId', 'combobox-' ~ id ~ '-list') -%}
{%- do provide('combobox.contentId', 'combobox-' ~ id ~ '-content') -%}
<div
    data-slot="combobox"
    data-state="{{ open ? 'open' : 'closed' }}"
    data-multiple="{{ multiple ? 'true' : 'false' }}"
    data-combobox-open-value="{{ open ? 'true' : 'false' }}"
    data-combobox-multiple-value="{{ multiple ? 'true' : 'false' }}"
    data-combobox-auto-highlight-value="{{ autoHighlight ? 'true' : 'false' }}"
    data-combobox-required-value="{{ required ? 'true' : 'false' }}"
    data-combobox-name-value="{{ name }}"
    data-combobox-value-value="{{ _value|json_encode }}"
    {{ attributes.defaults({
        class: 'group/combobox relative w-full'|tailwind_classes,
        'data-controller': 'combobox',
        'data-action': [
            'click@window->combobox#handleOutsideClick',
            'keydown.esc@window->combobox#handleEscape',
        ]|join(' ')|html_attr_type('sst'),
    }) }}
>
    {##- A `Combobox:Input` (or a `Combobox:Chips`) and a `Combobox:Content`. -#}
    {%- block content %}{% endblock -%}
    {%- if null != name -%}
        {%- if multiple -%}
            {%- for selected in _value -%}
                <input type="hidden" name="{{ name ~ '[]' }}" value="{{ selected }}" data-combobox-hidden-input>
            {%- endfor -%}
        {%- else -%}
            <input type="hidden" name="{{ name }}" value="{{ _value|first|default('') }}" data-combobox-hidden-input>
        {%- endif -%}
    {%- endif -%}
</div>
templates/components/Combobox/Chip.html.twig
{%- props
    ## string The value the chip stands for.
    value,
    ## boolean Whether the chip renders a button removing it from the selection.
    showRemove = true
-%}
<span
    data-slot="combobox-chip"
    data-combobox-target="chip"
    data-value="{{ value }}"
    {{ attributes.defaults({
        class: 'flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 whitespace-nowrap rounded-sm bg-muted px-1.5 text-xs font-medium text-foreground has-data-[slot=combobox-chip-remove]:pe-0'|tailwind_classes,
    }) }}
>
    <span data-slot="combobox-chip-label">
        {##- The chip label. -#}
        {%- block content %}{% endblock -%}
    </span>
    {%- if showRemove -%}
        <button
            type="button"
            data-slot="combobox-chip-remove"
            aria-label="Remove"
            data-action="click->combobox#removeChip"
            class="-ms-1 inline-flex size-4 shrink-0 items-center justify-center rounded-sm opacity-50 transition-opacity hover:opacity-100"
        ><twig:ux:icon name="lucide:x" class="size-3" /></button>
    {%- endif -%}
</span>
templates/components/Combobox/Chips.html.twig
{%- set _combobox_value = inject('combobox.value', []) -%}
<div
    data-slot="combobox-chips"
    data-combobox-target="chips"
    {{ attributes.defaults({
        class: 'flex min-h-8 w-full flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40'|tailwind_classes,
        'data-action': 'click->combobox#focusInput'|html_attr_type('sst'),
    }) }}
>
    {%- for value in _combobox_value -%}
        <twig:Combobox:Chip value="{{ value }}">{{ value }}</twig:Combobox:Chip>
    {%- endfor -%}
    {##- The chips input, i.e. a `Combobox:ChipsInput`. -#}
    {%- block content %}{% endblock -%}
    <template data-combobox-target="chipTemplate"><twig:Combobox:Chip value="" /></template>
</div>
templates/components/Combobox/ChipsInput.html.twig
{%- props
    ## string The text shown in the input while nothing has been typed.
    placeholder = ''
-%}
{%- set _combobox_open = inject('combobox.open', false) -%}
{%- set _combobox_inputId = inject('combobox.inputId', null) -%}
{%- set _combobox_listId = inject('combobox.listId', null) -%}
<input
    id="{{ _combobox_inputId }}"
    type="text"
    role="combobox"
    autocomplete="off"
    placeholder="{{ placeholder }}"
    data-slot="combobox-chip-input"
    data-combobox-target="input"
    aria-autocomplete="list"
    aria-haspopup="listbox"
    aria-controls="{{ _combobox_listId }}"
    aria-expanded="{{ _combobox_open ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: 'min-w-16 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground'|tailwind_classes,
        'data-action': 'input->combobox#filter click->combobox#open keydown->combobox#onInputKeydown'|html_attr_type('sst'),
    }) }}
>
templates/components/Combobox/Clear.html.twig
{%- set _combobox_value = inject('combobox.value', []) -%}
{%- set combobox_clear_attrs = {
    'data-slot': 'combobox-clear',
    'data-combobox-target': 'clear',
    'data-action': 'click->combobox#clear'|html_attr_type('sst'),
    'aria-label': 'Clear selection',
    hidden: _combobox_value is empty,
} -%}
{##- The clear element (e.g., an `InputGroup:Button`) that resets the selection when clicked. -#}
{%- block content %}{% endblock -%}
templates/components/Combobox/Collection.html.twig
<div data-slot="combobox-collection" {{ attributes }}>
    {##- The items of the enclosing `Combobox:Group`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Combobox/Content.html.twig
{%- props
    ## 'top'|'right'|'bottom'|'left' Which side of the input the content appears on.
    side = 'bottom',
    ## 'start'|'center'|'end' Alignment of the content along the cross axis.
    align = 'start'
-%}
{%- set _combobox_open = inject('combobox.open', false) -%}
{%- set _combobox_contentId = inject('combobox.contentId', null) -%}
{%- set style = html_cva(
    base: 'group/combobox-content invisible absolute z-50 flex max-h-72 w-full min-w-36 scale-95 flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground opacity-0 shadow-md ring-1 ring-foreground/10 transition-[opacity,scale,visibility] duration-150 ease-out group-data-[state=open]/combobox:transition-[opacity,scale] group-data-[state=open]/combobox:visible group-data-[state=open]/combobox:scale-100 group-data-[state=open]/combobox:opacity-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none',
    variants: {
        side: {
            top: 'bottom-full mb-1.5',
            bottom: 'top-full mt-1.5',
            left: 'right-full top-1/2 -translate-y-1/2 mr-1.5 origin-right',
            right: 'left-full top-1/2 -translate-y-1/2 ml-1.5 origin-left',
        },
    },
    compound_variants: [
        {side: ['top'], align: ['start'], class: 'start-0 ltr:origin-bottom-left rtl:origin-bottom-right'},
        {side: ['top'], align: ['center'], class: 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2 origin-bottom'},
        {side: ['top'], align: ['end'], class: 'end-0 ltr:origin-bottom-right rtl:origin-bottom-left'},
        {side: ['bottom'], align: ['start'], class: 'start-0 ltr:origin-top-left rtl:origin-top-right'},
        {side: ['bottom'], align: ['center'], class: 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2 origin-top'},
        {side: ['bottom'], align: ['end'], class: 'end-0 ltr:origin-top-right rtl:origin-top-left'},
    ],
) -%}
<div
    id="{{ _combobox_contentId }}"
    data-slot="combobox-content"
    data-combobox-target="content"
    data-side="{{ side }}"
    data-align="{{ align }}"
    data-state="{{ _combobox_open ? 'open' : 'closed' }}"
    aria-hidden="{{ _combobox_open ? 'false' : 'true' }}"
    {{ attributes.defaults({
        class: style.apply({side: side, align: align})|tailwind_classes,
    }) }}
>
    {##- The content body, typically a `Combobox:Empty` and a `Combobox:List`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Combobox/Empty.html.twig
<div
    data-slot="combobox-empty"
    {{ attributes.defaults({
        class: 'hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex'|tailwind_classes,
    }) }}
>
    {##- The message shown when the filter matches no item. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Combobox/Group.html.twig
{%- props
    ## string Unique identifier used to link the group to its `Combobox:Label`.
    id
-%}
{%- set _combobox_id = inject('combobox.id', 'combobox') -%}
{%- do provide('combobox.groupLabelId', 'combobox-' ~ _combobox_id ~ '-group-' ~ id ~ '-label') -%}
<div
    role="group"
    data-slot="combobox-group"
    data-combobox-target="group"
    aria-labelledby="{{ 'combobox-' ~ _combobox_id ~ '-group-' ~ id ~ '-label' }}"
    {{ attributes }}
>
    {##- The group content, typically a `Combobox:Label` and a `Combobox:Collection`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Combobox/Input.html.twig
{%- props
    ## string The text shown in the input while nothing has been typed.
    placeholder = '',
    ## boolean Whether the button toggling the list is rendered.
    showTrigger = true,
    ## boolean Whether the button clearing the selection is rendered.
    showClear = false,
    ## boolean Whether the input is disabled.
    disabled = false
-%}
{%- set _combobox_open = inject('combobox.open', false) -%}
{%- set _combobox_value = inject('combobox.value', []) -%}
{%- set _combobox_required = inject('combobox.required', false) -%}
{%- set _combobox_inputId = inject('combobox.inputId', null) -%}
{%- set _combobox_listId = inject('combobox.listId', null) -%}
{%- set _combobox_input_attrs = attributes.without('class').all() -%}
<twig:InputGroup class="{{ 'w-auto ' ~ attributes.render('class') }}">
    <twig:InputGroup:Input
        id="{{ _combobox_inputId }}"
        type="text"
        role="combobox"
        autocomplete="off"
        placeholder="{{ placeholder }}"
        value="{{ _combobox_value|first|default('') }}"
        aria-autocomplete="list"
        aria-haspopup="listbox"
        aria-controls="{{ _combobox_listId }}"
        aria-expanded="{{ _combobox_open ? 'true' : 'false' }}"
        data-combobox-target="input"
        data-action="input->combobox#filter click->combobox#open keydown->combobox#onInputKeydown"
        :disabled="disabled"
        :required="_combobox_required and _combobox_value is empty"
        {{ ..._combobox_input_attrs }}
    />
    <twig:InputGroup:Addon align="inline-end">
        {%- if showTrigger -%}
            <twig:Combobox:Trigger>
                <twig:InputGroup:Button
                    size="icon-xs"
                    variant="ghost"
                    aria-label="Toggle the list"
                    class="aria-expanded:bg-transparent group-has-[[data-combobox-target=clear]:not([hidden])]/input-group:hidden"
                    :disabled="disabled"
                    {{ ...combobox_trigger_attrs }}
                >
                    <twig:ux:icon name="lucide:chevron-down" class="size-4 text-muted-foreground" />
                </twig:InputGroup:Button>
            </twig:Combobox:Trigger>
        {%- endif -%}
        {%- if showClear -%}
            <twig:Combobox:Clear>
                <twig:InputGroup:Button
                    size="icon-xs"
                    variant="ghost"
                    :disabled="disabled"
                    {{ ...combobox_clear_attrs }}
                >
                    <twig:ux:icon name="lucide:x" class="size-4 text-muted-foreground" />
                </twig:InputGroup:Button>
            </twig:Combobox:Clear>
        {%- endif -%}
    </twig:InputGroup:Addon>
    {##- Extra `InputGroup:Addon` elements rendered alongside the input. -#}
    {{- block(outerBlocks.content) -}}
</twig:InputGroup>
templates/components/Combobox/Item.html.twig
{%- props
    ## string The value carried by the item, and submitted once it is selected.
    value,
    ## string|null The text matched while filtering; the item text content is used when omitted.
    label = null,
    ## boolean Whether the item is disabled.
    disabled = false
-%}
{%- set _combobox_value = inject('combobox.value', []) -%}
{%- set _selected = value in _combobox_value -%}
<div
    role="option"
    data-slot="combobox-item"
    data-combobox-target="item"
    data-value="{{ value }}"
    data-label="{{ label }}"
    data-selected="{{ _selected ? 'true' : 'false' }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    aria-selected="{{ _selected ? 'true' : 'false' }}"
    aria-disabled="{{ disabled ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: "group/combobox-item relative flex w-full cursor-default select-none items-center gap-2 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-highlighted:bg-accent data-highlighted:text-accent-foreground data-highlighted:**:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"|tailwind_classes,
        'data-action': 'click->combobox#select mousemove->combobox#highlight'|html_attr_type('sst'),
    }) }}
>
    {##- The item label. -#}
    {%- block content %}{% endblock -%}
    <span
        data-slot="combobox-item-indicator"
        class="pointer-events-none absolute end-2 hidden size-4 items-center justify-center group-data-[selected=true]/combobox-item:flex"
    ><twig:ux:icon name="lucide:check" class="size-4" /></span>
</div>
templates/components/Combobox/Label.html.twig
{%- set _combobox_groupLabelId = inject('combobox.groupLabelId', null) -%}
<div
    id="{{ _combobox_groupLabelId }}"
    data-slot="combobox-label"
    {{ attributes.defaults({
        class: 'px-2 py-1.5 text-xs text-muted-foreground'|tailwind_classes,
    }) }}
>
    {##- The group heading. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Combobox/List.html.twig
{%- set _combobox_listId = inject('combobox.listId', null) -%}
{%- set _combobox_multiple = inject('combobox.multiple', false) -%}
<div
    id="{{ _combobox_listId }}"
    role="listbox"
    data-slot="combobox-list"
    data-combobox-target="list"
    aria-multiselectable="{{ _combobox_multiple ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: 'no-scrollbar max-h-[calc(--spacing(72)---spacing(9))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0'|tailwind_classes,
    }) }}
>
    {##- The selectable items, e.g. `Combobox:Item` or `Combobox:Group`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Combobox/Separator.html.twig
<div
    role="none"
    data-slot="combobox-separator"
    {{ attributes.defaults({
        class: '-mx-1 my-1 h-px shrink-0 bg-border'|tailwind_classes,
    }) }}
></div>
templates/components/Combobox/Trigger.html.twig
{%- set _combobox_open = inject('combobox.open', false) -%}
{%- set _combobox_contentId = inject('combobox.contentId', null) -%}
{%- set combobox_trigger_attrs = {
    'data-slot': 'combobox-trigger',
    'data-combobox-target': 'trigger',
    'data-action': 'click->combobox#toggle'|html_attr_type('sst'),
    'aria-haspopup': 'listbox',
    'aria-controls': _combobox_contentId,
    'aria-expanded': _combobox_open ? 'true' : 'false',
    'data-state': _combobox_open ? 'open' : 'closed',
} -%}
{##- The trigger element (e.g., a `Button`) that opens the list when clicked. -#}
{%- block content %}{% endblock -%}
templates/components/Combobox/Value.html.twig
{%- props
    ## string The text shown while nothing is selected.
    placeholder = ''
-%}
{%- set _combobox_value = inject('combobox.value', []) -%}
<span
    data-slot="combobox-value"
    data-combobox-target="value"
    data-placeholder="{{ placeholder }}"
    data-empty="{{ _combobox_value is empty ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: 'truncate data-[empty=true]:text-muted-foreground'|tailwind_classes,
    }) }}
>{{ _combobox_value is empty ? placeholder : _combobox_value|join(', ') }}</span>
assets/controllers/input_group_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    focusInput(event) {
        if (event.target.closest('button')) {
            return;
        }

        event.currentTarget.parentElement?.querySelector('input')?.focus();
    }
}
assets/controllers/input_group_display_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['copyIcon', 'copiedIcon', 'favoriteIcon'];
    static values = { copiedDuration: { type: Number, default: 2000 } };

    #copiedTimeout = null;

    async copy(event) {
        await navigator.clipboard.writeText(event.params.text);

        this.copyIconTarget.toggleAttribute('hidden', true);
        this.copiedIconTarget.toggleAttribute('hidden', false);

        clearTimeout(this.#copiedTimeout);
        this.#copiedTimeout = setTimeout(() => {
            this.copyIconTarget.toggleAttribute('hidden', false);
            this.copiedIconTarget.toggleAttribute('hidden', true);
        }, this.copiedDurationValue);
    }

    toggleFavorite() {
        const icon = this.favoriteIconTarget;
        icon.dataset.favorite = String(icon.dataset.favorite !== 'true');
    }

    disconnect() {
        clearTimeout(this.#copiedTimeout);
    }
}
templates/components/InputGroup.html.twig
<div
    data-slot="input-group"
    role="group"
    {{ attributes.defaults({
        class: 'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pe-1.5 has-[>[data-align=inline-start]]:[&>input]:ps-1.5'|tailwind_classes,
        'data-controller': 'input-group',
    }) }}
>
    {##- The input group elements, typically includes input, `InputGroup:Addon`, and/or `InputGroup:Button`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/InputGroup/Addon.html.twig
{%- props
    ## 'inline-start'|'inline-end'|'block-start'|'block-end' The addon position relative to the input.
    align = 'inline-start'
-%}
{%- set style = html_cva(
    base: "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
    variants: {
        align: {
            'inline-start': 'order-first ps-2 has-[>button]:ms-[-0.3rem] has-[>kbd]:ms-[-0.15rem]',
            'inline-end': 'order-last pe-2 has-[>button]:me-[-0.3rem] has-[>kbd]:me-[-0.15rem]',
            'block-start': 'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
            'block-end': 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
        },
    },
) -%}
<div
    data-slot="input-group-addon"
    data-align="{{ align }}"
    role="group"
    {{ attributes.defaults({
        class: style.apply({align: align})|tailwind_classes,
        'data-action': 'click->input-group#focusInput'|html_attr_type('sst'),
    }) }}
>
    {##- The addon content, typically an icon or text. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/InputGroup/Button.html.twig
{%- props
    ## 'button'|'submit' The button type.
    type = 'button',
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'ghost',
    ## 'xs'|'sm'|'icon-xs'|'icon-sm' The button size.
    size = 'xs'
-%}
{%- set style = html_cva(
    base: 'flex items-center gap-2 text-sm shadow-none',
    variants: {
        size: {
            xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
            sm: '',
            'icon-xs': 'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
            'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
        },
    },
) %}
<twig:Button
    type="{{ type }}"
    variant="{{ variant }}"
    {{ ...attributes.defaults({class: style.apply({size: size})|tailwind_classes}) }}
>
    {##- The button label and/or icon. -#}
    {{- block(outerBlocks.content) -}}
</twig:Button>
templates/components/InputGroup/Input.html.twig
<twig:Input
    data-slot="input-group-control"
    {{ ...attributes.defaults({class: 'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent'|tailwind_classes}) }}
/>
templates/components/InputGroup/Text.html.twig
<span
    data-slot="input-group-text"
    {{ attributes.defaults({
        class: 'text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*=size-])]:size-4'|tailwind_classes,
    }) }}
>
    {##- The text content displayed in the input group. -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/InputGroup/Textarea.html.twig
<twig:Textarea
    data-slot="input-group-control"
    {{ ...attributes.defaults({class: 'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent'|tailwind_classes}) }}
>
    {##- The initial textarea value. -#}
    {{- block(outerBlocks.content) -}}
</twig:Textarea>
templates/components/Button.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default',
    ## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
    as = 'button'
-%}
{%- set style = html_cva(
    base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    variants: {
        variant: {
            default: 'bg-primary text-primary-foreground hover:bg-primary/80',
            outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
            secondary: 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
            ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
            destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
            link: 'text-primary underline-offset-4 hover:underline',
        },
        size: {
            default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
            sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            icon: 'size-8',
            'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
            'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
            'icon-lg': 'size-9',
        },
    },
) -%}
<{{ as }}
    data-slot="{{ attributes.render('data-slot')|default('button') }}"
    data-variant="{{ variant }}"
    data-size="{{ size }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The button label and/or icon. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/Input.html.twig
<input
    data-slot="{{ attributes.render('data-slot')|default('input') }}"
    {{ attributes.defaults({
        class: 'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40'|tailwind_classes,
    }) }}
>
templates/components/Textarea.html.twig
<textarea
    data-slot="{{ attributes.render('data-slot')|default('textarea') }}"
    {{ attributes.defaults({
        class: 'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40'|tailwind_classes,
    }) }}
>
    {##- The initial textarea value. -#}
    {%- block content %}{% endblock -%}
</textarea>

Usage

<twig:Combobox id="framework">
    <twig:Combobox:Input placeholder="Select a framework" />
    <twig:Combobox:Content>
        <twig:Combobox:Empty>No items found.</twig:Combobox:Empty>
        <twig:Combobox:List>
            <twig:Combobox:Item value="next">Next.js</twig:Combobox:Item>
            <twig:Combobox:Item value="astro">Astro</twig:Combobox:Item>
        </twig:Combobox:List>
    </twig:Combobox:Content>
</twig:Combobox>

Composition

Simple

A single-line input and a flat list (see Basic).

Combobox
├── Combobox:Input
└── Combobox:Content
    ├── Combobox:Empty
    └── Combobox:List
        ├── Combobox:Item
        └── Combobox:Item

With chips

Multiple selection with multiple, chips, and a chips input (see Multiple).

Combobox
├── Combobox:Chips
│   └── Combobox:ChipsInput
└── Combobox:Content
    ├── Combobox:Empty
    └── Combobox:List
        ├── Combobox:Item
        └── Combobox:Item

With groups and collection

Nested items per group using Combobox:Collection inside each Combobox:Group, with a separator between groups (see Groups).

Combobox
├── Combobox:Input
└── Combobox:Content
    ├── Combobox:Empty
    └── Combobox:List
        ├── Combobox:Group
        │   ├── Combobox:Label
        │   └── Combobox:Collection
        │       ├── Combobox:Item
        │       └── Combobox:Item
        ├── Combobox:Separator
        └── Combobox:Group
            ├── Combobox:Label
            └── Combobox:Collection
                ├── Combobox:Item
                └── Combobox:Item

Examples

Basic

A simple combobox with a list of frameworks.

100%
Loading...
{% set frameworks = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 280px">
    <twig:Combobox id="framework-basic">
        <twig:Combobox:Input placeholder="Select a framework" />
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No items found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for framework in frameworks %}
                    <twig:Combobox:Item value="{{ framework }}">{{ framework }}</twig:Combobox:Item>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Multiple

A combobox with multiple selection using multiple and Combobox:Chips.

100%
Loading...
{% set frameworks = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 280px">
    <twig:Combobox id="framework-multiple" name="frameworks" multiple autoHighlight :value="['Next.js']">
        <twig:Combobox:Chips>
            <twig:Combobox:ChipsInput placeholder="Add framework" />
        </twig:Combobox:Chips>
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No items found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for framework in frameworks %}
                    <twig:Combobox:Item value="{{ framework }}">{{ framework }}</twig:Combobox:Item>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Clear Button

Use the showClear prop to show a clear button.

100%
Loading...
{% set frameworks = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 280px">
    <twig:Combobox id="framework-clear" name="framework" value="Next.js">
        <twig:Combobox:Input placeholder="Select a framework" showClear />
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No items found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for framework in frameworks %}
                    <twig:Combobox:Item value="{{ framework }}">{{ framework }}</twig:Combobox:Item>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Groups

Use Combobox:Group and Combobox:Separator to group items.

100%
Loading...
{% set timezones = [
    {label: 'Americas', items: ['(GMT-5) New York', '(GMT-8) Los Angeles', '(GMT-6) Chicago', '(GMT-5) Toronto', '(GMT-8) Vancouver', '(GMT-3) São Paulo']},
    {label: 'Europe', items: ['(GMT+0) London', '(GMT+1) Paris', '(GMT+1) Berlin', '(GMT+1) Rome', '(GMT+1) Madrid', '(GMT+1) Amsterdam']},
    {label: 'Asia/Pacific', items: ['(GMT+9) Tokyo', '(GMT+8) Shanghai', '(GMT+8) Singapore', '(GMT+4) Dubai', '(GMT+11) Sydney', '(GMT+9) Seoul']},
] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 380px">
    <twig:Combobox id="timezone-groups">
        <twig:Combobox:Input placeholder="Select a timezone" />
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No timezones found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for timezone in timezones %}
                    <twig:Combobox:Group id="{{ loop.index }}">
                        <twig:Combobox:Label>{{ timezone.label }}</twig:Combobox:Label>
                        <twig:Combobox:Collection>
                            {% for item in timezone.items %}
                                <twig:Combobox:Item value="{{ item }}">{{ item }}</twig:Combobox:Item>
                            {% endfor %}
                        </twig:Combobox:Collection>
                        {% if not loop.last %}<twig:Combobox:Separator />{% endif %}
                    </twig:Combobox:Group>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Custom Items

You can render a custom component inside Combobox:Item. Set the label prop so the filter matches the country name rather than the whole item text.

100%
Loading...
{% set countries = [
    {value: 'argentina', label: 'Argentina', continent: 'South America', code: 'ar'},
    {value: 'australia', label: 'Australia', continent: 'Oceania', code: 'au'},
    {value: 'brazil', label: 'Brazil', continent: 'South America', code: 'br'},
    {value: 'canada', label: 'Canada', continent: 'North America', code: 'ca'},
    {value: 'china', label: 'China', continent: 'Asia', code: 'cn'},
    {value: 'egypt', label: 'Egypt', continent: 'Africa', code: 'eg'},
    {value: 'france', label: 'France', continent: 'Europe', code: 'fr'},
    {value: 'germany', label: 'Germany', continent: 'Europe', code: 'de'},
    {value: 'japan', label: 'Japan', continent: 'Asia', code: 'jp'},
    {value: 'kenya', label: 'Kenya', continent: 'Africa', code: 'ke'},
    {value: 'united-states', label: 'United States', continent: 'North America', code: 'us'},
] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 380px">
    <twig:Combobox id="country-custom">
        <twig:Combobox:Input placeholder="Search countries..." />
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No countries found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for country in countries %}
                    <twig:Combobox:Item value="{{ country.value }}" label="{{ country.label }}">
                        <twig:Item size="xs" class="p-0">
                            <twig:Item:Content>
                                <twig:Item:Title class="whitespace-nowrap">{{ country.label }}</twig:Item:Title>
                                <twig:Item:Description>{{ country.continent }} ({{ country.code }})</twig:Item:Description>
                            </twig:Item:Content>
                        </twig:Item>
                    </twig:Combobox:Item>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Invalid

Use the aria-invalid attribute to mark the combobox as invalid.

100%
Loading...
{% set frameworks = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 280px">
    <twig:Combobox id="framework-invalid">
        <twig:Combobox:Input placeholder="Select a framework" aria-invalid="true" />
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No items found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for framework in frameworks %}
                    <twig:Combobox:Item value="{{ framework }}">{{ framework }}</twig:Combobox:Item>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Disabled

Use the disabled prop to disable the combobox.

100%
Loading...
{% set frameworks = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 120px">
    <twig:Combobox id="framework-disabled">
        <twig:Combobox:Input placeholder="Select a framework" disabled />
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No items found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for framework in frameworks %}
                    <twig:Combobox:Item value="{{ framework }}">{{ framework }}</twig:Combobox:Item>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Auto Highlight

Use the autoHighlight prop to automatically highlight the first item while filtering.

100%
Loading...
{% set frameworks = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 280px">
    <twig:Combobox id="framework-auto-highlight" autoHighlight>
        <twig:Combobox:Input placeholder="Select a framework" />
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No items found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for framework in frameworks %}
                    <twig:Combobox:Item value="{{ framework }}">{{ framework }}</twig:Combobox:Item>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Popup

You can open the combobox from a button, or any other element, by spreading combobox_trigger_attrs onto it. Move the Combobox:Input inside the Combobox:Content, and display the selection with a Combobox:Value.

100%
Loading...
{% set countries = [
    {value: 'argentina', label: 'Argentina'},
    {value: 'australia', label: 'Australia'},
    {value: 'brazil', label: 'Brazil'},
    {value: 'canada', label: 'Canada'},
    {value: 'france', label: 'France'},
    {value: 'germany', label: 'Germany'},
    {value: 'japan', label: 'Japan'},
    {value: 'united-states', label: 'United States'},
] %}
<div class="mx-auto w-64" style="min-height: 340px">
    <twig:Combobox id="country-popup">
        <twig:Combobox:Trigger>
            <twig:Button
                variant="outline"
                class="w-full justify-between font-normal"
                {{ ...combobox_trigger_attrs }}
            >
                <twig:Combobox:Value placeholder="Select country" />
                <twig:ux:icon name="lucide:chevron-down" data-icon="inline-end" />
            </twig:Button>
        </twig:Combobox:Trigger>
        <twig:Combobox:Content>
            <twig:Combobox:Input placeholder="Search" :showTrigger="false" />
            <twig:Combobox:Empty>No items found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for country in countries %}
                    <twig:Combobox:Item value="{{ country.value }}" label="{{ country.label }}">{{ country.label }}</twig:Combobox:Item>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

Input Group

You can add an addon to the combobox by rendering an InputGroup:Addon inside the Combobox:Input.

100%
Loading...
{% set timezones = [
    {label: 'Americas', items: ['(GMT-5) New York', '(GMT-8) Los Angeles', '(GMT-6) Chicago']},
    {label: 'Europe', items: ['(GMT+0) London', '(GMT+1) Paris', '(GMT+1) Berlin']},
    {label: 'Asia/Pacific', items: ['(GMT+9) Tokyo', '(GMT+8) Shanghai', '(GMT+11) Sydney']},
] %}
<div class="mx-auto w-full max-w-xs" style="min-height: 380px">
    <twig:Combobox id="timezone-input-group">
        <twig:Combobox:Input placeholder="Select a timezone">
            <twig:InputGroup:Addon>
                <twig:ux:icon name="lucide:globe" />
            </twig:InputGroup:Addon>
        </twig:Combobox:Input>
        <twig:Combobox:Content>
            <twig:Combobox:Empty>No timezones found.</twig:Combobox:Empty>
            <twig:Combobox:List>
                {% for timezone in timezones %}
                    <twig:Combobox:Group id="{{ loop.index }}">
                        <twig:Combobox:Label>{{ timezone.label }}</twig:Combobox:Label>
                        <twig:Combobox:Collection>
                            {% for item in timezone.items %}
                                <twig:Combobox:Item value="{{ item }}">{{ item }}</twig:Combobox:Item>
                            {% endfor %}
                        </twig:Combobox:Collection>
                    </twig:Combobox:Group>
                {% endfor %}
            </twig:Combobox:List>
        </twig:Combobox:Content>
    </twig:Combobox>
</div>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

100%
Loading...
{% set categories = {
    ar: ['التكنولوجيا', 'التصميم', 'الأعمال', 'التسويق', 'التعليم', 'الصحة'],
    he: ['טכנולוגיה', 'עיצוב', 'עסקים', 'שיווק', 'חינוך', 'בריאות'],
} %}
<div class="mx-auto flex w-full max-w-xs flex-col gap-6" style="min-height: 460px">
    {# Arabic #}
    <twig:Field dir="rtl">
        <twig:Field:Label for="combobox-categories-ar-input">الفئات</twig:Field:Label>
        <twig:Combobox id="categories-ar" multiple autoHighlight :value="[categories.ar|first]">
            <twig:Combobox:Chips>
                <twig:Combobox:ChipsInput placeholder="أضف فئات" />
            </twig:Combobox:Chips>
            <twig:Combobox:Content>
                <twig:Combobox:Empty>لم يتم العثور على فئات.</twig:Combobox:Empty>
                <twig:Combobox:List>
                    {% for category in categories.ar %}
                        <twig:Combobox:Item value="{{ category }}">{{ category }}</twig:Combobox:Item>
                    {% endfor %}
                </twig:Combobox:List>
            </twig:Combobox:Content>
        </twig:Combobox>
    </twig:Field>

    {# Hebrew #}
    <twig:Field dir="rtl">
        <twig:Field:Label for="combobox-categories-he-input">קטגוריות</twig:Field:Label>
        <twig:Combobox id="categories-he" multiple autoHighlight :value="[categories.he|first]">
            <twig:Combobox:Chips>
                <twig:Combobox:ChipsInput placeholder="הוסף קטגוריות" />
            </twig:Combobox:Chips>
            <twig:Combobox:Content>
                <twig:Combobox:Empty>לא נמצאו קטגוריות.</twig:Combobox:Empty>
                <twig:Combobox:List>
                    {% for category in categories.he %}
                        <twig:Combobox:Item value="{{ category }}">{{ category }}</twig:Combobox:Item>
                    {% endfor %}
                </twig:Combobox:List>
            </twig:Combobox:Content>
        </twig:Combobox>
    </twig:Field>
</div>

Accessibility

  • The text input renders role="combobox" with aria-haspopup="listbox", aria-expanded and an aria-controls pointing at the list, so assistive tech announces the widget and its state.
  • Combobox:List renders role="listbox" with aria-multiselectable, each Combobox:Item renders role="option" with aria-selected, and a Combobox:Group wraps its items in role="group" labelled by its Combobox:Label.
  • The highlighted item is referenced by aria-activedescendant on the input, so the focus stays in the input while the reading order follows the highlight.
  • ArrowDown and ArrowUp move through the items, Home and End jump to the first and last, Enter selects the highlighted item and Escape closes the list. In a popup, the focus goes back to the button that opened it. In a multiple selection, Backspace on an empty input removes the last chip.
  • Set id so the internal ARIA references are unique, and give the combobox a visible Field:Label bound to the input with for, since the input's own text is only the current selection.
  • The clear button carries aria-label="Clear selection" and each chip's remove button aria-label="Remove". Translate them when your interface is not in English.

API Reference

<twig:Combobox>

Prop Type Default
id 
string -
name 
string|null null
value 
string|array<string>|null null
multiple 
boolean false
autoHighlight 
boolean false
open 
boolean false
required 
boolean false
Block Description
content A Combobox:Input (or a Combobox:Chips) and a Combobox:Content.

<twig:Combobox:Chip>

Prop Type Default
value 
string -
showRemove 
boolean true
Block Description
content The chip label.

<twig:Combobox:Chips>

Block Description
content The chips input, i.e. a Combobox:ChipsInput.

<twig:Combobox:ChipsInput>

Prop Type Default
placeholder 
string ''

<twig:Combobox:Clear>

Block Description
content The clear element (e.g., an InputGroup:Button) that resets the selection when clicked.

<twig:Combobox:Collection>

Block Description
content The items of the enclosing Combobox:Group.

<twig:Combobox:Content>

Prop Type Default
side 
'top'|'right'|'bottom'|'left' 'bottom'
align 
'start'|'center'|'end' 'start'
Block Description
content The content body, typically a Combobox:Empty and a Combobox:List.

<twig:Combobox:Empty>

Block Description
content The message shown when the filter matches no item.

<twig:Combobox:Group>

Prop Type Default
id 
string -
Block Description
content The group content, typically a Combobox:Label and a Combobox:Collection.

<twig:Combobox:Input>

Prop Type Default
placeholder 
string ''
showTrigger 
boolean true
showClear 
boolean false
disabled 
boolean false
Block Description
content Extra InputGroup:Addon elements rendered alongside the input.

<twig:Combobox:Item>

Prop Type Default
value 
string -
label 
string|null null
disabled 
boolean false
Block Description
content The item label.

<twig:Combobox:Label>

Block Description
content The group heading.

<twig:Combobox:List>

Block Description
content The selectable items, e.g. Combobox:Item or Combobox:Group.

<twig:Combobox:Trigger>

Block Description
content The trigger element (e.g., a Button) that opens the list when clicked.

<twig:Combobox:Value>

Prop Type Default
placeholder 
string ''

data-controller="combobox"

Value Type Default
data-combobox-open-value 
Boolean false
data-combobox-multiple-value 
Boolean false
data-combobox-auto-highlight-value 
Boolean false
data-combobox-required-value 
Boolean false
data-combobox-name-value 
String ''
data-combobox-value-value 
Array []
Target Description
input The text input filtering the list and reflecting the selection.
trigger The element toggling the list and reflecting its expanded state.
clear The element resetting the selection, hidden while nothing is selected.
content The floating surface holding the list.
list The listbox holding the items.
item A selectable item of the list.
group A group of items, hidden when the filter empties it.
chips The container holding the chips of a multiple selection.
chip A chip standing for one selected value.
chipTemplate The template cloned to build a chip.
value The element displaying the selection when there is no text input.
Action Description
toggle Opens the list, or closes it when it is already open.
open Opens the list.
clear Resets the selection.
filter Narrows the list down to the items matching the typed text.
select Adds the clicked item to the selection, or removes it when multiple is on.
highlight Highlights the item under the pointer.
removeChip Removes from the selection the value the clicked chip stands for.
focusInput Moves the focus to the text input.
onInputKeydown Handles the keyboard navigation from the text input.
handleOutsideClick Closes the list when the click landed outside the component.
handleEscape Closes the list and gives the focus back to the text input.