Components · Forms

Select

Pick one value from a known list. The trigger uses the same label-inside-container language as TextField; clicking it opens a listbox below. Reach for it when the user can’t — or shouldn’t — type their own answer.

Documentedby Derek Fidler
VAT

Choose the correct VAT rate for this product.

Default — filled value

Overview

Select is the canonical input for picking exactly one value from a small known list. It pairs with TextField visually — same label-inside-container, same border tokens, same #9EB7FF focus ring — but the suffix is a chevron, and clicking it opens a custom listbox below.

Custom dropdown, not native <select>

The native <select> element can't be styled to match our visual language — the menu chrome is owned by the OS. We render a custom popover so the list, the highlight states, and the keyboard handling all sit on the system. A hidden <input> keeps form submission native.

States

Four resting states cover the form. The hover, focus, and open states come for free on the real component — interact with the next section to see them.

VAT

Choose the correct VAT rate for this product.

Empty

Resting state. Label visible at the top, placeholder where the value would sit.

VAT

Choose the correct VAT rate for this product.

Filled

A value is selected. Label sits at the top; the chosen option's text fills the value slot.

VAT

Set during onboarding.

Disabled

Inert. Set elsewhere; locked here.

VAT

Choose a VAT rate before continuing.

Invalid

Container fills #FFEFED, border switches to #E94743, error message replaces help text.

Open it

Click the field to open the listbox. Use and to navigate options, Enter to confirm, and Esc to dismiss without changing the value.

VAT

Choose the correct VAT rate for this product.

Open me

Click the field to open the listbox. Use Arrow Up / Arrow Down to navigate, Enter to confirm, Escape to close. The hovered option is highlighted; the selected one carries a check glyph.

In forms

Select shares the visual language with TextField — the two stack cleanly in a column or pair across a row without seam.

Country

Where the prototype is operating.

Currency

Settlement currency for this prototype.

In a form

Select shares the visual language with TextField — the two stack cleanly in a column or pair across a row.

Anatomy

Five named parts. The trigger mirrors TextField almost exactly; the listbox is the only thing unique to Select.

VAT

Choose the correct VAT rate.

  1. Trigger container

    Same border / focus / error tones as TextField. Acts as a button — Enter, Space, or arrow keys open the listbox.

  2. Label

    Always-on floating label at 60% foreground.

  3. Display value

    The matching option's label text. Falls back to the placeholder when no value is selected.

  4. Chevron

    Suffix slot. Rotates 180° when the listbox is open. Inherits text colour from the trigger.

  5. Listbox

    Floating popover anchored to the trigger's left and right edges. White background, 1 px #EEEEEF border, 8 px radius, soft shadow. Selected option carries a check glyph.

Behavior

  • Listbox opens to the current value. When you open a Select with a value already chosen, the active highlight lands on that option — not at the top of the list. Saves you one keystroke on revisit.
  • Keyboard moves the highlight; click commits. Hovering with the mouse moves the highlight too. Enter or Space on a highlighted option commits the selection and refocuses the trigger.
  • Escape closes without changing the value. The user's previous choice stays put. Click outside also closes; same outcome.
  • Form submission is native. Pass a name and the component renders a hidden <input> — the value posts with the form, no extra wiring.
  • No type-to-search yet. For lists longer than 8–10 items, reach for Combobox — Select is for short, known lists where keyboard browsing is enough.

Accessibility

  • Combobox + listbox semantics. The trigger is a role="combobox" with aria-haspopup="listbox" and aria-expanded; the popover is a role="listbox" of role="option" children. Selected state is announced via aria-selected.
  • Description and error are linked to the trigger via aria-describedby. Error sets aria-invalid="true".
  • Keyboard contract. Trigger: Enter / Space / opens. Listbox: / move, Home / End jump to bounds, Enter commits, Esc closes.
  • Focus returns on commit. When the user picks an option (mouse or keyboard), focus moves back to the trigger so keyboard tab order continues correctly.
  • Don't hide the label.Same rule as TextField — the always-floating label is the design. Don't set it to sr-only or rely on the placeholder for context.

Code

Import from @flatpay-dk/ui. Children are <SelectOption> elements; pass value as the form value and the children as the visible label.

tsx

import { Select, SelectOption } from "@flatpay-dk/ui";

// Default — uncontrolled, with a placeholder
<Select
  label="VAT"
  placeholder="Select…"
  description="Choose the correct VAT rate for this product."
  name="vat_rate"
>
  <SelectOption value="0">0% — exempt</SelectOption>
  <SelectOption value="9">9% — reduced</SelectOption>
  <SelectOption value="21">21% — standard</SelectOption>
</Select>

// Controlled
const [country, setCountry] = useState<string | undefined>("dk");

<Select label="Country" value={country} onValueChange={setCountry}>
  <SelectOption value="dk">Denmark</SelectOption>
  <SelectOption value="se">Sweden</SelectOption>
  <SelectOption value="no">Norway</SelectOption>
</Select>

// Mapped from data
<Select label="Currency" defaultValue="DKK" name="currency">
  {currencies.map((c) => (
    <SelectOption key={c.code} value={c.code}>
      {c.label}
    </SelectOption>
  ))}
</Select>

// Invalid + error message
<Select
  label="VAT"
  placeholder="Select…"
  error="Choose a VAT rate before continuing."
>
  …
</Select>

Best practices

A handful of rules keep Select predictable.

Country

Do

Use Select for known lists of 3–10 items where the user can browse comfortably.

Notifications

Don't

Don't use Select for binary choices. Reach for Switch or Radio.

Currency

Do

Order options the way the user thinks about them — most-likely first, alphabetical when there's no hierarchy.

Country

Don't

Don't cram 30 items in here. Long lists belong in Combobox with type-to-search.

Props

PropTypeDefaultDescription
labelReactNodeFloating label inside the trigger. Sentence case. Always visible.
descriptionReactNodeHelp text below the trigger. Hidden when error is set.
errorReactNodeError message. Flips the surface to error tones, sets aria-invalid, replaces help.
valuestringControlled value. Pair with onValueChange.
defaultValuestringInitial value for uncontrolled use.
onValueChange(next: string) => voidCalled with the new value when the user picks an option.
placeholderstring"Select…"Shown in the value slot when no option is selected.
namestringNative form name. Renders a hidden input so the value posts with the form.
requiredbooleanfalseNative form validation flag, applied to the hidden input.
disabledbooleanfalseInert state. The trigger ignores clicks; the listbox can&apos;t open.
displayValuestringOverride the visible label when collapsed. Defaults to the matching option&apos;s child text.
childrenReactNodeSelectOption children — one per choice.