View as Markdown

Select

Displays a list of options for the user to pick from—triggered by a button.

100%
Loading...
<div class="mx-auto w-full max-w-48" style="min-height: 320px">
    <twig:Select id="demo" class="block">
        <twig:Select:Trigger class="w-full">
            <twig:Select:Value placeholder="Select a fruit" />
        </twig:Select:Trigger>
        <twig:Select:Content>
            <twig:Select:Group id="demo-fruits">
                <twig:Select:Label>Fruits</twig:Select:Label>
                <twig:Select:Item value="apple">Apple</twig:Select:Item>
                <twig:Select:Item value="banana">Banana</twig:Select:Item>
                <twig:Select:Item value="blueberry">Blueberry</twig:Select:Item>
                <twig:Select:Item value="grapes">Grapes</twig:Select:Item>
                <twig:Select:Item value="pineapple">Pineapple</twig:Select:Item>
            </twig:Select:Group>
        </twig:Select:Content>
    </twig:Select>
</div>

Installation

php bin/console ux:install select --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/select_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = [
        'trigger',
        'value',
        'content',
        'viewport',
        'item',
        'hiddenSelect',
        'scrollUpButton',
        'scrollDownButton',
    ];

    static values = {
        open: { type: Boolean, default: false },
        value: { type: String, default: '' },
    };

    _typeahead = '';
    _typeaheadTimeout = null;

    connect() {
        this._onOutsideClick = this._onOutsideClick.bind(this);
        this._syncSelection();
        this._setState(this.openValue);
        if (this.openValue) {
            this.open({ focus: false });
        }
    }

    disconnect() {
        document.removeEventListener('click', this._onOutsideClick, true);
        window.clearTimeout(this._typeaheadTimeout);
    }

    toggle(event) {
        event?.preventDefault();
        if (this.element.dataset.state === 'open') {
            this.close();
        } else {
            this.open();
        }
    }

    open({ focus = true } = {}) {
        this._setState(true);
        this._position();
        document.addEventListener('click', this._onOutsideClick, true);

        requestAnimationFrame(() =>
            requestAnimationFrame(() => {
                this._scrollSelectedIntoView();
                this._syncScrollButtons();
                if (focus) {
                    (this._selectedItem() ?? this._enabledItems()[0])?.focus();
                }
            })
        );
    }

    close({ focus = true } = {}) {
        this._setState(false);
        document.removeEventListener('click', this._onOutsideClick, true);

        if (focus && this.hasTriggerTarget) {
            this.triggerTarget.focus();
        }
    }

    selectItem(event) {
        const item = event.currentTarget;
        if (this._isItemDisabled(item)) {
            event.preventDefault();
            return;
        }

        this._select(item);
        this.close();
    }

    highlightItem(event) {
        const item = event.currentTarget;
        if (event.pointerType === 'mouse' && document.activeElement !== item) {
            item.focus({ preventScroll: true });
        }
    }

    unhighlightItem(event) {
        if (document.activeElement === event.currentTarget) {
            this._focusContent();
        }
    }

    onTriggerKeydown(event) {
        switch (event.key) {
            case 'ArrowDown':
            case 'ArrowUp':
            case 'Enter':
            case ' ':
                event.preventDefault();
                this.open();
                break;
            default:
                if (this._isTypeaheadKey(event)) {
                    event.preventDefault();
                    const match = this._typeaheadMatch(event.key, this._selectedItem());
                    if (match) {
                        this._select(match);
                    }
                }
        }
    }

    onContentKeydown(event) {
        switch (event.key) {
            case 'Escape':
                event.preventDefault();
                this.close();
                break;
            case 'ArrowDown':
                event.preventDefault();
                this._focusRelativeItem(1);
                break;
            case 'ArrowUp':
                event.preventDefault();
                this._focusRelativeItem(-1);
                break;
            case 'Home':
                event.preventDefault();
                this._enabledItems()[0]?.focus();
                break;
            case 'End': {
                event.preventDefault();
                const items = this._enabledItems();
                items[items.length - 1]?.focus();
                break;
            }
            case 'Enter':
            case ' ': {
                event.preventDefault();
                const item = this._enabledItems().find((candidate) => candidate === document.activeElement);
                item?.click();
                break;
            }
            case 'Tab':
                this.close({ focus: false });
                break;
            default:
                if (this._isTypeaheadKey(event)) {
                    event.preventDefault();
                    this._typeaheadMatch(event.key, document.activeElement)?.focus();
                }
        }
    }

    scrollUp() {
        this._scrollViewportBy(-1);
    }

    scrollDown() {
        this._scrollViewportBy(1);
    }

    onViewportScroll() {
        this._syncScrollButtons();
    }

    valueValueChanged() {
        this._syncSelection();
    }

    _select(item) {
        this.valueValue = item.dataset.value;
        this.dispatch('change', {
            detail: { value: this.valueValue, label: this._labelOf(item) },
            bubbles: true,
        });
    }

    _setState(isOpen) {
        const state = isOpen ? 'open' : 'closed';
        this.element.dataset.state = state;

        if (this.hasTriggerTarget) {
            this.triggerTarget.dataset.state = state;
            this.triggerTarget.setAttribute('aria-expanded', String(isOpen));
        }
        if (this.hasContentTarget) {
            this.contentTarget.dataset.state = state;
            this.contentTarget.setAttribute('aria-hidden', String(!isOpen));
        }
    }

    _syncSelection() {
        const selected = this._selectedItem();

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

        if (this.hasValueTarget) {
            const label = selected ? this._labelOf(selected) : '';
            this.valueTarget.textContent = label || this.valueTarget.dataset.placeholder || '';
        }
        if (this.hasTriggerTarget) {
            this.triggerTarget.dataset.hasValue = String(Boolean(selected));
        }
        if (this.hasHiddenSelectTarget) {
            this.hiddenSelectTarget.options[0].value = this.valueValue;
        }
    }

    _position() {
        if (!this.hasContentTarget || !this.hasTriggerTarget) {
            return;
        }

        const content = this.contentTarget;
        content.style.minWidth = `${this.triggerTarget.offsetWidth}px`;

        if (content.dataset.position !== 'item-aligned' || !this.hasViewportTarget) {
            return;
        }

        const anchor = this._selectedItem() ?? this._enabledItems()[0];
        if (!anchor) {
            return;
        }

        content.style.bottom = 'auto';
        content.style.marginTop = '0px';
        content.style.marginBottom = '0px';

        const margin = 8;
        const trigger = this.triggerTarget;
        const viewport = this.viewportTarget;
        const triggerMiddle = trigger.getBoundingClientRect().top + trigger.offsetHeight / 2;
        const anchorMiddle = () => anchor.offsetTop + anchor.offsetHeight / 2;
        viewport.style.maxHeight = '';
        viewport.scrollTop = 0;
        this._syncScrollButtons();

        // A second pass accounts for the scroll-up button that the first scroll may reveal.
        for (let pass = 0; pass < 2; pass++) {
            const anchorTopInList = anchor.offsetTop - viewport.offsetTop;
            const scrollTop = Math.max(
                0,
                anchorTopInList + anchor.offsetHeight - viewport.clientHeight,
                anchorMiddle() - (triggerMiddle - margin)
            );
            const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
            if (scrollTop > maxScrollTop) {
                const height = viewport.clientHeight - (scrollTop - maxScrollTop);
                const minHeight = Math.min(anchor.offsetHeight * 5, viewport.scrollHeight);
                viewport.style.maxHeight = `${Math.max(height, minHeight)}px`;
            }
            viewport.scrollTop = scrollTop;
            this._syncScrollButtons();
        }

        const top = trigger.offsetTop + trigger.offsetHeight / 2 - (anchorMiddle() - viewport.scrollTop);
        const topInWindow = this.element.getBoundingClientRect().top + top;
        const overflowTop = margin - topInWindow;
        const overflowBottom = topInWindow + content.offsetHeight - (window.innerHeight - margin);
        const shift = overflowTop > 0 ? overflowTop : overflowBottom > 0 ? -overflowBottom : 0;
        content.style.top = `${top + shift}px`;
    }

    _scrollSelectedIntoView() {
        if (this.hasViewportTarget) {
            this._selectedItem()?.scrollIntoView({ block: 'nearest' });
        }
    }

    _scrollViewportBy(direction) {
        if (this.hasViewportTarget) {
            this.viewportTarget.scrollBy({ top: direction * this.viewportTarget.clientHeight * 0.5 });
        }
    }

    _syncScrollButtons() {
        if (!this.hasViewportTarget) {
            return;
        }

        const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget;
        if (this.hasScrollUpButtonTarget) {
            this.scrollUpButtonTarget.hidden = scrollTop <= 0;
        }
        if (this.hasScrollDownButtonTarget) {
            this.scrollDownButtonTarget.hidden = Math.ceil(scrollTop + clientHeight) >= scrollHeight;
        }
    }

    _isTypeaheadKey(event) {
        return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
    }

    _typeaheadMatch(key, current) {
        window.clearTimeout(this._typeaheadTimeout);
        this._typeahead += key.toLowerCase();
        this._typeaheadTimeout = window.setTimeout(() => (this._typeahead = ''), 1000);

        const isRepeatedCharacter = [...this._typeahead].every((character) => character === this._typeahead[0]);
        const search = isRepeatedCharacter ? this._typeahead[0] : this._typeahead;
        const items = this._enabledItems();
        const start = Math.max(items.indexOf(current), 0);
        const candidates = [...items.slice(start), ...items.slice(0, start)].filter(
            (item) => search.length > 1 || item !== current
        );
        const match = candidates.find((item) => this._labelOf(item).toLowerCase().startsWith(search));

        return match && match !== current ? match : null;
    }

    _labelOf(item) {
        return (item.querySelector('[data-slot="select-item-text"]') ?? item).textContent.trim();
    }

    _selectedItem() {
        return this.itemTargets.find((item) => item.dataset.value === this.valueValue) ?? null;
    }

    _isItemDisabled(item) {
        return !item || item.dataset.disabled === 'true' || item.getAttribute('aria-disabled') === 'true';
    }

    _enabledItems() {
        return this.itemTargets.filter((item) => !this._isItemDisabled(item));
    }

    _focusContent() {
        if (this.hasContentTarget) {
            this.contentTarget.focus({ preventScroll: true });
        }
    }

    _focusRelativeItem(offset) {
        const items = this._enabledItems();
        if (items.length === 0) {
            return;
        }

        const index = items.indexOf(document.activeElement);
        if (index === -1) {
            items[offset > 0 ? 0 : items.length - 1].focus();
            return;
        }

        items[Math.min(Math.max(index + offset, 0), items.length - 1)].focus();
    }

    _onOutsideClick(event) {
        if (!this.element.contains(event.target)) {
            this.close({ focus: false });
        }
    }
}
templates/components/Select.html.twig
{%- props
    ## string Unique identifier set on the trigger, so a `<label for>` can point at it, and used to generate the internal Select IDs.
    id,
    ## string|null Name of the form field carrying the selected value.
    name = null,
    ## string The value selected on initial render.
    value = '',
    ## boolean Whether the select is disabled.
    disabled = false,
    ## boolean Whether a value must be selected before the form can be submitted.
    required = false,
    ## boolean Whether the select is open on initial render.
    open = false
-%}
{%- do provide('select.id', id) -%}
{%- do provide('select.value', value) -%}
{%- do provide('select.open', open) -%}
{%- do provide('select.disabled', disabled) -%}
{%- do provide('select.triggerId', id) -%}
{%- do provide('select.contentId', 'select-' ~ id ~ '-content') -%}
<div
    data-slot="select"
    data-state="{{ open ? 'open' : 'closed' }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    data-select-open-value="{{ open ? 'true' : 'false' }}"
    data-select-value-value="{{ value }}"
    {{ attributes.defaults({
        class: 'group/select relative inline-block'|tailwind_classes,
        'data-controller': 'select',
    }) }}
