Select
A fully-featured, form-associated select widget that reads native <option> and <optgroup> children, supports single and multiple selection, keyboard navigation, grouped options, and ARIA combobox semantics.
Features
Full Keyboard Nav — Arrow keys, Enter, Space, Escape, Home, End, Tab Loading State — loadingattribute shows a loading indicator while options are being fetchedARIA Combobox — role="combobox",role="listbox",role="option"with live attributes6 Semantic Colors — primary, secondary, info, success, warning, error 5 Variants — solid, flat, bordered, outline, ghost Label Placement — inset (floating) or outside Native Options — use standard <option>and<optgroup>children; no custom syntax3 Sizes — sm, md, lg Helper & Error Text — inline helper or error message below the control Form-Associated — participates in native form submission Multiple Selection — chip-based multi-select via multipleattributeGrouped Options — <optgroup label="...">renders as section headers
Source Code
View Source Code
import { define, useField, html, inject, prop } from '@vielzeug/craft';
import { computed, signal, watch } from '@vielzeug/ripple';
import type { ChoiceChangeDetail, DropdownCloseReason, OverlayOpenDetail, OverlayOpenReason } from '../../headless';
import type { SelectableFieldProps } from '../../shared';
import type { VisualVariant } from '../../types';
import { lifecycleSignal, createChoiceField, createOptionList } from '../../headless';
import '../../feedback/chip/chip';
import '../../content/icon/icon';
import '../input/input';
import { disablableBundle, loadableBundle, roundableBundle, sizableBundle, themableBundle } from '../../shared';
import { reducedMotionMixin } from '../../styles';
import { FORM_CTX, useFormContext } from '../shared/form-context';
import componentStyles from './select.css?inline';
// ── Types ─────────────────────────────────────────────────────────────
type OptionItem = {
disabled: boolean;
group?: string;
label: string;
value: string;
};
export type SgSelectOptionInput = {
disabled?: boolean;
group?: string;
label?: string;
value: string;
};
type FlatRow =
| {
idx: number;
opt: OptionItem;
type: 'option';
}
| {
label: string;
type: 'group';
};
// ── Styles ─────────────────────────────────────────────────────────────
// ── Component Props ─────────────────────────────────────────────────────────────
/** Select component properties */
export type SgSelectEvents = {
change: ChoiceChangeDetail;
close: { reason: DropdownCloseReason };
open: OverlayOpenDetail;
};
export type SgSelectProps = SelectableFieldProps<Exclude<VisualVariant, 'text' | 'frost'>> & {
/** Show loading state in dropdown */
loading?: boolean;
/** Allow selecting multiple options */
multiple?: boolean;
/** JS options array (alternative to slotted <option> elements) */
options?: SgSelectOptionInput[];
/** Mark the field as required */
required?: boolean;
};
/**
* A fully custom form-associated select dropdown with keyboard navigation and ARIA support.
* Reads `<option>` and `<optgroup>` children from the default slot.
*
* @element sg-select
*
* @attr {string} label - Label text
* @attr {string} label-placement - 'inset' | 'outside'
* @attr {string} value - Current selected value(s) (comma-separated for multiple)
* @attr {string} placeholder - Placeholder when no option selected
* @attr {string} name - Form field name
* @attr {boolean} multiple - Enable multi-select
* @attr {boolean} disabled - Disable the select
* @attr {boolean} required - Required field
* @attr {string} helper - Helper text below the select
* @attr {string} error - Error message
* @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
* @attr {string} variant - Visual variant: 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost'
* @attr {string} size - Component size: 'sm' | 'md' | 'lg'
* @attr {string} rounded - Border radius: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
* @attr {boolean} fullwidth - Expand to full width
*
* @fires change - Fired when selection changes. detail: { value: string, values: string[], labels: string[], originalEvent?: Event }
* @fires open - Fired when the dropdown opens. detail: { reason: 'trigger' | 'programmatic' }
* @fires close - Fired when the dropdown closes. detail: { reason: 'escape' | 'outsideClick' | 'programmatic' | 'trigger' }
*
* @slot - `<option>` and `<optgroup>` elements
*
* @cssprop --select-bg - Background
* @cssprop --select-border-color - Border color
* @cssprop --select-radius - Border radius
* @cssprop --select-padding - Padding
* @cssprop --select-font-size - Font size
* @cssprop --select-placeholder-color - Placeholder text color
*
* @part trigger - The `sg-input` trigger element that opens the dropdown.
* @part dropdown - The dropdown listbox panel container.
*
* @example
* ```html
* <sg-select label="Role" value="admin">
* <option value="admin">Administrator</option>
* <option value="editor">Editor</option>
* <option value="viewer">Viewer</option>
* </sg-select>
*
* <sg-select label="Tags" multiple color="primary">
* <optgroup label="Frontend">
* <option value="react">React</option>
* <option value="vue">Vue</option>
* </optgroup>
* <optgroup label="Backend">
* <option value="node">Node.js</option>
* </optgroup>
* </sg-select>
* ```
*/
export const SELECT_TAG = 'sg-select' as const;
define<SgSelectProps, SgSelectEvents>(SELECT_TAG, {
formAssociated: true,
props: {
...themableBundle,
...sizableBundle,
...disablableBundle,
...loadableBundle,
...roundableBundle,
error: prop.string(),
fullwidth: prop.bool(false),
helper: prop.string(),
label: prop.string(),
'label-placement': prop.oneOf(['inset', 'outside'] as const, 'inset'),
multiple: prop.bool(false),
name: prop.string(),
options: prop.json(undefined as SgSelectOptionInput[] | undefined),
placeholder: prop.string(),
required: prop.bool(false),
value: prop.string(),
variant: prop.string<'flat' | 'solid' | 'bordered' | 'outline' | 'ghost'>(),
},
setup(props, { bind, el, emit, onCleanup, onMounted, slots }) {
const shadowRoot = el.shadowRoot;
// ────────────────────────────────────────────────────────────────
// State & Context
// ────────────────────────────────────────────────────────────────
const slottedOptions = signal<OptionItem[]>([]);
const isLoading = computed(() => Boolean(props.loading.value));
// Merged options: explicit prop value overrides slotted options
function normalizeOption(option: SgSelectOptionInput): OptionItem {
return {
disabled: Boolean(option.disabled),
group: option.group,
label: option.label ?? option.value,
value: option.value,
};
}
const options = computed(() => {
const explicitOptions = props.options.value;
return Array.isArray(explicitOptions) ? explicitOptions.map(normalizeOption) : slottedOptions.value;
});
const formCtx = inject(FORM_CTX);
const fCtxProps = useFormContext(bind, props, formCtx);
let triggerEl: HTMLElement | null = null;
let dropdownEl: HTMLElement | null = null;
const abortSignal = lifecycleSignal(onCleanup);
let _formField: { reportValidity(): void } | null = null;
const choice = createChoiceField({
disabled: fCtxProps.disabled,
error: props.error,
getFormField: () => _formField,
helper: props.helper,
label: props.label,
labelPlacement: props['label-placement'],
multiple: props.multiple,
prefix: 'select',
signal: abortSignal,
validateOn: formCtx?.validateOn,
value: props.value,
});
_formField = useField<string>({ disabled: choice.disabled, toFormValue: (v) => v, value: choice.formValue });
const optionList = createOptionList<OptionItem>({
getBoundary: () => el,
getFocusedOptionElement: () => dropdownEl?.querySelector<HTMLElement>('[data-focused]') ?? null,
getItems: () => options.value,
getPanel: () => dropdownEl,
getReference: () => triggerEl,
getTrigger: () => triggerEl,
isDisabled: () => choice.disabled.value,
onClose: (reason) => {
emit('close', { reason });
choice.triggerValidation('blur');
},
onOpen: (reason) => emit('open', { reason }),
signal: abortSignal,
});
const { triggerValidation } = choice;
const selectedValues = choice.selectedValues;
const isDisabled = choice.disabled;
const { fieldId: selectId } = choice;
const listboxId = `listbox-${selectId}`;
const { focusedIndex, isOpen } = optionList;
// Sync host attributes for CSS hooks
bind({
attr: {
'has-error': () => (props.error.value ? true : undefined),
open: () => (isOpen.value ? true : undefined),
size: fCtxProps.size,
variant: fCtxProps.variant,
},
});
// ────────────────────────────────────────────────────────────────
// Option Reading from Slot
// ────────────────────────────────────────────────────────────────
function readOptions() {
const slot = shadowRoot?.querySelector<HTMLSlotElement>('slot');
if (!slot) return;
const assigned = slot.assignedElements({ flatten: true });
const items: OptionItem[] = [];
for (const el of assigned) {
if (el.tagName === 'OPTION') {
const opt = el as HTMLOptionElement;
items.push({ disabled: opt.disabled, label: opt.text || opt.value, value: opt.value });
} else if (el.tagName === 'OPTGROUP') {
const group = el as HTMLOptGroupElement;
const groupLabel = group.label;
for (const child of Array.from(group.querySelectorAll('option'))) {
const opt = child as HTMLOptionElement;
items.push({ disabled: opt.disabled, group: groupLabel, label: opt.text || opt.value, value: opt.value });
}
}
}
slottedOptions.value = items;
}
const displayLabel = computed(() => {
if (selectedValues.value.length === 0) return '';
if (props.multiple.value && selectedValues.value.length > 1) {
return `${selectedValues.value.length} selected`;
}
const first = selectedValues.value[0];
return options.value.find((o) => o.value === first)?.label ?? first;
});
const selectedChipItems = computed(() => {
if (!props.multiple.value) return [];
return selectedValues.value.map((value) => ({
label: options.value.find((o) => o.value === value)?.label ?? value,
value,
}));
});
const showChips = computed(() => props.multiple.value && selectedValues.value.length > 0);
const triggerText = computed(() => displayLabel.value || props.placeholder.value || '');
function buildFlatList(opts: OptionItem[]): FlatRow[] {
const flat: FlatRow[] = [];
const groups = new Map<string | undefined, OptionItem[]>();
for (const opt of opts) {
const key = opt.group;
let group = groups.get(key);
if (!group) {
group = [];
groups.set(key, group);
}
group.push(opt);
}
let globalIdx = 0;
for (const [groupLabel, groupOpts] of groups) {
if (groupLabel !== undefined) flat.push({ label: groupLabel, type: 'group' });
for (const opt of groupOpts) flat.push({ idx: globalIdx++, opt, type: 'option' });
}
return flat;
}
const flatRows = computed(() => buildFlatList(options.value));
function getLabelForValue(value: string): string {
return options.value.find((option) => option.value === value)?.label ?? value;
}
function emitChange(originalEvent?: Event): void {
const values = selectedValues.value;
const labels = values.map((value) => getLabelForValue(value));
emit('change', { labels, originalEvent, values });
}
function removeChip(event: Event): void {
event.stopPropagation();
const value = (event as CustomEvent<{ value?: string }>).detail?.value;
if (value === undefined) return;
choice.removeValue(value);
emitChange(event);
triggerValidation('change');
}
function openPopup(reason: OverlayOpenReason = 'programmatic') {
optionList.open(reason);
requestAnimationFrame(() => {
const selectedIndex =
selectedValues.value.length > 0
? options.value.findIndex((option) => option.value === selectedValues.value[0])
: -1;
if (selectedIndex >= 0) {
optionList.set(selectedIndex);
return;
}
optionList.navigate('first');
});
}
// Selection
function selectOption(opt: OptionItem, e?: Event) {
if (opt.disabled) return;
if (props.multiple.value) {
choice.toggleValue(opt.value);
} else {
choice.selectValue(opt.value);
optionList.close();
}
emitChange(e);
triggerValidation('change');
}
// Keyboard navigation
function handleTriggerKeydown(e: KeyboardEvent) {
if (isDisabled.value) return;
// Let optionList handle arrow key navigation
if (optionList.handleKeydown(e)) return;
switch (e.key) {
case ' ':
case 'Enter':
e.preventDefault();
if (isOpen.value) {
const idx = focusedIndex.value;
const opts = options.value;
if (idx >= 0 && idx < opts.length) selectOption(opts[idx], e);
} else {
openPopup('keyboard');
}
break;
case 'Tab':
optionList.close();
break;
}
}
// sg-input prop helpers
const inputValue = () => triggerText.value;
const inputLabel = () => props.label.value ?? '';
const inputPlaceholder = () => props.placeholder.value ?? '';
const inputLabelPlacement = () => props['label-placement'].value ?? 'inset';
const inputColor = () => props.color?.value ?? undefined;
const inputSize = () => fCtxProps.size?.value ?? undefined;
const inputVariant = () => fCtxProps.variant?.value ?? undefined;
const inputRounded = () => props.rounded?.value ?? undefined;
const inputHelper = () => props.helper.value ?? '';
const inputError = () => props.error.value ?? '';
const inputDisabled = () => (isDisabled.value ? true : undefined);
const inputRequired = () => (props.required.value ? true : undefined);
const inputFullwidth = () => (props.fullwidth.value ? true : undefined);
const inputLoading = () => (props.loading.value ? true : undefined);
const tabIndexAttr = () => (isDisabled.value ? '-1' : '0');
watch(slots.elements(), () => readOptions(), { immediate: true });
onMounted(() => {
let onTriggerClick: ((event: MouseEvent) => void) | null = null;
let onTriggerKeydown: ((event: KeyboardEvent) => void) | null = null;
if (triggerEl) {
onTriggerClick = (event: MouseEvent) => {
event.stopPropagation();
if (isDisabled.value) return;
if (isOpen.value) optionList.close('trigger');
else openPopup('click');
};
onTriggerKeydown = (event: KeyboardEvent) => {
handleTriggerKeydown(event);
};
triggerEl.addEventListener('click', onTriggerClick);
triggerEl.addEventListener('keydown', onTriggerKeydown);
}
return () => {
if (triggerEl && onTriggerClick) triggerEl.removeEventListener('click', onTriggerClick);
if (triggerEl && onTriggerKeydown) triggerEl.removeEventListener('keydown', onTriggerKeydown);
};
});
return html`<slot style="display:none"></slot>
<sg-input
class="trigger"
part="trigger"
readonly
tabindex="${tabIndexAttr}"
role="combobox"
aria-haspopup="listbox"
aria-controls="${listboxId}"
:aria-disabled="${() => (isDisabled.value ? 'true' : null)}"
:aria-expanded="${() => String(isOpen.value)}"
:aria-invalid="${() => (props.error.value ? 'true' : null)}"
:value="${inputValue}"
:label="${inputLabel}"
:placeholder="${inputPlaceholder}"
:label-placement="${inputLabelPlacement}"
:color="${inputColor}"
:size="${inputSize}"
:variant="${inputVariant}"
:rounded="${inputRounded}"
:helper="${inputHelper}"
:error="${inputError}"
?disabled="${inputDisabled}"
?required="${inputRequired}"
?fullwidth="${inputFullwidth}"
?loading="${inputLoading}"
ref="${(el: HTMLElement) => {
triggerEl = el;
}}">
<div slot="prefix" class="chips-row" ?hidden="${() => !showChips.value}">
${() =>
selectedChipItems.value.map(
(item) =>
html`<sg-chip
value="${item.value}"
label="${item.label}"
mode="removable"
variant="flat"
size="sm"
color="${props.color}"
@remove="${removeChip}">
${item.label}
</sg-chip>`,
)}
</div>
<span slot="suffix" class="trigger-suffix" aria-hidden="true">
<span class="loader"></span>
<span class="trigger-icon">
<sg-icon name="chevron-down" size="14" stroke-width="2" aria-hidden="true"></sg-icon>
</span>
</span>
</sg-input>
<div
class="dropdown"
part="dropdown"
?data-open="${isOpen}"
role="listbox"
id="${listboxId}"
aria-label="Options"
ref="${(el: HTMLElement) => {
dropdownEl = el;
}}">
<div class="dropdown-loading" ?hidden="${() => !isLoading.value}">Loading…</div>
<div class="dropdown-empty" ?hidden="${() => isLoading.value || options.value.length > 0}">No options</div>
<div class="options-list" ?hidden="${() => isLoading.value || options.value.length === 0}">
${() =>
flatRows.value.map((row) =>
row.type === 'group'
? html`<div class="optgroup-label" role="presentation">${row.label}</div>`
: html`<div
class="option"
role="option"
id="${`${selectId}-opt-${row.idx}`}"
data-option-index="${String(row.idx)}"
data-option-value="${row.opt.value}"
aria-selected="${() => String(selectedValues.value.includes(row.opt.value))}"
aria-disabled="${() => String(row.opt.disabled)}"
?data-focused="${() => focusedIndex.value === row.idx}"
?data-selected="${() => selectedValues.value.includes(row.opt.value)}"
?data-disabled="${() => row.opt.disabled}"
@click="${(e: MouseEvent) => {
e.stopPropagation();
selectOption(row.opt, e);
}}"
@pointerenter="${() => {
optionList.set(row.idx);
}}">
<span>${row.opt.label}</span>
<span class="option-check" aria-hidden="true">
<sg-icon name="check" size="14" stroke-width="2.5" aria-hidden="true"></sg-icon>
</span>
</div>`,
)}
</div>
</div>`;
},
shadow: { delegatesFocus: true },
styles: [reducedMotionMixin, componentStyles],
});Basic Usage
Place <option> children directly inside sg-select.
<sg-select label="Country">
<option value="">Pick a country…</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="de">Germany</option>
<option value="jp">Japan</option>
</sg-select>Visual Options
Variants
Six visual variants for different UI contexts and levels of emphasis.
Colors
Six semantic colors for different contexts and validation states. Defaults to neutral when no color is specified.
Sizes
Label Placement
Grouped Options
Use native <optgroup> elements to create labelled groups.
Multiple Selection
Add multiple to allow selecting more than one option. Each selected value is displayed as a removable sg-chip tag inside the trigger field — clicking the × on a chip deselects that value without closing the dropdown.
The change event detail includes both value (comma-separated string) and values (array of selected values):
document.querySelector('sg-select').addEventListener('change', (e) => {
console.log('csv:', e.detail.value); // "ts,rust"
console.log('array:', e.detail.values); // ["ts", "rust"]
});Helper & Error Text
States
Loading State
Set loading to show a loading indicator inside the dropdown while options are being fetched from a server. The option list is hidden during loading.
const select = document.querySelector('sg-select');
select.loading = true;
const data = await fetch('/api/countries').then((r) => r.json());
select.options = data.map((c) => ({ value: c.code, label: c.name }));
select.loading = false;JavaScript Options
For dynamic or large option lists, set the options property directly in JavaScript instead of using <option> children. Each item only needs a value; label falls back to the same string when omitted, and group remains optional.
const select = document.querySelector('sg-select');
select.options = [
{ value: 'us', label: 'United States' },
{ value: 'gb', label: 'United Kingdom', group: 'Europe' },
{ value: 'de', label: 'Germany', group: 'Europe' },
{ value: 'fr', label: 'France', group: 'Europe' },
];Assigning a new array to options at any time updates the dropdown immediately. When both <option> children and options are provided, the JS property takes precedence.
In a Form
sg-select is form-associated. Read the value via FormData or a change event.
<sg-form id="myForm">
<sg-select name="category" label="Category" required>
<option value="">Select a category…</option>
<option value="tech">Technology</option>
<option value="science">Science</option>
<option value="art">Art</option>
</sg-select>
<sg-button type="submit">Submit</sg-button>
</sg-form>
<script type="module">
import '@vielzeug/sigil/form';
import '@vielzeug/sigil/select';
import '@vielzeug/sigil/button';
document.getElementById('myForm').addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(e.target);
console.log('category:', data.get('category'));
});
document.querySelector('sg-select').addEventListener('change', (e) => {
console.log('Selected value:', e.detail.value);
console.log('Selected labels:', e.detail.labels);
// For multiple: e.detail.values (string[])
});
document.querySelector('sg-select').addEventListener('open', (e) => {
console.log('Opened because:', e.detail.reason); // 'trigger' | 'programmatic'
});
document.querySelector('sg-select').addEventListener('close', (e) => {
console.log('Closed because:', e.detail.reason); // 'escape' | 'outside-click' | 'programmatic' | 'trigger'
});
</script>API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | '' | Currently selected value |
name | string | '' | Form field name |
label | string | '' | Label text |
label-placement | 'inset' | 'outside' | 'inset' | Label positioning |
placeholder | string | '' | Empty-state placeholder text |
helper | string | '' | Helper text shown below the control |
error | string | '' | Error message; overrides helper text |
variant | 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'solid' | Visual style variant |
color | 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'default' | Color theme |
size | 'sm' | 'md' | 'lg' | 'md' | Control size |
multiple | boolean | false | Allow multiple selections |
disabled | boolean | false | Disable the control |
required | boolean | false | Mark field as required for form validation |
fullwidth | boolean | false | Expand to full width |
rounded | 'none' | 'sm' | 'md' | 'lg' | 'full' | — | Override border-radius |
loading | boolean | false | Show loading indicator in the dropdown |
Slots
| Slot | Description |
|---|---|
| (default) | <option> and <optgroup> elements to display |
Events
| Event | Detail | Description |
|---|---|---|
change | { value: string, values: string[], labels: string[], originalEvent?: Event } | Emitted when the selected value(s) change |
open | { reason: 'trigger' | 'programmatic' } | Emitted when the dropdown opens |
close | { reason: 'escape' | 'outside-click' | 'programmatic' | 'trigger' } | Emitted when the dropdown closes |
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--select-border-color | Default border color | --color-border |
--select-focus-color | Focus ring / active color | Per color theme |
--select-bg | Trigger background | Per variant |
--select-dropdown-bg | Dropdown panel background | --color-surface |
--select-option-hover-bg | Option hover state background | --color-hover |
--select-height | Trigger height | Per size |
Accessibility
The select component follows WCAG 2.1 Level AA standards.
sg-select
Tabfocuses the trigger;Enter/Spaceopen the dropdown.- Arrow keys navigate options;
Home/Endjump to first / last;Escapecloses;Tabcloses and moves focus out.
- The trigger uses
role="combobox"witharia-haspopup="listbox",aria-expanded, andaria-activedescendant. - The dropdown uses
role="listbox"; each option usesrole="option"witharia-selected;aria-multiselectableis set whenmultipleis active. aria-labelledbylinks the label;aria-describedbylinks helper and error text.aria-disabledreflects the disabled state.
Best Practices
Do:
- Supply a placeholder
<option value="">…</option>when the field is not pre-selected. - Use
<optgroup>for lists longer than ~8 options to improve scanability. - Combine
requiredwitherrortext to give users clear validation feedback. - For long single-select lists, consider a searchable implementation instead.
Don't:
- Use
multiplefor selections where only one makes logical sense. - Put more than ~20 ungrouped options in one select — consider a multi-level pattern.