Open this component in Storybook
The <dsgn-select> component picks one value from a list. It behaves like a
native <select> inside a <form> — its value reaches FormData, required
blocks submission, and form reset and <fieldset disabled> work — while
rendering a styled listbox that follows the WAI-ARIA
select-only combobox
pattern.
Multi-select, search inside the list, option groups, and async options are not supported yet.
Usage
<dsgn-select
label="Framework adapter"
name="adapter"
placeholder="Choose an adapter"
></dsgn-select>
import '@ds-gn/ui/select';
document.querySelector('dsgn-select').options = [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'angular', label: 'Angular', disabled: true, tag: 'soon' },
];
In plain HTML, options can also be set as a JSON attribute:
<dsgn-select
label="Framework adapter"
options='[{"value":"react","label":"React"},{"value":"vue","label":"Vue"}]'
></dsgn-select>
Playground
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | SelectOption[] | [] | The choices, in order. A property, or a JSON attribute in plain HTML. See Options. |
| value | string | '' | The selected option’s value, or '' for none. Not reflected; the value at first render is the reset value. |
| name | string | '' | Field name submitted with the form. Reflected, so a value set only as a property still lands in FormData. |
| placeholder | string | '' | Shown in the trigger while no option is selected. |
| label | string | '' | Label text rendered above the trigger. Omitted when empty. |
| hint | string | '' | Supporting text below the trigger. Stays visible alongside an error. Omitted when empty. |
| error | string | '' | Error message shown below the hint, with an icon. A non-empty value also marks the select invalid. |
| invalid | boolean | false | Marks the select invalid without a message. Reflected. Set by dsgn-field from its error. |
| aria-label | string | '' | Accessible name for the trigger and the listbox, for selects with no visible label. |
| size | 'sm' | 'md' | 'lg' | 'md' | Control size, shared with dsgn-input: 32, 40, and 48px tall. |
| disabled | boolean | false | Disables the select. |
| required | boolean | false | Reports valueMissing while no option is chosen, and adds a * to the label. |
| surface | 'light' | 'dark' | 'light' | Semantic token surface to use. |
aria-label is the attribute name; the matching JavaScript property is
ariaLabel. It is ignored when label is set.
Inside a <dsgn-field>, Field supplies label, hint,
error, required, invalid, and size, and overwrites values set on the
select directly.
Options
interface SelectOption {
value: string; // submitted with the form and reported in dsgn-change
label: string; // visible text, also used for typeahead
disabled?: boolean; // shown, but not selectable; skipped by the keyboard
tag?: string; // short trailing note, such as why it is unavailable
}
The type is exported from @ds-gn/ui/select/props.
Slots
The component renders its own label, trigger, listbox, hint, and error message, and takes no slotted content.
CSS parts
| Part | Description |
|---|---|
label | The visible <label>. |
required-indicator | The * appended to the label when required. Hidden from assistive technology. |
trigger | The combobox button. |
listbox | The floating option list. |
option | Each option in the list. |
hint | Hint text below the trigger. |
error | The error message, icon and text, below the hint. |
Events
| Event | Detail | Description |
|---|---|---|
dsgn-change | { value, name } | Fired when the user picks a different option. Not fired when the same option is picked again or when value is set from code. Bubbles and crosses the shadow boundary. |
In React, the generated wrapper exposes this as onDsgnChange.
Methods
| Method | Description |
|---|---|
formResetCallback() | Called by the platform when the owning form resets. Restores the first-rendered value and clears error. |
formDisabledCallback(disabled) | Called by the platform when a <fieldset disabled> or form owner toggles the inherited disabled state. |
formStateRestoreCallback(state) | Called by the platform to restore the value after navigation or autofill. |
These are form-association lifecycle callbacks. The browser calls them — you do not.
Keyboard
Focus stays on the trigger the whole time; the highlighted option is tracked
with aria-activedescendant.
| Key | Closed | Open |
|---|---|---|
↓ / ↑ | Open on the selected option, or the first enabled one. | Move the highlight; stops at the ends and skips disabled options. |
Enter / Space | Open, as above. | Pick the highlighted option and close. |
Alt + ↑ | — | Pick the highlighted option and close. |
Home / End | Open on the first / last enabled option. | Highlight the first / last enabled option. |
| A character | Open and jump to the first match. | Jump to the next match. Characters typed within 500ms combine. |
Escape | — | Close without changing the value. |
Tab | Move focus on. | Pick the highlighted option, then move focus on. |
The pointer works the same way: hovering an option highlights it, clicking picks it, and clicking outside or moving focus away closes the list.
Forms
<form>
<dsgn-select name="adapter" label="Framework adapter" required></dsgn-select>
<dsgn-button type="submit">Generate</dsgn-button>
</form>
form.addEventListener('submit', (event) => {
event.preventDefault();
const adapter = new FormData(form).get('adapter');
});
- A
requiredselect with no value reportsvalueMissing, so it blocks submission and the browser’s message points at the trigger. - Inherited disabled state (for example from a
<fieldset disabled>) is tracked separately from thedisabledproperty, so re-enabling the fieldset restores the select. - A form reset returns to the value the select was first rendered with and
clears any
errormessage you set.
Positioning
The listbox opens in the browser’s top layer (a popover="manual" element),
so it is never clipped by an ancestor’s overflow and never covered by a
stacking context — including inside a <dsgn-modal>,
where Escape closes the listbox first and the dialog on the next press.
It opens below the trigger, at least as wide as it. When there is not enough room below, it flips above; when it would spill past the right edge of the viewport, it shifts left to keep a 16px gutter. It follows the trigger on scroll and resize while open.
In a browser without the Popover API, the listbox falls back to absolute
positioning inside the component, where an ancestor with overflow: hidden
can clip it.
Examples
Sizes
<dsgn-select label="Small" size="sm"></dsgn-select>
<dsgn-select label="Medium" size="md"></dsgn-select>
<dsgn-select label="Large" size="lg"></dsgn-select>
Hint and error message
<dsgn-select
label="Framework adapter"
hint="Used for generated wrappers."
error="Choose an adapter."
required
></dsgn-select>
Listening for changes
document
.querySelector('dsgn-select')
.addEventListener('dsgn-change', (event) => {
const { value, name } = event.detail;
});
Pill shape
The trigger’s corner radius comes from the --radius-control token (default
0.375rem), which dsgn-input follows too. Override it on :root to round
every form control:
:root {
--radius-control: 9999px;
}
Dark surface
<dsgn-select label="Framework adapter" surface="dark"></dsgn-select>
Accessibility
- The trigger is a
<button role="combobox">witharia-haspopup="listbox",aria-expanded, andaria-controls; the list isrole="listbox"and each option isrole="option"witharia-selectedand, when unavailable,aria-disabled. - Prefer a visible
label. When the design has no room for one, setaria-label— a placeholder is not a label. - The error and hint are linked through
aria-describedby, error first, andrequiredsetsaria-required. The*is hidden from assistive technology. - The selected option carries a check mark and medium weight, and the highlighted option a fill, so neither relies on color alone.
- Opening and closing animate with a short scale and fade, and not at all under
prefers-reduced-motion: reduce.