>
    {%- if name is not null -%}
        <select
            name="{{ name }}"
            tabindex="-1"
            aria-hidden="true"
            data-select-target="hiddenSelect"
            class="sr-only"
            {% if required %}required{% endif %}
            {% if disabled %}disabled{% endif %}
        ><option value="{{ value }}"></option></select>
    {%- endif -%}
    {##- A `Select:Trigger` and a `Select:Content`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Select/Content.html.twig
{%- props
    ## 'item-aligned'|'popper' How the content is placed: over the trigger on the selected item, or anchored to the trigger edge.
    position = 'item-aligned',
    ## 'top'|'bottom' Which side of the trigger the content appears on.
    side = 'bottom',
    ## 'start'|'center'|'end' Alignment of the content along the cross axis.
    align = 'start'
-%}
{%- set _select_open = inject('select.open', false) -%}
{%- set _select_triggerId = inject('select.triggerId', null) -%}
{%- set _select_contentId = inject('select.contentId', null) -%}
{%- set style = html_cva(
    base: 'invisible absolute z-50 w-max min-w-36 scale-95 overflow-hidden rounded-lg bg-popover text-popover-foreground opacity-0 shadow-md ring-1 ring-foreground/10 outline-none [transition:opacity_150ms_ease-out,scale_150ms_ease-out,visibility_0s_linear_150ms] group-data-[state=open]/select:visible group-data-[state=open]/select:[transition-delay:0s] group-data-[state=open]/select:scale-100 group-data-[state=open]/select:opacity-100',
    variants: {
        side: {
            top: 'bottom-full mb-1',
            bottom: 'top-full mt-1',
        },
    },
    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="{{ _select_contentId }}"
    role="listbox"
    tabindex="-1"
    aria-labelledby="{{ _select_triggerId }}"
    data-slot="select-content"
    data-select-target="content"
    data-position="{{ position }}"
    data-side="{{ side }}"
    data-align="{{ align }}"
    data-state="{{ _select_open ? 'open' : 'closed' }}"
    aria-hidden="{{ _select_open ? 'false' : 'true' }}"
    {{ attributes.defaults({
        class: style.apply({side: side, align: align})|tailwind_classes,
        'data-action': 'keydown->select#onContentKeydown',
    }) }}
>
    <div
        hidden
        aria-hidden="true"
        data-slot="select-scroll-up-button"
        data-select-target="scrollUpButton"
        data-action="click->select#scrollUp"
        class="z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=size-])]:size-4"
    >
        <twig:ux:icon name="lucide:chevron-up" class="size-4" />
    </div>
    <div
        data-slot="select-viewport"
        data-select-target="viewport"
        data-action="scroll->select#onViewportScroll"
        class="max-h-72 scroll-my-1 overflow-x-hidden overflow-y-auto"
    >
        {##- The options, typically `Select:Group`, `Select:Label`, `Select:Item` and `Select:Separator` components. -#}
        {%- block content %}{% endblock -%}
    </div>
    <div
        hidden
        aria-hidden="true"
        data-slot="select-scroll-down-button"
        data-select-target="scrollDownButton"
        data-action="click->select#scrollDown"
        class="z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=size-])]:size-4"
    >
        <twig:ux:icon name="lucide:chevron-down" class="size-4" />
    </div>
</div>
templates/components/Select/Group.html.twig
{%- props
    ## string|null Unique identifier linking the group to its `Select:Label`, so the group is announced with a name.
    id = null
-%}
{%- do provide('selectGroup.labelId', null is same as(id) ? null : 'select-group-' ~ id ~ '-label') -%}
<div
    role="group"
    {% if id is not null %}aria-labelledby="select-group-{{ id }}-label"{% endif %}
    data-slot="select-group"
    {{ attributes.defaults({
        class: 'scroll-my-1 p-1'|tailwind_classes,
    }) }}
>
    {##- The group options, typically a `Select:Label` followed by `Select:Item` components. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Select/Item.html.twig
{%- props
    ## string The value submitted when this item is selected.
    value,
    ## boolean Whether the item is disabled.
    disabled = false
-%}
{%- set _select_value = inject('select.value', '') -%}
{%- set _selected = value is same as(_select_value) -%}
<div
    role="option"
    tabindex="-1"
    data-slot="select-item"
    data-select-target="item"
    data-value="{{ value }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    aria-selected="{{ _selected ? 'true' : 'false' }}"
    aria-disabled="{{ disabled ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: 'group/select-item relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4'|tailwind_classes,
        'data-action': 'click->select#selectItem pointermove->select#highlightItem pointerleave->select#unhighlightItem',
    }) }}
>
    <span data-slot="select-item-indicator" class="pointer-events-none absolute end-2 flex size-4 items-center justify-center opacity-0 group-aria-selected/select-item:opacity-100">
        <twig:ux:icon name="lucide:check" class="size-4" />
    </span>
    <span data-slot="select-item-text" class="flex flex-1 gap-2">
        {##- The item label. -#}
        {%- block content %}{% endblock -%}
    </span>
</div>
templates/components/Select/Label.html.twig
{%- set _selectGroup_labelId = inject('selectGroup.labelId', null) -%}
<div
    {% if _selectGroup_labelId is not null %}id="{{ _selectGroup_labelId }}"{% endif %}
    data-slot="select-label"
    {{ attributes.defaults({
        class: 'px-1.5 py-1 text-xs text-muted-foreground'|tailwind_classes,
    }) }}
>
    {##- The label text. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Select/Separator.html.twig
<div
    role="separator"
    aria-orientation="horizontal"
    data-slot="select-separator"
    {{ attributes.defaults({
        class: 'pointer-events-none -mx-1 my-1 h-px bg-border'|tailwind_classes,
    }) }}
></div>
templates/components/Select/Trigger.html.twig
{%- props
    ## 'default'|'sm' The trigger size.
    size = 'default'
-%}
{%- set _select_value = inject('select.value', '') -%}
{%- set _select_open = inject('select.open', false) -%}
{%- set _select_disabled = inject('select.disabled', false) -%}
{%- set _select_triggerId = inject('select.triggerId', null) -%}
{%- set _select_contentId = inject('select.contentId', null) -%}
{%- set style = html_cva(
    base: 'flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pe-2 ps-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[has-value=false]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 *:data-[slot=select-value]:flex [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
    variants: {
        size: {
            default: 'h-8',
            sm: 'h-7 rounded-[min(var(--radius-md),10px)]',
        },
    },
) -%}
<button
    type="button"
    role="combobox"
    id="{{ _select_triggerId }}"
    data-slot="select-trigger"
    data-select-target="trigger"
    data-size="{{ size }}"
    data-state="{{ _select_open ? 'open' : 'closed' }}"
    data-has-value="{{ _select_value is empty ? 'false' : 'true' }}"
    aria-haspopup="listbox"
    aria-controls="{{ _select_contentId }}"
    aria-expanded="{{ _select_open ? 'true' : 'false' }}"
    {% if _select_disabled %}disabled{% endif %}
    {{ attributes.defaults({
        class: style.apply({size: size})|tailwind_classes,
        'data-action': 'click->select#toggle keydown->select#onTriggerKeydown',
    }) }}
>
    {##- The trigger content, typically a `Select:Value`. -#}
    {%- block content %}{% endblock -%}
    <twig:ux:icon name="lucide:chevron-down" data-slot="select-trigger-icon" class="pointer-events-none size-4 text-muted-foreground" />
</button>
templates/components/Select/Value.html.twig
{%- props
    ## string Text shown while no option is selected.
    placeholder = ''
-%}
<span
    data-slot="select-value"
    data-select-target="value"
    data-placeholder="{{ placeholder }}"
    {{ attributes.defaults({
        class: 'flex flex-1 text-start'|tailwind_classes,
    }) }}
>{{ placeholder }}</span>

Usage

<twig:Select id="fruit" name="fruit" value="banana">
    <twig:Select:Trigger>
        <twig:Select:Value placeholder="Select a fruit" />
    </twig:Select:Trigger>
    <twig:Select:Content position="item-aligned | popper">
        <twig:Select:Group id="fruits">
            <twig:Select:Label>Fruits</twig:Select:Label>
            <twig:Select:Item value="apple">Apple</twig:Select:Item>
            <twig:Select:Item value="banana">Banana</twig:Select:Item>
        </twig:Select:Group>
        <twig:Select:Separator />
        <twig:Select:Group id="vegetables">
            <twig:Select:Label>Vegetables</twig:Select:Label>
            <twig:Select:Item value="carrot">Carrot</twig:Select:Item>
        </twig:Select:Group>
    </twig:Select:Content>
</twig:Select>

Set name to submit the selected value with a form, required to block the submission while nothing is selected, and value to preselect an option. The id is set on the trigger, so a <label for> can point at it. Selecting an option dispatches a select:change event carrying the new value and label.

Examples

Align Item With Trigger

Use the position prop on Select:Content to control alignment. With position="item-aligned" (the default), the popup is placed so the selected item appears over the trigger. With position="popper", it is aligned to the trigger edge.

100%
Loading...
<div class="flex w-full max-w-xs flex-col gap-6" style="min-height: 340px">
    <twig:Field>
        <twig:Field:Label for="align-item-aligned">Item aligned</twig:Field:Label>
        <twig:Select id="align-item-aligned" value="banana">
            <twig:Select:Trigger class="w-full">
                <twig:Select:Value placeholder="Select a fruit" />
            </twig:Select:Trigger>
            <twig:Select:Content position="item-aligned">
                <twig:Select:Group>
                    <twig:Select:Item value="apple">Apple</twig:Select:Item>
                    <twig:Select:Item value="banana">Banana</twig:Select:Item>
                    <twig:Select:Item value="blueberry">Blueberry</twig:Select:Item>
                    <twig:Select:Item value="grapes">Grapes</twig:Select:Item>
                    <twig:Select:Item value="pineapple">Pineapple</twig:Select:Item>
                </twig:Select:Group>
            </twig:Select:Content>
        </twig:Select>
    </twig:Field>
    <twig:Field>
        <twig:Field:Label for="align-popper">Popper</twig:Field:Label>
        <twig:Select id="align-popper" value="banana">
            <twig:Select:Trigger class="w-full">
                <twig:Select:Value placeholder="Select a fruit" />
            </twig:Select:Trigger>
            <twig:Select:Content position="popper">
                <twig:Select:Group>
                    <twig:Select:Item value="apple">Apple</twig:Select:Item>
                    <twig:Select:Item value="banana">Banana</twig:Select:Item>
                    <twig:Select:Item value="blueberry">Blueberry</twig:Select:Item>
                    <twig:Select:Item value="grapes">Grapes</twig:Select:Item>
                    <twig:Select:Item value="pineapple">Pineapple</twig:Select:Item>
                </twig:Select:Group>
            </twig:Select:Content>
        </twig:Select>
    </twig:Field>
</div>

Groups

Use Select:Group, Select:Label and Select:Separator to organize items.

100%
Loading...
<div class="mx-auto w-full max-w-48" style="min-height: 380px">
    <twig:Select id="groups" class="block">
        <twig:Select:Trigger class="w-full">
            <twig:Select:Value placeholder="Select a fruit" />
        </twig:Select:Trigger>
        <twig:Select:Content>
            <twig:Select:Group id="groups-fruits">
                <twig:Select:Label>Fruits</twig:Select:Label>
                <twig:Select:Item value="apple">Apple</twig:Select:Item>
                <twig:Select:Item value="banana">Banana</twig:Select:Item>
                <twig:Select:Item value="blueberry">Blueberry</twig:Select:Item>
            </twig:Select:Group>
            <twig:Select:Separator />
            <twig:Select:Group id="groups-vegetables">
                <twig:Select:Label>Vegetables</twig:Select:Label>
                <twig:Select:Item value="carrot">Carrot</twig:Select:Item>
                <twig:Select:Item value="broccoli">Broccoli</twig:Select:Item>
                <twig:Select:Item value="spinach">Spinach</twig:Select:Item>
            </twig:Select:Group>
        </twig:Select:Content>
    </twig:Select>
</div>

Scrollable

A select with many items that scrolls.

100%
Loading...
<div class="mx-auto w-full max-w-64" style="min-height: 380px">
    <twig:Select id="scrollable" class="block">
        <twig:Select:Trigger class="w-full">
            <twig:Select:Value placeholder="Select a timezone" />
        </twig:Select:Trigger>
        <twig:Select:Content>
            <twig:Select:Group id="scrollable-north-america">
                <twig:Select:Label>North America</twig:Select:Label>
                <twig:Select:Item value="est">Eastern Standard Time</twig:Select:Item>
                <twig:Select:Item value="cst">Central Standard Time</twig:Select:Item>
                <twig:Select:Item value="mst">Mountain Standard Time</twig:Select:Item>
                <twig:Select:Item value="pst">Pacific Standard Time</twig:Select:Item>
                <twig:Select:Item value="akst">Alaska Standard Time</twig:Select:Item>
                <twig:Select:Item value="hst">Hawaii Standard Time</twig:Select:Item>
            </twig:Select:Group>
            <twig:Select:Group id="scrollable-europe-africa">
                <twig:Select:Label>Europe &amp; Africa</twig:Select:Label>
                <twig:Select:Item value="gmt">Greenwich Mean Time</twig:Select:Item>
                <twig:Select:Item value="cet">Central European Time</twig:Select:Item>
                <twig:Select:Item value="eet">Eastern European Time</twig:Select:Item>
                <twig:Select:Item value="west">Western European Summer Time</twig:Select:Item>
                <twig:Select:Item value="cat">Central Africa Time</twig:Select:Item>
                <twig:Select:Item value="eat">East Africa Time</twig:Select:Item>
            </twig:Select:Group>
            <twig:Select:Group id="scrollable-asia">
                <twig:Select:Label>Asia</twig:Select:Label>
                <twig:Select:Item value="msk">Moscow Time</twig:Select:Item>
                <twig:Select:Item value="ist">India Standard Time</twig:Select:Item>
                <twig:Select:Item value="cst_china">China Standard Time</twig:Select:Item>
                <twig:Select:Item value="jst">Japan Standard Time</twig:Select:Item>
                <twig:Select:Item value="kst">Korea Standard Time</twig:Select:Item>
                <twig:Select:Item value="ist_indonesia">Indonesia Central Standard Time</twig:Select:Item>
            </twig:Select:Group>
            <twig:Select:Group id="scrollable-australia-pacific">
                <twig:Select:Label>Australia &amp; Pacific</twig:Select:Label>
                <twig:Select:Item value="awst">Australian Western Standard Time</twig:Select:Item>
                <twig:Select:Item value="acst">Australian Central Standard Time</twig:Select:Item>
                <twig:Select:Item value="aest">Australian Eastern Standard Time</twig:Select:Item>
                <twig:Select:Item value="nzst">New Zealand Standard Time</twig:Select:Item>
                <twig:Select:Item value="fjt">Fiji Time</twig:Select:Item>
            </twig:Select:Group>
            <twig:Select:Group id="scrollable-south-america">
                <twig:Select:Label>South America</twig:Select:Label>
                <twig:Select:Item value="art">Argentina Time</twig:Select:Item>
                <twig:Select:Item value="bot">Bolivia Time</twig:Select:Item>
                <twig:Select:Item value="brt">Brasilia Time</twig:Select:Item>
                <twig:Select:Item value="clt">Chile Standard Time</twig:Select:Item>
            </twig:Select:Group>
        </twig:Select:Content>
    </twig:Select>
</div>

Disabled

Set disabled on the root to disable the whole control, or on a single Select:Item to disable just that option.

100%
Loading...
<div class="mx-auto w-full max-w-48">
    <twig:Select id="disabled" class="block" disabled>
        <twig:Select:Trigger class="w-full">
            <twig:Select:Value placeholder="Select a fruit" />
        </twig:Select:Trigger>
        <twig:Select:Content>
            <twig:Select:Group>
                <twig:Select:Item value="apple">Apple</twig:Select:Item>
                <twig:Select:Item value="banana">Banana</twig:Select:Item>
                <twig:Select:Item value="blueberry">Blueberry</twig:Select:Item>
                <twig:Select:Item value="grapes" disabled>Grapes</twig:Select:Item>
                <twig:Select:Item value="pineapple">Pineapple</twig:Select:Item>
            </twig:Select:Group>
        </twig:Select:Content>
    </twig:Select>
</div>

Invalid

Add the data-invalid attribute to the Field component and the aria-invalid attribute to the Select:Trigger component to show an error state.

100%
Loading...
<div class="w-full max-w-48" style="min-height: 280px">
    <twig:Field data-invalid="true">
        <twig:Field:Label for="invalid">Fruit</twig:Field:Label>
        <twig:Select id="invalid" name="fruit" required>
            <twig:Select:Trigger class="w-full" aria-invalid="true" aria-describedby="invalid-error">
                <twig:Select:Value placeholder="Select a fruit" />
            </twig:Select:Trigger>
            <twig:Select:Content>
                <twig:Select:Group>
                    <twig:Select:Item value="apple">Apple</twig:Select:Item>
                    <twig:Select:Item value="banana">Banana</twig:Select:Item>
                    <twig:Select:Item value="blueberry">Blueberry</twig:Select:Item>
                </twig:Select:Group>
            </twig:Select:Content>
        </twig:Select>
        <twig:Field:Error id="invalid-error">Please select a fruit.</twig:Field:Error>
    </twig:Field>
</div>

RTL

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

100%
Loading...
<div class="flex flex-col items-center gap-4" style="min-height: 460px">
    {# Arabic #}
    <div dir="rtl">
        <twig:Select id="rtl-ar">
            <twig:Select:Trigger class="w-48">
                <twig:Select:Value placeholder="اختر فاكهة" />
            </twig:Select:Trigger>
            <twig:Select:Content>
                <twig:Select:Group id="rtl-ar-fruits">
                    <twig:Select:Label>الفواكه</twig:Select:Label>
                    <twig:Select:Item value="apple">تفاح</twig:Select:Item>
                    <twig:Select:Item value="banana">موز</twig:Select:Item>
                    <twig:Select:Item value="blueberry">توت أزرق</twig:Select:Item>
                </twig:Select:Group>
                <twig:Select:Separator />
                <twig:Select:Group id="rtl-ar-vegetables">
                    <twig:Select:Label>الخضروات</twig:Select:Label>
                    <twig:Select:Item value="carrot">جزر</twig:Select:Item>
                    <twig:Select:Item value="broccoli">بروكلي</twig:Select:Item>
                    <twig:Select:Item value="spinach">سبانخ</twig:Select:Item>
                </twig:Select:Group>
            </twig:Select:Content>
        </twig:Select>
    </div>

    {# Hebrew #}
    <div dir="rtl">
        <twig:Select id="rtl-he">
            <twig:Select:Trigger class="w-48">
                <twig:Select:Value placeholder="בחר פרי" />
            </twig:Select:Trigger>
            <twig:Select:Content>
                <twig:Select:Group id="rtl-he-fruits">
                    <twig:Select:Label>פירות</twig:Select:Label>
                    <twig:Select:Item value="apple">תפוח</twig:Select:Item>
                    <twig:Select:Item value="banana">בננה</twig:Select:Item>
                    <twig:Select:Item value="blueberry">אוכמניה</twig:Select:Item>
                </twig:Select:Group>
                <twig:Select:Separator />
                <twig:Select:Group id="rtl-he-vegetables">
                    <twig:Select:Label>ירקות</twig:Select:Label>
                    <twig:Select:Item value="carrot">גזר</twig:Select:Item>
                    <twig:Select:Item value="broccoli">ברוקולי</twig:Select:Item>
                    <twig:Select:Item value="spinach">תרד</twig:Select:Item>
                </twig:Select:Group>
            </twig:Select:Content>
        </twig:Select>
    </div>
</div>

Accessibility

  • Select:Trigger is a role="combobox" button wired to the popup with aria-haspopup="listbox", aria-controls and aria-expanded, which the Stimulus controller keeps in sync as the popup opens and closes. It carries the id of Select, so a Field:Label with a matching for gives it its name.
  • Select:Content is a role="listbox" labelled by the trigger, and each Select:Item is a role="option" carrying aria-selected. Focus moves to the options themselves (roving tabindex="-1") and follows the mouse pointer, so the highlighted option is always the focused one.
  • Keyboard: Enter, Space, ↓ and ↑ open the popup; ↓/↑ move between options, Home/End jump to the first and last; typing letters moves to the next option starting with them, and selects it directly when the popup is closed; Enter or Space selects; Escape closes and returns focus to the trigger; Tab closes and moves on.
  • Select:Value renders its placeholder server-side; the controller replaces it with the selected option's label on connect. Without JavaScript the trigger therefore shows the placeholder, so keep the value in a Field:Description or a server-rendered summary when that matters.
  • With name, the value is carried by a hidden native <select>: required blocks the form submission like on a native control, and a disabled Select submits no value.
  • Give Select:Group an id so its Select:Label is wired with aria-labelledby and the group is announced with a name. A group without an id is announced as an unnamed group.
  • Set aria-invalid="true" on Select:Trigger for the invalid styling, and point aria-describedby at the message explaining what is wrong, as the Invalid example does.
  • disabled on the root disables the trigger itself; disabled on an item marks it aria-disabled and takes it out of the keyboard order while leaving it visible.

API Reference

<twig:Select>

Prop Type Default
id 
string -
name 
string|null null
value 
string ''
disabled 
boolean false
required 
boolean false
open 
boolean false
Block Description
content A Select:Trigger and a Select:Content.

<twig:Select:Content>

Prop Type Default
position 
'item-aligned'|'popper' 'item-aligned'
side 
'top'|'bottom' 'bottom'
align 
'start'|'center'|'end' 'start'
Block Description
content The options, typically Select:Group, Select:Label, Select:Item and Select:Separator components.

<twig:Select:Group>

Prop Type Default
id 
string|null null
Block Description
content The group options, typically a Select:Label followed by Select:Item components.

<twig:Select:Item>

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

<twig:Select:Label>

Block Description
content The label text.

<twig:Select:Trigger>

Prop Type Default
size 
'default'|'sm' 'default'
Block Description
content The trigger content, typically a Select:Value.

<twig:Select:Value>

Prop Type Default
placeholder 
string ''