Docs

This page isn't translated yet

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.

Select with a placeholder
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

PartRenders
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:

Selectable reset option
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.

Select with its own icon
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

Invalid and disabled
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

PropTypeDefaultDescription
name (required)string-
idstring-
classNamestring-
selectClassNamestring-
iconClassNamestring-
children (required)ReactNode-
prependReactNode-
appendReactNode-
disableIconbooleanfalse
…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

PropTypeDefaultDescription
value (required)string-
…and all <option> attributes

SelectPlaceholderLink to this section

PropTypeDefaultDescription
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.

ClassStyles
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-wrapperposition: 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-fieldposition: 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 use SelectField, 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 icon aria-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.