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 fetched - ♿ ARIA Combobox —
role="combobox",role="listbox",role="option"with live attributes - 🌈 6 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 syntax - 📏 3 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
multipleattribute - 🧩 Grouped Options —
<optgroup label="...">renders as section headers
Source Code
View Source Code
import {
aria,
computed,
createFormIds,
defineComponent,
defineField,
effect,
html,
inject,
onMount,
onSlotChange,
ref,
signal,
typed,
watch,
} from '@vielzeug/craftit';
import { createListNavigation, createOverlayControl } from '@vielzeug/craftit/labs';
import type { VisualVariant } from '../../types';
import '../../feedback/chip/chip';
import type { SelectableFieldProps } from '../shared/base-props';
import { checkIcon, chevronDownIcon } from '../../icons';
import { disabledLoadingMixin, forcedColorsFocusMixin, formFieldMixins, sizeVariantMixin } from '../../styles';
import { FIELD_SIZE_PRESET } from '../shared/design-presets';
import { createDropdownPositioner, mountLabelSyncStandalone } from '../shared/dom-sync';
import { FORM_CTX } from '../shared/form-context';
import {
type ChoiceChangeDetail,
computeControlledCsvState,
createChoiceChangeDetail,
resolveMergedAssistiveText,
} from '../shared/utils';
import { createFieldValidation } from '../shared/validation';
import componentStyles from './select.css?inline';
// ============================================
// Types
// ============================================
type OptionItem = {
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 BitSelectEvents = {
change: ChoiceChangeDetail;
};
export type BitSelectProps = SelectableFieldProps<Exclude<VisualVariant, 'glass' | 'text' | 'frost'>> & {
/** Show loading state in dropdown */
loading?: boolean;
/** Allow selecting multiple options */
multiple?: boolean;
/** JS options array (alternative to slotted <option> elements) */
options?: OptionItem[];
/** 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 bit-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
* @attr {string} variant - Visual variant
* @attr {string} size - Component size
* @attr {string} rounded - Border radius
* @attr {boolean} fullwidth - Expand to full width
*
* @fires change - Fired when selection changes. detail: { value: string, values: string[], labels: string[], originalEvent?: Event }
*
* @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
*
* @example
* ```html
* <bit-select label="Role" value="admin">
* <option value="admin">Administrator</option>
* <option value="editor">Editor</option>
* <option value="viewer">Viewer</option>
* </bit-select>
*
* <bit-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>
* </bit-select>
* ```
*/
export const SELECT_TAG = defineComponent<BitSelectProps, BitSelectEvents>({
formAssociated: true,
props: {
color: { default: undefined },
disabled: { default: false },
error: { default: '', omit: true },
fullwidth: { default: false },
helper: { default: '' },
label: { default: '' },
'label-placement': { default: 'inset' },
loading: { default: false },
multiple: { default: false },
name: { default: '' },
options: typed<OptionItem[] | undefined>(undefined, { reflect: false }),
placeholder: { default: '' },
required: { default: false },
rounded: { default: undefined },
size: { default: undefined },
value: { default: '' },
variant: { default: undefined },
},
setup({ emit, host, props }) {
// ============================================
// State
// ============================================
const selectedValues = signal<string[]>([]);
const slottedOptions = signal<OptionItem[]>([]);
const isOpen = signal(false);
const focusedIndex = signal(-1);
const isLoading = computed(() => Boolean(props.loading.value));
// Merged options: explicit prop value overrides slotted options.
const options = computed(() => {
const propOptions = props.options.value;
return propOptions !== undefined ? propOptions : slottedOptions.value;
});
const formCtx = inject(FORM_CTX, undefined);
// Form-associated value (comma-separated for multiple)
const formValue = computed(() => selectedValues.value.join(','));
const fd = defineField(
{ disabled: computed(() => Boolean(props.disabled.value) || Boolean(formCtx?.disabled.value)), value: formValue },
{
onReset: () => {
selectedValues.value = [];
},
},
);
const { triggerValidation } = createFieldValidation(formCtx, fd);
// Sync host attributes from component state for CSS hooks.
watch(
isOpen,
(value) => {
host.toggleAttribute('open', ((value) => Boolean(value))(value));
},
{ immediate: true },
);
watch(
props.error,
(value) => {
host.toggleAttribute('has-error', ((value) => Boolean(value))(value));
},
{ immediate: true },
);
// Accessibility IDs
const { fieldId: selectId, labelId } = createFormIds('select', props.name.value);
const listboxId = `listbox-${selectId}`;
// DOM refs
let triggerEl: HTMLElement | null = null;
let dropdownEl: HTMLElement | null = null;
// Refs for dynamic content
const labelOutsideRef = ref<HTMLSpanElement>();
const labelInsetRef = ref<HTMLSpanElement>();
// ============================================
// Option reading from slot
// ============================================
function readOptions() {
const slot = host.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;
}
// Initialize selectedValues from prop
effect(() => {
selectedValues.value = computeControlledCsvState(props.value.value).values;
});
// ============================================
// Display value
// ============================================
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 assistiveText = computed(() => resolveMergedAssistiveText(props.error.value, props.helper.value));
const showChips = computed(() => props.multiple.value && selectedValues.value.length > 0);
const triggerText = computed(() => displayLabel.value || props.placeholder.value || '');
const hasLabel = computed(() => !!props.label.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;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.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', createChoiceChangeDetail(values, labels, originalEvent));
}
function removeChip(event: Event): void {
event.stopPropagation();
const value = (event as CustomEvent<{ value?: string }>).detail?.value;
if (value === undefined) return;
selectedValues.value = selectedValues.value.filter((v) => v !== value);
emitChange(event);
triggerValidation('change');
}
// ============================================
// Dropdown positioning (shared positioner)
// ============================================
const positioner = createDropdownPositioner(
() => triggerEl,
() => dropdownEl,
);
const listNavigation = createListNavigation<OptionItem>({
getIndex: () => focusedIndex.value,
getItems: () => options.value,
isItemDisabled: (option) => option.disabled,
setIndex: (index) => {
focusedIndex.value = index;
scrollFocusedIntoView();
},
});
const overlay = createOverlayControl({
getBoundaryElement: () => host,
getPanelElement: () => dropdownEl,
getTriggerElement: () => triggerEl,
isDisabled: () => Boolean(props.disabled.value),
isOpen: () => isOpen.value,
positioner: {
floating: () => dropdownEl,
reference: () => triggerEl,
update: () => positioner.updatePosition(),
},
setOpen: (next) => {
isOpen.value = next;
if (!next) listNavigation.reset();
},
});
// ============================================
// Open / Close
// ============================================
function open() {
overlay.open();
requestAnimationFrame(() => {
const selectedIndex =
selectedValues.value.length > 0
? options.value.findIndex((option) => option.value === selectedValues.value[0])
: 0;
listNavigation.set(selectedIndex >= 0 ? selectedIndex : 0);
});
}
function close(reason: 'escape' | 'programmatic' = 'programmatic') {
overlay.close({ reason });
triggerValidation('blur');
}
// ============================================
// Selection
// ============================================
function selectOption(opt: OptionItem, e?: Event) {
if (opt.disabled) return;
if (props.multiple.value) {
selectedValues.value = selectedValues.value.includes(opt.value)
? selectedValues.value.filter((entry) => entry !== opt.value)
: [...selectedValues.value, opt.value];
} else {
selectedValues.value = [opt.value];
close();
}
emitChange(e);
triggerValidation('change');
}
// ============================================
// Keyboard navigation
// ============================================
function scrollFocusedIntoView() {
const idx = focusedIndex.value;
if (idx >= 0) {
const focusedOptionEl = dropdownEl?.querySelector<HTMLElement>(`#${selectId}-opt-${idx}`);
focusedOptionEl?.scrollIntoView({ block: 'nearest' });
return;
}
if (!dropdownEl) return;
const focusedEl = dropdownEl.querySelector<HTMLElement>('[data-focused]');
focusedEl?.scrollIntoView({ block: 'nearest' });
}
function handleTriggerKeydown(e: KeyboardEvent) {
if (props.disabled.value) return;
const opts = options.value;
switch (e.key) {
case ' ':
case 'Enter':
e.preventDefault();
if (isOpen.value) {
const idx = focusedIndex.value;
if (idx >= 0 && idx < opts.length) selectOption(opts[idx], e);
} else {
open();
}
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen.value) {
open();
} else {
listNavigation.next();
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen.value) {
open();
} else {
listNavigation.prev();
}
break;
case 'End':
if (isOpen.value) {
e.preventDefault();
listNavigation.last();
}
break;
case 'Escape':
e.preventDefault();
close('escape');
break;
case 'Home':
if (isOpen.value) {
e.preventDefault();
listNavigation.first();
}
break;
case 'Tab':
close();
break;
}
}
onMount(() => {
onSlotChange('default', readOptions);
// Ensure initial light-DOM <option>/<optgroup> content is available immediately.
readOptions();
mountLabelSyncStandalone(labelInsetRef, labelOutsideRef, props);
if (triggerEl) {
aria(triggerEl, {
activedescendant: () => (focusedIndex.value >= 0 ? `${selectId}-opt-${focusedIndex.value}` : null),
disabled: () => props.disabled.value,
expanded: () => (isOpen.value ? 'true' : 'false'),
invalid: () => !!props.error.value,
labelledby: () => (hasLabel.value ? labelId : null),
});
}
const removeOutsideClick = overlay.bindOutsideClick(document);
return () => {
positioner.destroy();
removeOutsideClick();
};
});
return html`<slot style="display:none"></slot>
<div class="select-wrapper">
<label class="label-outside" id="${labelId}" ref=${labelOutsideRef} hidden></label>
<div
class="field"
ref=${(el: HTMLElement) => {
triggerEl = el;
}}
role="combobox"
tabindex=${() => (props.disabled.value ? '-1' : '0')}
aria-controls="${listboxId}"
aria-expanded="false"
aria-labelledby="${labelId}"
@click=${(e: MouseEvent) => {
e.stopPropagation();
if (isOpen.value) close();
else open();
}}
@keydown=${handleTriggerKeydown}>
<label class="label-inset" id="${labelId}" ref=${labelInsetRef} hidden></label>
<div class="trigger-row">
<div class="chips-row" ?hidden=${() => !showChips.value}>
${() =>
selectedChipItems.value.map(
(item) => html`
<bit-chip
value=${item.value}
aria-label=${item.label}
mode="removable"
variant="flat"
size="sm"
color=${() => props.color.value}
@remove=${removeChip}>
${item.label}
</bit-chip>
`,
)}
</div>
<span
class="trigger-value ${() => (displayLabel.value ? '' : 'trigger-placeholder')}"
?hidden=${() => showChips.value}
>${() => triggerText.value}</span
>
</div>
<span class="trigger-icon" aria-hidden="true">
${chevronDownIcon}
<span class="loader" aria-label="Loading"></span>
</span>
</div>
<div
class="helper-text"
aria-live="polite"
?hidden=${() => assistiveText.value.hidden}
style=${() => (assistiveText.value.isError ? 'color: var(--color-error);' : '')}>
${() => assistiveText.value.text}
</div>
</div>
<div
class="dropdown"
?data-open=${() => isOpen.value}
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=${() => {
focusedIndex.value = row.idx;
}}>
<span>${row.opt.label}</span>
<span class="option-check" aria-hidden="true">${checkIcon}</span>
</div>`,
)}
</div>
</div>`;
},
shadow: { delegatesFocus: true },
styles: [
sizeVariantMixin(FIELD_SIZE_PRESET),
...formFieldMixins,
disabledLoadingMixin(),
forcedColorsFocusMixin('.field'),
componentStyles,
],
tag: 'bit-select',
});Basic Usage
Place <option> children directly inside bit-select.
<bit-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>
</bit-select>
<script type="module">
import '@vielzeug/buildit/select';
</script>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 bit-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('bit-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('bit-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 is an object with value, label, and optional group properties.
const select = document.querySelector('bit-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
bit-select is form-associated. Read the value via FormData or a change event.
<bit-form id="myForm">
<bit-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>
</bit-select>
<bit-button type="submit">Submit</bit-button>
</bit-form>
<script type="module">
import '@vielzeug/buildit/form';
import '@vielzeug/buildit/select';
import '@vielzeug/buildit/button';
document.getElementById('myForm').addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(e.target);
console.log('category:', data.get('category'));
});
document.querySelector('bit-select').addEventListener('change', (e) => {
console.log('Selected value:', e.detail.value);
// For multiple: e.detail.values (string[])
});
</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 |
readonly | boolean | false | Prevent the dropdown from opening |
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[] } | Emitted when the selected value(s) change |
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.
bit-select
✅ Keyboard Navigation
Tabfocuses the trigger;Enter/Spaceopen the dropdown.- Arrow keys navigate options;
Home/Endjump to first / last;Escapecloses;Tabcloses and moves focus out.
✅ Screen Readers
- 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.