Select
FormSelect, FormSelectIcon, Option and SelectPlaceholder, a native select with a placeholder, a token icon and prepend and append slots.
On this page
FormSelect is a native <select> on the same input surface as a text field, with a trailing icon in place of the browser's arrow. Option and SelectPlaceholder fill it. For a labelled select with a description and an error message, use SelectField.
import { FormGroup, FormLabel, FormSelect, Option, SelectPlaceholder } from '@systhemaui/next'
export default function Demo() {
return (
<FormGroup style={{ width: '100%', maxWidth: 480 }}>
<FormLabel htmlFor="select-country">Country</FormLabel>
<FormSelect name="select-country" autoComplete="country">
<SelectPlaceholder>Select a country</SelectPlaceholder>
<Option value="be">Belgium</Option>
<Option value="hu">Hungary</Option>
<Option value="nl">Netherlands</Option>
</FormSelect>
</FormGroup>
)
}ImportLink to this section
import { FormSelect, FormSelectIcon, Option, SelectPlaceholder } from '@systhemaui/next'In a React app without Next.js, import them from @systhemaui/react.
PartsLink to this section
| Part | Renders |
|---|---|
FormSelect | <div class="form-select-wrapper"> around <select class="form-select"> and the icon |
FormSelectIcon | <div class="form-select-icon">, the trailing icon |
Option | <option> with a required string value |
SelectPlaceholder | <option value="" hidden disabled>, the prompt shown before a choice |
className goes on the wrapper, selectClassName on the <select> and iconClassName on the icon. Every other prop, including value, onChange and required, goes to the <select>. id defaults to name.
ExamplesLink to this section
PlaceholderLink to this section
SelectPlaceholder is an option with value="" that is hidden from the list and cannot be picked. An uncontrolled FormSelect starts on the empty value, so the placeholder shows until the visitor chooses; while it is selected, the text takes the placeholder color. With required, leaving it on the placeholder fails validation.
Every <option> prop can be overridden. Turn the placeholder into a selectable "All" entry for a filter by switching hidden and disabled off:
import { FormGroup, FormLabel, FormSelect, Option, SelectPlaceholder } from '@systhemaui/next'
export default function Demo() {
return (
<FormGroup style={{ width: '100%', maxWidth: 480 }}>
<FormLabel htmlFor="select-category">Category</FormLabel>
<FormSelect name="select-category">
<SelectPlaceholder hidden={false} disabled={false}>
All categories
</SelectPlaceholder>
<Option value="news">News</Option>
<Option value="guides">Guides</Option>
<Option value="releases">Releases</Option>
</FormSelect>
</FormGroup>
)
}Custom iconLink to this section
The default icon is an empty FormSelectIcon that the CSS masks with blocks.form.selectIcon and paints in the input's icon color, so it follows focus and invalid states. Change it for the whole project in blocks, or per select: disableIcon drops the default, and a FormSelectIcon with children in append takes its place.
import { FormGroup, FormLabel, FormSelect, FormSelectIcon, Option } from '@systhemaui/next'
export default function Demo() {
return (
<FormGroup style={{ width: '100%', maxWidth: 480 }}>
<FormLabel htmlFor="select-sort">Sort by</FormLabel>
<FormSelect
name="select-sort"
defaultValue="newest"
disableIcon
append={
<FormSelectIcon>
<svg
viewBox="0 -960 960 960"
width="100%"
height="100%"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path d="M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z" />
</svg>
</FormSelectIcon>
}
>
<Option value="newest">Newest first</Option>
<Option value="oldest">Oldest first</Option>
<Option value="title">Title</Option>
</FormSelect>
</FormGroup>
)
}prepend renders before the <select> inside the wrapper, and append after the icon. Both are for absolutely positioned decorations; the wrapper is the positioning context.
StatesLink to this section
import { FormGroup, FormLabel, FormSelect, Option, SelectPlaceholder } from '@systhemaui/next'
export default function Demo() {
return (
<div style={{ display: 'grid', gap: 24, width: '100%', maxWidth: 480 }}>
<FormGroup>
<FormLabel htmlFor="select-size">Size</FormLabel>
<FormSelect name="select-size" required selectClassName="is-invalid" aria-invalid>
<SelectPlaceholder>Choose a size</SelectPlaceholder>
<Option value="s">Small</Option>
<Option value="m">Medium</Option>
</FormSelect>
</FormGroup>
<FormGroup>
<FormLabel htmlFor="select-plan">Plan</FormLabel>
<FormSelect name="select-plan" defaultValue="team" disabled>
<Option value="team">Team</Option>
</FormSelect>
</FormGroup>
</div>
)
}PropsLink to this section
FormSelectLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
name (required) | string | - | |
id | string | - | |
className | string | - | |
selectClassName | string | - | |
iconClassName | string | - | |
children (required) | ReactNode | - | |
prepend | ReactNode | - | |
append | ReactNode | - | |
disableIcon | boolean | false | |
…and all <select> attributes |
FormSelectIconLink to this section
Takes every <div> attribute. Leave it empty for the masked token icon, or pass an icon as children.
OptionLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
value (required) | string | - | |
…and all <option> attributes |
SelectPlaceholderLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
children (required) | ReactNode | - | |
…and all <option> attributes |
HTML and CSSLink to this section
<div class="form-group">
<label class="form-label" for="country">Country</label>
<div class="form-select-wrapper">
<select class="form-select" id="country" name="country">
<option value="" hidden disabled selected>Select a country</option>
<option value="be">Belgium</option>
<option value="hu">Hungary</option>
</select>
<div class="form-select-icon"></div>
</div>
</div>The icon must follow the <select> as a sibling, because the CSS positions it with .form-select ~ .form-select-icon. The placeholder color applies while an empty-value option is the selected one.
| Class | Styles |
|---|---|
form | & > :not([hidden], input[type="hidden"]) ~ :not([hidden], input[type="hidden"]) { margin-top: var(--form-row-gap-y); } |
form-row | @media (width < 640px) { & { display: flex; flex-direction: column; row-gap: var(--form-row-gap-y); } }@media (width >= 640px) { & { display: flex; flex-wrap: wrap; gap: var(--form-row-gap-x); } }@media (width >= 640px) { & > .form-group { flex: 1 1 0%; min-width: 0; } }@media (width >= 640px) { & > .form-group[style*="--width"] { flex: 0 0 auto; width: calc(var(--width) - var(--form-row-gap-x) * (1 - var(--width) / 100%)); } } |
form-group | @media (width >= 640px) { .form-row > .form-group { flex: 1 1 0%; min-width: 0; } }@media (width >= 640px) { .form-row > .form-group[style*="--width"] { flex: 0 0 auto; width: calc(var(--width) - var(--form-row-gap-x) * (1 - var(--width) / 100%)); } }& > :not([hidden]) ~ :not([hidden]) { margin-top: var(--form-group-gap-y); }&:has(:required) .form-label::after { content: ' *'; color: var(--color-form-label-required); }+ 1 more rule |
form-label | & { display: block; color: var(--color-form-label); }& { font-family: var(--font-form-label-font-family); font-size: var(--typography-form-label-font-size); font-weight: var(--font-form-label-font-weight); letter-spacing: var(--typography-form-label-letter-spacing); line-height: var(--typography-form-label-line-height); text-transform: none; text-decoration: none; font-style: var(--font-form-label-font-style); }+ 1 more rule |
form-description | & { display: block; color: var(--color-form-description); }& { font-family: var(--font-form-description-font-family); font-size: var(--typography-form-description-font-size); font-weight: var(--font-form-description-font-weight); letter-spacing: var(--typography-form-description-letter-spacing); line-height: var(--typography-form-description-line-height); text-transform: none; text-decoration: none; font-style: var(--font-form-description-font-style); } |
form-input | & { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 4 more rules |
form-select | & { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 12 more rules |
form-textarea | & { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 5 more rules |
search-field-input | & { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 9 more rules |
is-invalid | :is(.form-select:user-invalid ~ .form-select-icon, .form-select.is-invalid ~ .form-select-icon) { color: var(--color-form-input-block-invalid-icon); background-color: var(--color-form-input-block-invalid-icon); }:is(.form-group:has(:user-invalid) .form-invalid-message, .form-group:has(.is-invalid) .form-invalid-message) { display: block; } |
form-select-wrapper | position: relative; |
form-select-icon | :is(.form-select ~ .form-select-icon) { pointer-events: none; position: absolute; top: 50%; transform: translateY(-50%); object-fit: contain; object-position: center; overflow: visible; line-height: 100%; right: var(--form-input-block-padding-x); width: var(--form-input-block-icon-size); height: var(--form-input-block-icon-size); font-size: var(--form-input-block-icon-size); color: var(--color-form-input-block-default-icon); }+ 3 more rules |
search-field | position: relative; |
search-field-icon | :is(.search-field-input ~ .search-field-icon) { pointer-events: none; position: absolute; top: 50%; transform: translateY(-50%); object-fit: contain; object-position: center; overflow: visible; line-height: 100%; right: var(--form-input-block-padding-x); width: var(--form-input-block-icon-size); height: var(--form-input-block-icon-size); font-size: var(--form-input-block-icon-size); color: var(--color-form-input-block-default-icon); }+ 2 more rules |
form-inline-input-group | & > :not([hidden]) ~ :not([hidden]) { margin-top: var(--form-input-inline-space-y); } |
form-radio | & { position: relative; user-select: none; }& input[type="radio"], .form-radio input[type="checkbox"], .form-radio:before { content: ""; position: absolute; left: 0; top: calc(var(--typography-form-inline-line-height) / 2); transform: translateY(-50%); width: var(--form-input-inline-input-size); height: var(--form-input-inline-input-size); }& input[type="radio"], .form-radio input[type="checkbox"] { opacity: 0; cursor: pointer; }&:has(:checked):after { opacity: 1; }+ 13 more rules |
form-checkbox | & { position: relative; user-select: none; }& input[type="radio"], .form-checkbox input[type="checkbox"], .form-checkbox:before { content: ""; position: absolute; left: 0; top: calc(var(--typography-form-inline-line-height) / 2); transform: translateY(-50%); width: var(--form-input-inline-input-size); height: var(--form-input-inline-input-size); }& input[type="radio"], .form-checkbox input[type="checkbox"] { opacity: 0; cursor: pointer; }+ 14 more rules |
form-invalid-message | & { display: none; color: var(--color-form-invalid-message); }:is(.form-group:has(:user-invalid) .form-invalid-message, .form-group:has(.is-invalid) .form-invalid-message) { display: block; } |
form-file-input | & { cursor: pointer; padding: 0; height: calc(var(--form-input-block-padding-y) * 2 + var(--typography-form-block-line-height)); display: flex; align-items: center; }+ 2 more rules |
Next.jsLink to this section
@systhemaui/next re-exports the select parts from @systhemaui/react unchanged.
AccessibilityLink to this section
- Give every select a label (
FormLabel htmlFor), or useSelectField, which also wires its description and error. - The default icon is an empty
<div>with no content, so screen readers skip it. Mark a custom iconaria-hidden="true", as in the example above. - A hidden placeholder is not announced as an option. Repeat its hint in the label or a description if it carries information.