Select
Displays a list of options for the user to pick from—triggered by a button.
<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:
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 });
}
}
}
{%- 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>
{%- 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>
{%- 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>
{%- 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>
{%- 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>
<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>
{%- 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>
{%- 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.
<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.
<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.
<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 & 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 & 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.
<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.
<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.
<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:Triggeris arole="combobox"button wired to the popup witharia-haspopup="listbox",aria-controlsandaria-expanded, which the Stimulus controller keeps in sync as the popup opens and closes. It carries theidofSelect, so aField:Labelwith a matchingforgives it its name.Select:Contentis arole="listbox"labelled by the trigger, and eachSelect:Itemis arole="option"carryingaria-selected. Focus moves to the options themselves (rovingtabindex="-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:Valuerenders itsplaceholderserver-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 aField:Descriptionor a server-rendered summary when that matters.- With
name, the value is carried by a hidden native<select>:requiredblocks the form submission like on a native control, and a disabledSelectsubmits no value. - Give
Select:Groupanidso itsSelect:Labelis wired witharia-labelledbyand the group is announced with a name. A group without anidis announced as an unnamed group. - Set
aria-invalid="true"onSelect:Triggerfor the invalid styling, and pointaria-describedbyat the message explaining what is wrong, as theInvalidexample does. disabledon the root disables the trigger itself;disabledon an item marks itaria-disabledand takes it out of the keyboard order while leaving it visible.
API Reference
<twig:Select>
| Prop | Type | Default |
|---|---|---|
id Unique identifier set on the trigger, so a
<label for> can point at it, and used to generate the internal Select IDs. |
string |
- |
name Name of the form field carrying the selected value.
|
string|null |
null |
value The value selected on initial render.
|
string |
'' |
disabled Whether the select is disabled.
|
boolean |
false |
required Whether a value must be selected before the form can be submitted.
|
boolean |
false |
open Whether the select is open on initial render.
|
boolean |
false |
| Block | Description |
|---|---|
content |
A Select:Trigger and a Select:Content. |
<twig:Select:Content>
| Prop | Type | Default |
|---|---|---|
position How the content is placed: over the trigger on the selected item, or anchored to the trigger edge.
|
'item-aligned'|'popper' |
'item-aligned' |
side Which side of the trigger the content appears on.
|
'top'|'bottom' |
'bottom' |
align Alignment of the content along the cross axis.
|
'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 Unique identifier linking the group to its
Select:Label, so the group is announced with a name. |
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 The value submitted when this item is selected.
|
string |
- |
disabled Whether the item is 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 The trigger size.
|
'default'|'sm' |
'default' |
| Block | Description |
|---|---|
content |
The trigger content, typically a Select:Value. |
<twig:Select:Value>
| Prop | Type | Default |
|---|---|---|
placeholder Text shown while no option is selected.
|
string |
'' |