defuss-shadcn / Forms & Inputs / select
SelectATM
A native dropdown for choosing from a list of options. Built on <select> with appearance: none and a custom chevron. The empty-value placeholder option stays selectable, so it doubles as the clear/reset entry: re-choosing it empties the box again (add required for mandatory fields).
On this page (8)
§Default
Native select with a clearable placeholder - re-choose 'Select a fruit' to empty it again.
§With groups
§Multiple
The native multi-select: add multiple (and size='N' for the visible rows) and the browser shows a list box - Ctrl/⌘-click adds or removes an option, Shift-click or Shift+arrows pick a run, and the form submits every chosen value under the same name. Best for a short, fixed list on desktop; for tags, people or a long searchable list use the Combobox multi-select, for a handful of options checkboxes.
Several choices? Pick the control by the list
- A handful of options, all worth seeing (2-7): a group of checkboxes - every choice visible, one click each.
- A short fixed list, mostly desktop:
<select multiple>(above) - zero JavaScript, native keyboard and form behaviour; the Ctrl/⌘-click gesture needs a hint. - Tags, people, countries - long or searchable lists: the Combobox multi-select - type to filter, checkboxes in the list, removable tags, one hidden input per value.
disabled on an <option> greys out ONE choice in an otherwise usable list - a size that's out of stock, a plan you're not eligible for. It can't be chosen by click or keyboard, and the browser skips it. Say why in the option text ('out of stock'), so the state isn't carried by grey alone. <optgroup disabled> switches off a whole group. (The empty 'Select …' placeholder is different: it stays enabled so it can clear the choice.)
§Disabled
disabled on the <select> itself switches off the whole control - nothing can be chosen and it leaves the tab order. For one unavailable choice in a usable list, disable the <option> instead (above).
§Sizes
The full five-step scale via data-size - the unlabeled default IS md (2.25rem).
§States
Machine contract - verified against select.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | string | — | "apple" | Selected option (select.value), changed by panel or by picking. |
disabled | boolean | true, false | false | Interaction off (native disabled attribute). |
required | boolean | true, false | false | Post-validation marks the field invalid (:user-invalid). |
§CSS view file
/* -- Select component ------------------------------------------- */@layer components { .select { appearance: none; /* the field standard: unsized = md step (see .input) */ height: 2.25rem; width: 100%; border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); padding: 0 2rem 0 0.75rem; font-size: 0.875rem; font-family: var(--font-sans); color: var(--foreground); outline: none; box-shadow: var(--shadow-xs); cursor: pointer; transition: border-color 150ms, box-shadow 150ms; /* Custom chevron */ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.5rem center; background-size: 1rem; /* Placeholder = the empty-value option. It stays selectable (no `disabled`) so it doubles as the clear/reset entry - choosing it again empties the box (issue #32). While it is the selection the closed control reads as a placeholder; a real value restores --foreground because the base rule's color wins over this one. option:checked matches the *selected* option in every engine. */ &:has(> option[value=""]:checked) { color: var(--muted-foreground); } &:focus { border-color: var(--ring); box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2); } /* Disabled = legible but inert: a muted surface and muted text inside the full-strength border - never an opacity fade that lets the box dissolve into the page. */ &:disabled { background-color: var(--muted); color: var(--muted-foreground); box-shadow: none; cursor: not-allowed; opacity: 1; /* Chrome's UA sheet fades select:disabled to 0.7 on its own */ } &:is([aria-invalid="true"], :user-invalid:not(form[data-validate="submit"]:not([data-submitted]) *)) { border-color: var(--destructive); &:focus { border-color: var(--destructive); box-shadow: 0 0 0 2px oklch(from var(--destructive) l c h / 0.2); } } /* -- Multiple (<select multiple>) --------------------------- The browser renders a list box instead of a dropdown: every option is visible (size="N" rows), Ctrl/⌘-click or Shift-click / Shift+arrows pick several, and the form submits every chosen value under the same name. No fixed height, no chevron; chosen options get a tinted row. */ &[multiple] { height: auto; padding: 0.25rem; background-image: none; overflow-y: auto; & option { padding: 0.375rem 0.5rem; border-radius: calc(var(--radius-md) - 2px); color: var(--foreground); } & option:checked { /* a gradient beats the UA's opaque selection paint in every engine */ background: linear-gradient(color-mix(in oklch, var(--primary) 14%, transparent), color-mix(in oklch, var(--primary) 14%, transparent)); font-weight: 500; } /* one unavailable choice in a usable list */ & option:disabled { color: var(--muted-foreground); cursor: not-allowed; } & optgroup { font-size: 0.75rem; font-weight: 600; color: var(--muted-foreground); } } /* -- Sizes ----------------------------------------------- */ &[data-size="xs"] { height: 1.75rem; padding-left: 0.5rem; font-size: 0.75rem; } &[data-size="sm"] { height: 2rem; padding-left: 0.625rem; font-size: 0.8125rem; } &[data-size="md"] { height: 2.25rem; padding-left: 0.75rem; font-size: 0.875rem; } &[data-size="lg"] { height: 2.75rem; padding-left: 1rem; font-size: 1rem; } &[data-size="xl"] { height: 3.25rem; padding-left: 1.25rem; font-size: 1.125rem; } &[multiple][data-size] { height: auto; } }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend (and discrete display flips) firing so JS state machines that await them keep working. */@media (prefers-reduced-motion: reduce) { @layer components { .select, .select *, .select::before, .select::after, .select *::before, .select *::after, .select::backdrop { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}Comments, ideas or improvements? Edit this page's source on GitHub