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.
Choose the correct VAT rate for this product.
Empty
Resting state. Label visible at the top, placeholder where the value would sit.
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.
Set during onboarding.
Disabled
Inert. Set elsewhere; locked here.
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.
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.
Where the prototype is operating.
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.
Choose the correct VAT rate.
Trigger container
Same border / focus / error tones as TextField. Acts as a button — Enter, Space, or arrow keys open the listbox.
Label
Always-on floating label at 60% foreground.
Display value
The matching option's label text. Falls back to the placeholder when no value is selected.
Chevron
Suffix slot. Rotates 180° when the listbox is open. Inherits text colour from the trigger.
Listbox
Floating popover anchored to the trigger's left and right edges. White background, 1 px
#EEEEEFborder, 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
nameand 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"witharia-haspopup="listbox"andaria-expanded; the popover is arole="listbox"ofrole="option"children. Selected state is announced viaaria-selected. - Description and error are linked to the trigger via
aria-describedby. Error setsaria-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-onlyor 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.
Do
Use Select for known lists of 3–10 items where the user can browse comfortably.
Don't
Don't use Select for binary choices. Reach for Switch or Radio.
Do
Order options the way the user thinks about them — most-likely first, alphabetical when there's no hierarchy.
Don't
Don't cram 30 items in here. Long lists belong in Combobox with type-to-search.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | — | Floating label inside the trigger. Sentence case. Always visible. |
| description | ReactNode | — | Help text below the trigger. Hidden when error is set. |
| error | ReactNode | — | Error message. Flips the surface to error tones, sets aria-invalid, replaces help. |
| value | string | — | Controlled value. Pair with onValueChange. |
| defaultValue | string | — | Initial value for uncontrolled use. |
| onValueChange | (next: string) => void | — | Called with the new value when the user picks an option. |
| placeholder | string | "Select…" | Shown in the value slot when no option is selected. |
| name | string | — | Native form name. Renders a hidden input so the value posts with the form. |
| required | boolean | false | Native form validation flag, applied to the hidden input. |
| disabled | boolean | false | Inert state. The trigger ignores clicks; the listbox can't open. |
| displayValue | string | — | Override the visible label when collapsed. Defaults to the matching option's child text. |
| children | ReactNode | — | SelectOption children — one per choice. |