Custom select
A styled select whose options carry an icon, a label and a line of detail, such as a workspace or a delivery method. Keyboard and typeahead per the APG select-only combobox; posts through a form input.
cmp_custom_select_01 Before you use this component
- Install
bits-ui@^2.19.3. - Requires client-side JavaScript to work.
Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_custom_select_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-665003e5ef1deba2233f3666a076c35cf6429c277e943bd59e09cf4a7c993aa0
<!--
A select whose options carry more than a word: an icon, a label and a line of detail, such as a
workspace with its member count or a delivery method with its price. The trigger is a WAI-ARIA
select-only combobox and the popup a listbox, both from Bits UI Select, which also supplies the
form input, typeahead and collision-aware positioning.
The trigger and every row share one icon column, one label edge and one end column (chevron
closed, check open), and the popup is exactly the trigger's width, so the chosen option reads
the same whether the select is open or closed. The popup's transparent border stands in for
the trigger's, so those columns line up to the pixel.
-->
<script lang="ts" module>
import type { Snippet } from 'svelte';
export type CustomSelectSize = 'sm' | 'md';
export interface CustomSelectOption {
/** Posted with the form and passed to onValueChange. Unique and non-empty: '' means none. */
value: string;
/** What the option is called. Typeahead matches the start of it. */
label: string;
/** One line of detail under the label, and after it in the trigger. */
description?: string;
/** Decorative 16 px icon before the label. */
icon?: Snippet;
/** Shown and announced, skipped by the keyboard, never chosen. Say why in its description. */
disabled?: boolean;
/** Consecutive options with the same group sit under one heading with this text. */
group?: string;
}
</script>
<script lang="ts">
import { Select } from 'bits-ui';
import { tick } from 'svelte';
import type { ClassValue } from 'svelte/elements';
interface Props {
/** Visible label above the trigger, and the control's accessible name. */
label: string;
/** The options, in order. With none, the trigger is disabled. */
options: CustomSelectOption[];
/** The chosen option's value. Bindable; undefined until something is chosen. */
value?: string;
/** Name of the form input that posts the value. Omitted, nothing is posted. */
name?: string;
/** Trigger text while nothing is chosen. */
placeholder?: string;
/** One line of guidance under the trigger. */
help?: string;
/** The form will not submit until an option is chosen. */
required?: boolean;
/** Error message under the trigger. Marks the trigger invalid while set. */
error?: string;
/** Read before the error by assistive technology. Translate it with the page. */
errorPrefix?: string;
/** Disables the whole control. */
disabled?: boolean;
/** 'md' (36 px) for forms, 'sm' (32 px) for toolbars and dense rows. */
size?: CustomSelectSize;
/** Whether the listbox is open. Bindable. */
open?: boolean;
/** Called with the new value when someone chooses an option. */
onValueChange?: (value: string) => void;
/** Extra classes on the field, for its width or margins. */
class?: ClassValue;
}
let {
label,
options,
value = $bindable(),
name,
placeholder = 'Choose an option',
help,
required = false,
error,
errorPrefix = 'Error:',
disabled = false,
size = 'md',
open = $bindable(false),
onValueChange,
class: className
}: Props = $props();
const uid = $props.id();
const ids = {
label: `${uid}-label`,
trigger: `${uid}-trigger`,
listbox: `${uid}-listbox`,
help: `${uid}-help`,
error: `${uid}-error`
};
const selected = $derived(options.find((option) => option.value === value));
const inert = $derived(disabled || options.length === 0);
/* Reserve the icon column in every row when any option has an icon, so labels share one edge. */
const withIcons = $derived(options.some((option) => option.icon));
const describedBy = $derived(
[help && ids.help, error && ids.error].filter(Boolean).join(' ') || undefined
);
/* Runs of consecutive options with the same group, each under its heading. */
const sections = $derived.by(() => {
const runs: { group?: string; options: CustomSelectOption[] }[] = [];
for (const option of options) {
const last = runs.at(-1);
if (last && last.group === option.group) last.options.push(option);
else runs.push({ group: option.group, options: [option] });
}
return runs;
});
function choose(next: string) {
// Bits UI chooses whatever is highlighted, and a pointer can highlight a disabled row.
const option = options.find((candidate) => candidate.value === next);
if (!option || option.disabled || next === value) return;
value = next;
onValueChange?.(next);
}
let root = $state<HTMLElement | null>(null);
/*
* Keys Bits UI leaves out of the APG select-only combobox. Closed, a printable character or
* Home/End opens the listbox and moves to the match (Bits UI would change the value without
* opening); keys typed before the options are in are held, then applied. Typeahead is the
* component's own, so its buffer clears when the listbox closes: it matches the start of
* labels, "st" typed quickly matches as one string, a repeated letter cycles, and a new search
* starts after the chosen option, as in the APG example. The highlight moves through Bits UI
* with Home and ArrowDown, which skip disabled options. Open, Tab and Alt+ArrowUp choose the
* highlighted option before closing.
*/
let held: string[] | null = null;
/* How the highlight last moved: the keyboard draws a focus ring on it, a pointer only tints it. */
let nav = $state<'keyboard' | 'pointer'>('pointer');
let replaying = false;
let search = '';
/* The last match, read back by the next key: the highlight attribute updates a frame later. */
let matched: string | undefined;
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function handleKeydown(event: KeyboardEvent) {
if (replaying || event.ctrlKey || event.metaKey) return;
nav = 'keyboard';
const trigger = event.currentTarget as HTMLElement;
const printable = event.key.length === 1 && event.key !== ' ' && !event.altKey;
const jump = printable || event.key === 'Home' || event.key === 'End';
if (held) {
if (jump) {
event.preventDefault();
held.push(event.key);
}
return;
}
if (!open) {
if (jump) {
event.preventDefault();
held = [event.key];
readRoot();
open = true;
applyHeld(trigger);
}
return;
}
if (printable) {
event.preventDefault();
typeahead(trigger, event.key);
} else if (event.key === 'Tab') {
chooseHighlighted(trigger);
} else if (event.key === 'ArrowUp' && event.altKey) {
event.preventDefault();
chooseHighlighted(trigger);
open = false;
}
}
async function applyHeld(trigger: HTMLElement) {
await tick();
// Wait for the options to mount and Bits UI to place its first highlight.
for (let frame = 0; frame < 10; frame++) {
await new Promise((resolve) => requestAnimationFrame(resolve));
if (document.getElementById(ids.listbox)?.querySelector('[role="option"]')) break;
}
const keys = held ?? [];
held = null;
if (!open) return;
for (const key of keys) {
if (key.length === 1) typeahead(trigger, key);
else press(trigger, key);
}
}
function press(trigger: HTMLElement, key: string) {
replaying = true;
trigger.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
replaying = false;
}
function typeahead(trigger: HTMLElement, key: string) {
clearTimeout(searchTimer);
search += key.toLowerCase();
searchTimer = setTimeout(() => (search = ''), 1000);
const enabled = options.filter((option) => !option.disabled);
// A new search starts from the chosen option; a longer one from the highlighted one.
const from = search.length === 1 ? value : (matched ?? highlightedValue(trigger));
const current = enabled.findIndex((option) => option.value === from);
const repeated = [...search].every((char) => char === search[0]);
const term = repeated ? search[0] : search;
const start = current < 0 ? 0 : repeated ? current + 1 : current;
for (let step = 0; step < enabled.length; step++) {
const index = (start + step) % enabled.length;
if (enabled[index].label.toLowerCase().startsWith(term)) {
matched = enabled[index].value;
press(trigger, 'Home');
for (let down = 0; down < index; down++) press(trigger, 'ArrowDown');
return;
}
}
}
function highlightedValue(trigger: HTMLElement) {
const id = trigger.getAttribute('aria-activedescendant');
return (id ? document.getElementById(id) : null)?.dataset.value;
}
$effect(() => () => clearTimeout(searchTimer));
function chooseHighlighted(trigger: HTMLElement) {
const next = highlightedValue(trigger);
if (next !== undefined) choose(next);
}
/*
* The listbox is portalled to the body, out of reach of tokens set on an ancestor of the field.
* Copy the field's resolved values onto it when it opens, with its direction and language.
*/
const TOKENS = [
'--_accent',
'--_ink',
'--_muted',
'--_hairline',
'--_surface',
'--_popup-surface'
];
let popupTokens = $state('');
let dir = $state<'ltr' | 'rtl'>('ltr');
let lang = $state<string | undefined>();
function readRoot() {
if (!root) return;
const style = getComputedStyle(root);
popupTokens = TOKENS.map((token) => `${token}: ${style.getPropertyValue(token)}`).join('; ');
dir = style.direction === 'rtl' ? 'rtl' : 'ltr';
lang = root.closest('[lang]')?.getAttribute('lang') ?? undefined;
}
$effect.pre(() => {
// A control that is disabled or emptied while open closes, so bind:open stays truthful.
if (open && inert) open = false;
else if (open) readRoot();
else search = '';
});
</script>
<div
bind:this={root}
data-size={size}
data-invalid={error ? '' : undefined}
class={['custom-select relative grid w-full min-w-0 gap-2 text-start tabular-nums', className]}
>
<label
id={ids.label}
for={ids.trigger}
class={[
'font-medium text-pretty break-words text-(--_ink)',
size === 'sm' ? 'text-[13px]/5' : 'text-sm/5'
]}
>
{label}
</label>
<Select.Root
type="single"
bind:value={() => value ?? '', (next) => next !== '' && choose(next)}
bind:open
name={name ?? ''}
{required}
disabled={inert}
onOpenChange={(next) => next && readRoot()}
>
<Select.Trigger
id={ids.trigger}
role="combobox"
aria-labelledby={ids.label}
aria-controls={open ? ids.listbox : undefined}
aria-describedby={describedBy}
aria-invalid={error ? 'true' : undefined}
aria-required={required ? 'true' : undefined}
onkeydown={handleKeydown}
class={[
'custom-select__trigger group/trigger flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border bg-(--_surface) text-start text-(--_ink) select-none pointer-coarse:min-h-11',
size === 'sm' ? 'min-h-8 px-3 py-1 text-[13px]/5' : 'min-h-9 px-3 py-2 text-sm/5',
'border-(--_edge) hover:border-(--_edge-hover) data-[state=open]:border-(--_edge-hover)',
'transition-[border-color,background-color] duration-150 ease-(--_ease) motion-reduce:transition-none',
'outline-offset-2 outline-(--_accent) focus-visible:outline-2',
'disabled:cursor-not-allowed disabled:border-(--_hairline) disabled:bg-(--_raised) disabled:text-(--_muted)'
]}
>
{#if selected}
{#if withIcons && selected.icon}
<span
class="custom-select__icon grid h-5 w-4 shrink-0 place-items-center text-(--_muted)"
aria-hidden="true"
>
{@render selected.icon()}
</span>
{/if}
<!-- The detail gives way first, so the label keeps its room in a narrow trigger. -->
<span class="flex min-w-0 flex-1 items-baseline gap-2">
<span class="max-w-full shrink-0 truncate font-medium">{selected.label}</span>
{#if selected.description}
<span class="min-w-0 flex-1 truncate text-(--_muted)">{selected.description}</span>
{/if}
</span>
{:else}
<span class="min-w-0 flex-1 truncate text-(--_placeholder)">{placeholder}</span>
{/if}
<svg
class="size-4 shrink-0 text-(--_muted) transition-transform duration-200 ease-(--_ease) group-data-[state=open]/trigger:rotate-180 motion-reduce:transition-none"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="m4.75 6.5 3.25 3.25 3.25-3.25"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Select.Trigger>
<Select.Portal>
<Select.Content
id={ids.listbox}
aria-labelledby={ids.label}
side="bottom"
align="start"
sideOffset={6}
collisionPadding={8}
{dir}
{lang}
data-size={size}
data-nav={nav}
onpointermove={() => (nav = 'pointer')}
style={popupTokens}
class={[
'custom-select__popup z-50 w-(--bits-select-anchor-width) max-w-(--bits-select-content-available-width) tabular-nums',
'max-h-[min(20rem,var(--bits-select-content-available-height))] overflow-hidden',
'origin-(--bits-select-content-transform-origin) rounded-xl border border-transparent bg-(--_popup-surface) text-(--_ink) shadow-(--_shadow-popup) outline-none'
]}
>
<Select.ScrollUpButton
class="flex h-6 shrink-0 cursor-default items-center justify-center text-(--_muted)"
>
<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="m4.75 9.5 3.25-3.25 3.25 3.25"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Select.ScrollUpButton>
<Select.Viewport class="p-1">
{#each sections as section, s (s)}
{#if section.group}
<Select.Group class={[s > 0 && 'mt-1']}>
<Select.GroupHeading
class="px-2 pt-2 pb-1 text-[11px]/4 font-medium tracking-[0.06em] break-words text-(--_muted) uppercase rtl:tracking-normal rtl:normal-case"
>
{section.group}
</Select.GroupHeading>
{#each section.options as option (option.value)}
{@render row(option)}
{/each}
</Select.Group>
{:else}
{#each section.options as option (option.value)}
{@render row(option)}
{/each}
{/if}
{/each}
</Select.Viewport>
<Select.ScrollDownButton
class="flex h-6 shrink-0 cursor-default items-center justify-center text-(--_muted)"
>
<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="m4.75 6.5 3.25 3.25 3.25-3.25"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
{#if required && !name}
<!-- Bits UI renders its input only with a name; this one carries required validation alone. -->
<input
class="sr-only"
tabindex="-1"
aria-hidden="true"
value={value ?? ''}
required
disabled={inert}
onfocus={() => document.getElementById(ids.trigger)?.focus()}
/>
{/if}
{#if help}
<p
id={ids.help}
class={[
'max-w-[34em] text-pretty break-words text-(--_muted)',
size === 'sm' ? 'text-xs/4' : 'text-[13px]/5'
]}
>
{help}
</p>
{/if}
{#if error}
<p
id={ids.error}
class={[
'flex max-w-[34em] items-start gap-2 font-medium text-(--_error)',
size === 'sm' ? 'text-xs/4' : 'text-[13px]/5'
]}
>
<!-- One line tall, so the mark stays level with the first line of a wrapped message. -->
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<svg class="size-4" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5" />
<path d="M8 4.75v3.75" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
<circle cx="8" cy="11.1" r="1" fill="currentColor" />
</svg>
</span>
<span class="min-w-0 text-pretty break-words"
><span class="sr-only">{`${errorPrefix} `}</span>{error}</span
>
</p>
{/if}
</div>
{#snippet row(option: CustomSelectOption)}
<Select.Item value={option.value} label={option.label} disabled={option.disabled}>
{#snippet child({ props, selected: isSelected })}
<div
{...props}
onpointermove={option.disabled
? undefined
: (props.onpointermove as (event: PointerEvent) => void)}
aria-selected={isSelected ? 'true' : 'false'}
aria-disabled={option.disabled ? 'true' : undefined}
class={[
'custom-select__option group/option flex cursor-pointer items-start gap-2 rounded-lg select-none pointer-coarse:min-h-11',
size === 'sm' ? 'min-h-8 px-2 py-1 text-[13px]/5' : 'min-h-9 px-2 py-2 text-sm/5',
'data-disabled:cursor-not-allowed data-highlighted:bg-(--_highlight)'
]}
>
{#if withIcons}
<span
class="custom-select__icon grid h-5 w-4 shrink-0 place-items-center text-(--_muted) group-data-disabled/option:opacity-50"
aria-hidden="true"
>
{@render option.icon?.()}
</span>
{/if}
<span class="flex min-w-0 flex-1 flex-col">
<span
class="font-medium text-pretty break-words group-data-disabled/option:text-(--_muted)"
>{option.label}</span
>
{#if option.description}
<!-- The comma separates label and detail in the option's accessible name. -->
<span class="sr-only">, </span>
<span class="text-xs/4 text-pretty break-words text-(--_muted)"
>{option.description}</span
>
{/if}
</span>
<!-- The check's column is always there, so choosing never moves a label. -->
<span class="grid h-5 w-4 shrink-0 place-items-center text-(--_accent)" aria-hidden="true">
{#if isSelected}
<svg class="size-4" viewBox="0 0 16 16" fill="none">
<path
d="m3.75 8.25 2.75 2.75 5.75-6"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</span>
</div>
{/snippet}
</Select.Item>
{/snippet}
<style>
/*
* Public tokens: set --custom-select-* on the field or any ancestor to retone it. The popup is
* portalled, so it declares the same defaults and receives the field's resolved values inline.
*/
.custom-select,
:global(.custom-select__popup) {
--_accent: var(--custom-select-accent, #18181b);
--_ink: var(--custom-select-ink, #18181b);
--_muted: var(--custom-select-muted, #52525b);
--_hairline: var(--custom-select-hairline, rgb(0 0 0 / 0.08));
--_surface: var(--custom-select-surface, #ffffff);
--_popup-surface: var(--custom-select-popup-surface, #ffffff);
}
/*
* Formulas. The trigger's edge clears 3:1 on white (an input boundary, DESIGN §3.8) and darkens
* toward the ink on hover; tints are mixed from the ink so they follow any retone.
*/
.custom-select {
--_border: var(--custom-select-border, rgb(0 0 0 / 0.44));
--_error: var(--custom-select-error, #b91c1c);
--_ease: cubic-bezier(0.2, 0, 0, 1);
--_edge: var(--_border);
--_edge-hover: color-mix(in oklab, var(--_border), var(--_ink) 45%);
--_placeholder: color-mix(in oklab, var(--_muted) 82%, var(--_surface));
--_raised: color-mix(in oklab, var(--_ink) 4%, var(--_surface));
}
.custom-select[data-invalid] {
--_edge: var(--_error);
--_edge-hover: var(--_error);
}
/* The popup's elevation (DESIGN §3.5), its ring drawn in the hairline token, and the row tint. */
:global(.custom-select__popup) {
--_shadow-popup:
0 0 0 1px var(--_hairline), 0 4px 6px -1px rgb(0 0 0 / 0.07),
0 10px 15px -3px rgb(0 0 0 / 0.05);
--_highlight: color-mix(in oklab, var(--_ink) 6%, transparent);
}
/* Snippet icons are the consumer's markup: size them in a layer so their own classes win. */
@layer components {
:global(:where(.custom-select__icon) svg) {
width: 1rem;
height: 1rem;
}
}
/*
* The option a keyboard user is on carries the house focus ring, inset so the scrolling list
* never clips it; the tint alone is too faint to find (APG active descendant). A relation
* between the popup's navigation mode and the row, so it lives here rather than in utilities.
*/
:global(.custom-select__popup[data-nav='keyboard'] .custom-select__option[data-highlighted]) {
outline: 2px solid var(--_accent);
outline-offset: -2px;
}
/* Open and close: keyframes, because Bits UI waits for the exit animation before unmounting. */
:global(.custom-select__popup[data-state='open']) {
animation: custom-select-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
:global(.custom-select__popup[data-state='closed']) {
animation: custom-select-out 150ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes custom-select-in {
from {
opacity: 0;
scale: 0.97;
}
}
@keyframes custom-select-out {
to {
opacity: 0;
scale: 0.97;
}
}
@keyframes custom-select-fade-in {
from {
opacity: 0;
}
}
@keyframes custom-select-fade-out {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
:global(.custom-select__popup[data-state='open']) {
animation-name: custom-select-fade-in;
}
:global(.custom-select__popup[data-state='closed']) {
animation-name: custom-select-fade-out;
}
}
</style>
Usage#
On this pageGive it a label and the options; bind the value or listen with onValueChange, and pass name to post it with a form. It chooses one value and nothing more: it does not search or filter (use a combobox), load options asynchronously, choose several values, or validate beyond required.
- Suggested location
src/lib/components/custom-select-01
Limitations
- Option icons are snippets, so options built from JSON map icon names to snippets themselves, as the preview does.
- The popup is portalled to the body. Tokens set on an ancestor of the field are copied onto it when it opens; a token changed while it is open reaches it the next time it opens.
- Typeahead matches the start of the option's label; it does not search descriptions. For filtering, use a combobox.
- One value only. A multi-value select is a different component.
- Before hydration the trigger shows the chosen option and the form input holds its value, but the listbox does not open until the page's JavaScript runs.
- Options do not change weight when chosen; the check in the accent marks the chosen one.
- Option values must be non-empty strings: Bits UI reserves '' for no choice.
Example
<script lang="ts">
import CustomSelect from '$lib/components/custom-select-01/CustomSelect.svelte';
let delivery = $state<string | undefined>('standard');
</script>
<form method="POST">
<CustomSelect
label="Delivery"
name="delivery"
required
bind:value={delivery}
options={[
{ value: 'standard', label: 'Standard delivery', description: '3 to 5 working days · £3.95' },
{ value: 'express', label: 'Express', description: 'Next working day · £7.50' },
{ value: 'saturday', label: 'Saturday morning', description: 'Fully booked', disabled: true }
]}
class="max-w-sm"
/>
</form>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Custom select#
Use it where an option needs more than a word to choose well: a workspace with its size, a
delivery method with its price and arrival, a region with its code. For a plain list of words,
a native <select> is lighter; to let people type and filter, use a combobox.
Options with icons#
Icons are snippets, so they can be any inline SVG. Draw them at 16 px with currentColor:
{#snippet truck()}
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="…" stroke="currentColor" /></svg>
{/snippet}
<CustomSelect label="Delivery" options={[{ value: 'standard', label: 'Standard', icon: truck }]} />When any option has an icon, every row keeps the icon column, so labels share one edge.
Forms and validation#
Pass name and the value posts with the form. required adds native validation to that input
and aria-required to the trigger. The component does not show its own message: after a
failed submit, pass error with what to do ("Choose how you would like your order to arrive").
Groups#
Give consecutive options the same group and they sit under one small heading. Options
without a group render without one, so a list can start ungrouped.
Width#
The field fills its container; constrain it with class="max-w-sm" or a grid column. The
listbox is always exactly the trigger's width, so keep a compact select wide enough for its
longest label, and it flips above the trigger when there is no room below.
Descriptions#
The trigger shows the chosen option's description after its label and truncates it from the
end, so lead with the fact people decide on: £7.50 · Next working day, not
Next working day · £7.50.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label above the trigger, and the control's accessible name. |
options | CustomSelectOption[] | Yes | None | The options, in order, each with a unique non-empty value: { value, label, description?, icon?: Snippet, disabled?, group? }. Consecutive options with the same group sit under one heading. With none, the trigger is disabled. |
value | string | undefined | No | None | The chosen option's value. Bindable; undefined until something is chosen. |
name | string | No | None | Name of the form input that posts the value. Omitted, nothing is posted. |
placeholder | string | No | 'Choose an option' | Trigger text while nothing is chosen. |
help | string | No | None | One line of guidance under the trigger, read with it through aria-describedby. |
required | boolean | No | false | The form will not submit until an option is chosen; sets aria-required on the trigger. |
error | string | No | None | Error message under the trigger. While set, the trigger is aria-invalid and its edge takes the error colour. |
errorPrefix | string | No | 'Error:' | Read before the error message by assistive technology. Translate it with the page. |
disabled | boolean | No | false | Disables the whole control. |
size | 'sm' | 'md' | No | 'md' | 'md' is 36 px for forms, 'sm' is 32 px for toolbars and dense rows. Both are 44 px on touch screens. |
open | boolean | No | false | Whether the listbox is open. Bindable. |
onValueChange | (value: string) => void | No | None | Called with the new value when someone chooses an option. |
class | ClassValue | No | None | Extra classes on the field, for its width (max-w-sm) or margins. The trigger fills the field. |
Customization#
On this pageChange the label, options and copy through props, and retone the trigger and the popup through nine --custom-select-* CSS variables. The hover edge, placeholder and row tint are mixed from them, so they follow any retone.
- Width: the field fills its container. Pass class='
max-w-sm' (or a grid column) in a form; the popup is always exactly the trigger's width, and its rows wrap to fit. - Accent:
--custom-select-accentcolours the check mark and the focus ring. Keep it at 3:1 or more against the popup and the page. - Text:
--custom-select-inkis the label, values and option labels;--custom-select-mutedthe details, icons, help and group headings. - Surfaces:
--custom-select-surfacefills the trigger,--custom-select-popup-surfacethe listbox,--custom-select-hairlinedraws the popup's edge. - Edges:
--custom-select-borderis the trigger's resting edge (default black at 44%, 3:1 on white); hover and open darken it toward the ink.--custom-select-errorcolours the error edge and message. - Worked retone for a
#09090bpage:--custom-select-accent:#fafafa;--custom-select-ink:#fafafa;--custom-select-muted:#a1a1aa;--custom-select-surface:#18181b;--custom-select-popup-surface:#18181b;--custom-select-hairline: rgb(255 255 255 / 0.12);--custom-select-border: rgb(255 255 255 / 0.36);--custom-select-error:#f87171. - Options: keep labels short and lead the description with the deciding fact (£7.50 · Next working day), because the trigger truncates the description from its end.
- Size: size='sm' in toolbars and table rows, 'md' in forms. Do not mix sizes in one form.
Public CSS variables
| Variable | Token |
|---|---|
--custom-select-accent | accent |
--custom-select-ink | ink |
--custom-select-muted | muted |
--custom-select-hairline | hairline |
--custom-select-surface | surface |
--custom-select-popup-surface | popupSurface |
--custom-select-border | border |
--custom-select-error | error |
Dependencies and services#
On this page| Package | Range | Resolved at build time | Purpose |
|---|---|---|---|
bits-ui | ^2.19.3 | 2.19.3 | Headless Select: listbox roles, aria-activedescendant navigation, typeahead, outside-press and Escape dismissal, the form input, portal and collision-aware positioning. |
Install with (shown for reference, run it yourself)
npm install bits-ui@^2.19.3 Accessibility#
On this page- Follows the WAI-ARIA APG select-only combobox: the trigger is role="combobox" with
aria-haspopup="listbox",aria-expanded,aria-controlswhile open andaria-activedescendant; DOM focus stays on the trigger. - The popup is role="listbox" labelled by the field label; options are role="option" with
aria-selected. An option's name is its label and its detail. - Closed: Enter, Space, ArrowDown and ArrowUp open the listbox; Home and End open it on the first or last option; typing opens it on the first match. Open: arrows, Home, End, PageUp and PageDown move; Enter or Space chooses and closes; Tab and Alt+ArrowUp choose and close; Escape closes without changing the value and focus stays on the trigger.
- Disabled options stay listed with
aria-disabledand are skipped by arrows and typeahead. Say why in the description. - The visible <label> is tied to the trigger; help and error are read with it through
aria-describedby; error setsaria-invalidand is prefixed 'Error:' for assistive technology (errorPrefix). - With required, the form input carries native required validation and the trigger
aria-required. Show your own error message after a failed submit; the component does not validate on its own. - Touch screens get 44 px triggers and rows.
Known limitations
- Typeahead matches the label only, not the description.
- iOS VoiceOver reads
aria-activedescendantlistboxes less reliably than native selects; for a long plain-text list on mobile, a native select may serve better.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.