Form elements are styled globally using semantic tokens. All text inputs, textareas, selects, checkboxes, and radios share consistent sizing, focus states, and disabled styling.
Labels
Labels are styled as block elements with medium weight:
<label for="name">Full name</label>
<input type="text" id="name" placeholder="Enter your name">
Properties: display: block, font-size: var(--font-s), font-weight: var(--font-weight-medium), bottom margin for spacing from the input.
Text Inputs
All standard text input types are styled globally:
<input type="text" placeholder="Text">
<input type="email" placeholder="Email">
<input type="password" placeholder="Password">
<input type="number" placeholder="Number">
<input type="search" placeholder="Search">
<input type="url" placeholder="URL">
<input type="tel" placeholder="Phone">
Properties: full width, font-size: var(--font-m) (matches body text), padding: var(--space-m) var(--space-l), border from , smooth focus transition.
Focus State
All inputs share a consistent focus style:
- Border color changes to
- A subtle box-shadow ring appears (2px, 75% transparent)
- No outline (replaced by box-shadow for consistency)
This is accessibility-safe and keyboard-visible.
Textarea
Textareas have a minimum height and allow vertical resizing:
<label for="message">Message</label>
<textarea id="message" placeholder="Enter your message..."></textarea>
Properties: min-height: 120px, resize: vertical.
Select
Selects use a custom dropdown arrow via an inline SVG background:
<label for="country">Country</label>
<select id="country" required>
<option value="" selected>Choose a country</option>
<option value="nz">New Zealand</option>
<option value="au">Australia</option>
</select>
Properties: appearance: none, custom caret, right padding for arrow space.
Clearing a selection
Write the placeholder option as value="" selected and put required on the select — not disabled on the option.
A disabled placeholder is a one-way door: once someone picks a country they can never get back to "Choose a country", because the only row holding the empty value is unpickable. Leaving it enabled makes the placeholder a real choice again, and required still fails validation on an empty value, so nothing is submitted by accident. That is the whole mechanism — no clear button, no JavaScript.
Use disabled on the placeholder only when an empty value is genuinely not a state the field can return to.
The option list
The option list is styled too, using CSS customizable select. Setting appearance: base-select on both the select and its ::picker(select) takes the popup out of the operating system and into a CSS box, so the list matches the input: dropdown surface tokens, row metrics, and a leading checkmark on the selected row via ::checkmark.
The mark sits on the left, where sits, so an option row and a checkable dropdown row line up on the same two columns rather than being mirror images of each other. Its column is reserved on every row, selected or not, so choosing an option cannot shunt the labels sideways.
No JavaScript and no wrapper markup — the plain <select> above is all that is needed. Write options as normal.
Support is Chromium-only at time of writing and is not Baseline. The whole thing sits inside @supports (appearance: base-select), so Safari and Firefox keep the styled input and their own native list until they ship it. That fallback is the behaviour the system shipped before, so nothing regresses.
<optgroup> is handled: the group label reads as a quiet heading over its rows.
Four things to keep in mind when working on it:
- Both selectors must opt in. Setting
appearance: base-selecton theselectalone restyles the input and leaves the popup native. - The picker is a popover, so it is
display: nonewhen closed. Its entry transition needstransition-behavior: allow-discreteondisplayandoverlayplus a@starting-styleorigin, or it will pop rather than fade. - Drop-downs only. The rules are scoped
select:not([multiple]):not([size]). A listbox has no picker and no room for these row metrics, so it keeps the native rendering. Asize="1"select is a drop-down but cannot be told apart by selector, so it falls back too. - The author-button pattern is out of scope. Supplying your own
<button><selectedcontent></selectedcontent></button>as the select's first child is valid HTML but is not styled here — the layout rules assume the UA-generated button.
The checkmark is the signal for selection; colour alone would fail WCAG 1.4.1. Options carry a real focus ring rather than the row tint alone, because <option> is not one of the elements the global focus ring covers and a 5% tint sits well under the 3:1 non-text floor (1.4.11). In forced-colors mode the checkmark and caret drop their masked artwork and fall back to text glyphs — a mask is painted with background-color, which forced-colors flattens, so the mark would otherwise disappear exactly where it matters most.
Icons in options
An option in a customizable select is no longer text-only. It can hold images, SVG and other non-interactive text-level elements, so a brand icon per row is a normal thing to write — this is not a workaround and costs no JavaScript.
Each row carries its own icon, independent of the checkmark. Markup order decides which side it lands on: an icon written before the label leads, one written after it trails. There is no class for either — the row is a flex line, and now that the checkmark leads, the trailing edge is free.
The placement rows below put the icon last, so the label column stays flush and the previews line up down the right-hand edge:
<label for="placement">Placement</label>
<select id="placement" required>
<option value="" selected>Choose a placement</option>
<option value="left">
<span>Left</span>
<span class="svg-icn" data-icon="ad-left" aria-hidden="true"><!-- inline svg --></span>
</option>
<option value="right">
<span>Right</span>
<span class="svg-icn" data-icon="ad-right" aria-hidden="true"><!-- inline svg --></span>
</option>
<option value="top">
<span>Top</span>
<span class="svg-icn" data-icon="ad-top" aria-hidden="true"><!-- inline svg --></span>
</option>
</select>
In Chromium this renders as the tick, a label, and the icon out at the right edge. In Safari and Firefox the same markup collapses to a plain native list reading "Left / Right / Top" — still correct, just unstyled.
Put the icon first instead and it leads the label, immediately after the checkmark column — the icon-left pattern the dropdown uses for action verbs:
<option value="download">
<span class="svg-icn" data-icon="download" aria-hidden="true"><!-- inline svg --></span>
<span>Download</span>
</option>
Which side to pick is the same judgement as in a dropdown: lead with the icon when it reinforces what the row does, trail it when it is a preview or a piece of metadata about the row. Placement previews are the second kind.
Three rules for it:
- Use
<span class="svg-icn">, not the usual<div>. Only text-level elements are allowed inside an option. setsdisplay: flexitself, so the class behaves identically on a span — the icon contract is unchanged. - Mark the icon
aria-hidden="true"and keep the label as real text. The option's submitted value is itsvalueattribute; the accessible name comes from the text node. - Wrap the label in its own
<span>, and make the icon the last element in the option. The trailing rule keys on the icon being the last of two or more element children, and a bare text node is not an element — leave the label unwrapped and the icon counts as the option's only child, so it packs against the text instead of trailing. Anything written after the icon takes the trailing edge away from it for the same reason.
Either order degrades to the same thing. A browser without customizable select strips the option to its text nodes, and an icon contributes none, so both spellings collapse to the plain label.
The button shows the selected option's content through <selectedcontent>, which clones child nodes — so the icon appears in the closed input too, automatically. The clones sit inside <selectedcontent> rather than inside an <option>, so none of the row rules reach them; the sheet re-states the icon size against selectedcontent so the icon does not change size the moment the picker closes.
Icons are not limited to the selected row, and the checkmark is independent of them: ::checkmark is a pseudo-element, and pseudo-elements are not cloned into <selectedcontent>, so the tick never follows the selection into the closed input and never competes with an icon for the same edge.
Colour Input
The native colour picker is styled to match other form inputs. Browser chrome is removed so the colour swatch fills the entire element. Use alongside a text input for hex/named colour entry.
<div class="block row gap-s align-center">
<input type="color" id="color-picker" value="#ffa500">
<input type="text" value="ffa500" placeholder="hex or name">
</div>
Properties: appearance: none, aspect-ratio: 1 / 1, align-self: stretch (matches sibling height), padding: var(--space-xs), swatch wrapper padding removed. Same border and focus styles as text inputs.
Disabled State
Add the disabled attribute to any input, textarea, or select:
<input type="text" value="Cannot edit" disabled>
Properties: background, color, cursor: not-allowed.
Checkbox & Radio
Checkboxes and radios use appearance: none with custom styling. Wrap each in for inline label alignment.
Checkbox
<div class="form-check">
<input type="checkbox" id="terms">
<label for="terms">I agree to the terms</label>
</div>
Properties: 24px size (), fill, border with corners. Checked state uses fill with a white checkmark SVG.
Radio
<div class="form-check">
<input type="radio" name="group" id="option-a">
<label for="option-a">Option A</label>
</div>
Properties: same as checkbox but with border-radius: 50% and a centered dot on checked.
Toggle / Switch
A toggle is a checkbox styled as a sliding switch. Use instead of . Always include role="switch" for accessibility.
Default (label left)
<div class="form-toggle">
<input type="checkbox" id="notifications" role="switch">
<label for="notifications">Enable notifications</label>
</div>
Label right
Add to place the label after the toggle.
<div class="form-toggle is-label-right">
<input type="checkbox" id="darkmode" role="switch">
<label for="darkmode">Dark mode</label>
</div>
Disabled
<div class="form-toggle">
<input type="checkbox" id="feature" role="switch" disabled>
<label for="feature">Coming soon</label>
</div>
Component tokens
Re-point these to re-skin the toggle; never override the rules themselves.
| Token | Default | Controls |
|---|---|---|
44px |
Track width | |
| Track height | ||
18px |
Knob diameter | |
3px |
Inset between knob and track edge | |
| Knob slide and track colour fade |
The knob travel distance is derived from the first four, so changing the track
width or knob size keeps the checked position correct without further edits.
Reduced motion
Under prefers-reduced-motion: reduce, --toggle-transition-duration re-points
to . The knob still moves to its new position, it just
arrives immediately — the same approach , and
take. The transition is shortened rather than removed so the state change stays
legible as a change, and so nothing can get stuck mid-transition.
Layout Patterns
Form Group
Use to wrap a label + input pair with consistent bottom spacing:
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email">
</div>
Form Check
Use for inline checkbox/radio + label pairs:
<div class="form-check">
<input type="checkbox" id="opt-in">
<label for="opt-in">Subscribe to newsletter</label>
</div>
Properties: display: flex, align-items: center, gap for spacing. The label inside is inline with regular weight.
Fieldset & Legend
Use <fieldset> and <legend> to group related form controls:
<fieldset>
<legend>Contact details</legend>
<div class="form-group">
<label for="phone">Phone</label>
<input type="tel" id="phone">
</div>
</fieldset>
Segmented Control
is a button group that acts like a single-select input — a set of options where seeing all of them at once is the point.
Nothing is drawn until something is selected. There is no track, no outer border and no rules between segments; the selected segment is the only filled one, and its fill is the system's selected pair — the inverted neutral.
<div class="segmented-control" role="group" aria-label="Options">
<button class="segmented-control-btn is-active" type="button" aria-pressed="true">Option A</button>
<button class="segmented-control-btn" type="button" aria-pressed="false">Option B</button>
<button class="segmented-control-btn" type="button" aria-pressed="false">Option C</button>
</div>
Selection is deliberately not an accent colour. A status hue in the middle of a control spends a colour the system reserves for meaning. Instead selection speaks the system's selected grammar — the inverted neutral the toggle's checked track already uses — through the shared --segmented-selected-background / --segmented-selected-text pair, which clears the 3:1 non-text contrast a state indicator needs in both themes.
Hover and selected are distinct: hover previews engagement with the faded wash, selection inverts. (They used to share the same faded fill; it measured 1.13:1 against the page — well under the 3:1 floor — so the wash now belongs to hover alone.) aria-pressed still carries the state for assistive technology, which is why it must be kept in step with in script — never set one without the other.
Icon variant
squares the segment for icon-only options. Every one needs an aria-label, and the group needs a name of its own.
<div class="segmented-control" role="group" aria-label="Device">
<button class="segmented-control-btn is-icon is-active" type="button" aria-pressed="true" aria-label="Desktop">
<div class="svg-icn" data-icon="desktop"><!-- SVG --></div>
</button>
<button class="segmented-control-btn is-icon" type="button" aria-pressed="false" aria-label="Tablet">…</button>
</div>
Reach for a segmented control when the options are icons, when there are two of them, or when seeing every choice at once matters. Reach for a dropdown otherwise — three labelled segments cost roughly four times the width of one trigger whose label is the current value.
Thumb variant
data-variant="thumb" draws what the flat form deliberately omits: a track, and a thumb that slides to the selected segment. The flat form's argument — hover previews the selected state — assumes a pointer that can hover. On a touch surface there is no hover, and on a dense app screen an unfilled group reads as unrelated buttons, so the app form trades the ghost group for a visible boundary. The flat form stays the default, and the right choice inside bars and toolbars.
The markup is different on purpose: native radios inside labels, not buttons. The browser supplies the radiogroup semantics — arrow keys move the selection, Tab enters and leaves the group as one stop, and the value posts with a form when there is one. No script, no aria-pressed to keep in step. Radios group by their shared name, so the variant works anywhere in the page — a <form> is optional.
<div class="segmented-control" data-variant="thumb" role="radiogroup" aria-label="Verdict">
<label class="segmented-control-option">
<input type="radio" name="verdict" value="liked" checked>
<span>Liked</span>
</label>
<label class="segmented-control-option">
<input type="radio" name="verdict" value="loved">
<span>Loved</span>
</label>
<label class="segmented-control-option">
<input type="radio" name="verdict" value="meh">
<span>Meh</span>
</label>
</div>
Two constraints, both deliberate:
- Segments are equal width. The thumb is sized by segment count, not measured by script, so the columns share the track evenly. Labels of wildly different lengths want the flat form instead.
- Two to five segments. The position selectors enumerate up to five; a sixth option was a dropdown's job anyway.
Selection is still not an accent colour: the thumb takes --segmented-selected-background (the inverted neutral) and the checked label takes --segmented-selected-text — the same shared pair as the flat form's , so both forms re-theme from one place. The thumb clears the 3:1 non-text floor by an order of magnitude in both themes; --segmented-thumb-border stays available as a purely aesthetic knob, transparent by default, because the contrast pass lives in the surface where an optional border can never undo it. With nothing checked, no thumb is drawn. Under reduced motion the thumb snaps between segments instead of sliding, and in forced-colors mode the track keeps a drawn border while the checked label takes the system's Highlight pair.
Disable an option by disabling its radio — the label takes the standard disabled pair and stops responding, no extra class. Options render about 42px tall: above the 24px AA target floor, and --target-min is deliberately not applied — a segment is part of a grouped control, not a standalone target, the same reasoning as the toggle's visual width.
Read the value the same way as any radio group — input:checked in script, or the posted form value.
Styling
- Segments: transparent, , corners, no border
- Hover: fill with — a preview, not the state
- Selected: fill with — the inverted pair shared with the thumb variant
- Focus: 2px outline with offset
- Forced colours: selection falls back to
Highlight/HighlightText, and the focus outline declaresCanvasText
Accessibility
- Add
role="group"andaria-labelto the container - Use
aria-pressed="true"on the active segment button - Toggle
is-activeandaria-pressedvia JavaScript on click - The thumb variant needs none of the above script: use
role="radiogroup"with anaria-labelon the container, and the native radios carry the checked state and the arrow-key behaviour themselves
Slider
input[type="range"] is styled with design system tokens. Use for a labelled slider with live value display. See the Slider docs for full details.
<div class="slider-wrapper">
<div class="slider-header">
<label for="opacity">Opacity</label>
<span class="slider-value" id="opacity-val">75%</span>
</div>
<input type="range" id="opacity" min="0" max="100" value="75"
oninput="document.getElementById('opacity-val').textContent = this.value + '%'">
</div>
Number Input
wraps a native number input with decrement/increment buttons. See the Number Input docs for full details.
<div class="number-input" role="group" aria-label="Quantity">
<button class="number-input-btn" data-number-decrement type="button" aria-label="Decrease">−</button>
<input type="number" value="1" min="0" max="99" aria-label="Quantity">
<button class="number-input-btn" data-number-increment type="button" aria-label="Increase">+</button>
</div>
Requires assets/js/number-input.js.
Radio Group
wraps multiple radio inputs with consistent spacing. Supports vertical (default) and horizontal () layouts. See the Radio Group docs for full details.
<fieldset>
<legend class="radio-group-label">Shipping method</legend>
<div class="radio-group">
<div class="form-check">
<input type="radio" name="shipping" id="std" checked>
<label for="std">Standard (5-7 days)</label>
</div>
...
</div>
</fieldset>
Usage rules
| Do | Don't |
|---|---|
Use <label> with for attribute |
Use placeholder as a label replacement |
| Use for spacing | Add margins directly to inputs |
| Use for checkbox/radio pairs | Float labels next to checkboxes manually |
| Use for toggle switches | Style a checkbox as a toggle without the wrapper |
Add role="switch" on toggle inputs |
Use a toggle without the switch role |
| Use semantic tokens for customization | Hardcode colors on individual inputs |
Use <fieldset> for logical grouping |
Use <div> with borders to fake fieldsets |
| Keep inputs full-width by default | Set fixed widths unless layout requires it |
CSS reference
This section documents how the component is built. For usage, see the sections above.
Tokens
| Token | Default | What it controls |
|---|---|---|
| Default border colour | ||
| Input background | ||
| Input text colour | ||
| Placeholder text colour | ||
| Focus border and ring colour | ||
| Disabled background | ||
| Disabled text colour | ||
| Checkbox unchecked background | ||
| Checkbox/radio checked fill | ||
| Checkbox/radio border colour | ||
| Checkmark/dot colour | ||
| Toggle track background | ||
| Toggle knob (unchecked) | ||
| Toggle track (checked) | ||
| Toggle knob (checked) | ||
| Thumb variant: track fill | ||
3px |
Thumb variant: inset between track and thumb | |
| Thumb variant: track corner radius | ||
| Selected surface: the thumb, and the flat form's fill | ||
| Text on the selected surface, both forms | ||
transparent |
Optional thumb border — aesthetic only; the contrast pass lives in the surface | |
| track radius − track padding | Thumb corner radius, concentric with the track | |
| Thumb travel duration | ||
18rem |
Scroll ceiling for the option list |
The customizable select picker reuses --dropdown-background, --dropdown-border and --dropdown-item-hover rather than defining its own surface, so a native select and a read as the same object.
Selectors
| Selector | Purpose |
|---|---|
input[type="text"], input[type="email"], etc. |
Global text input styling: width, padding, border, font |
input:focus, textarea:focus, select:focus |
Focus state, border colour + box-shadow ring |
input:disabled, textarea:disabled, select:disabled |
Disabled state: muted background, not-allowed cursor |
textarea |
Textarea: min-height, vertical resize |
select |
Select: custom dropdown arrow, appearance reset |
select, select::picker(select) |
Customizable select opt-in via appearance: base-select |
select::picker-icon |
Caret in base appearance, rotates on select:open |
option |
Option row: metrics, hover, focus ring, disabled |
option .svg-icn |
Icon inside an option, sized to match .dropdown-item .svg-icn |
option .svg-icn:not(:only-child):last-child |
Icon written after the label, pushed to the trailing edge |
selectedcontent .svg-icn |
Icon cloned into the closed button, re-sized to match the row |
optgroup, optgroup legend |
Group label: quiet heading over its rows |
option::checkmark |
Selected marker, leads the row like ; column reserved on every row |
input[type="color"] |
Colour picker: appearance reset, aspect-ratio, swatch styling |
input[type="checkbox"], input[type="radio"] |
Custom checkbox/radio: appearance reset, checked state SVG |
| Inline checkbox/radio + label wrapper: flex, align-items, gap | |
.form-check label |
Label styling inside check wrapper: inline, regular weight |
| Toggle switch wrapper, grid layout for label + switch | |
.form-toggle input[type="checkbox"] |
Toggle track: pill shape, colour transition |
.form-toggle input[type="checkbox"]::after |
Toggle knob, circular pseudo-element |
.form-toggle input[type="checkbox"]:checked |
Checked track, background |
.form-toggle.is-label-right label |
Label-right variant, reorders grid column |
| Label + input wrapper, bottom margin spacing | |
| Helper text below input | |
| Error message styling | |
| Button group track: faded background, pill radius | |
| Segment button: padding, transitions | |
.segmented-control-btn.is-active |
Active segment: primary bg, inverted text |
.segmented-control-btn.is-icon |
Icon-only segment variant |
.segmented-control[data-variant="thumb"] |
Thumb variant track: equal-column grid, track fill and radius |
.segmented-control[data-variant="thumb"]::before |
The thumb: sized by segment count, moved by checked index |
Segment label: metrics, colour transition, checked and focus states via :has() — all rules scoped to the variant |
|
.segmented-control-option input[type="radio"] |
The invisible radio, stretched over its label as the hit area |
.segmented-control-option:has(input:disabled) |
Disabled segment: at half opacity, not-allowed cursor |
| Labelled slider container | |
| Stepper wrapper: flex, border | |
| Increment/decrement button | |
| Radio group wrapper, vertical stack | |
.radio-group.is-horizontal |
Horizontal radio layout |
| Group legend styling |
Use in another product
The design system installs once per product:
npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0
This component's styles ship in design-system.css. No JavaScript, nothing else to include.
In React, render this contract through the packaged adapter instead of writing the markup by hand:
import { SegmentedControl } from '@bydefaultstudio/design-system/react';
The adapter renders the contract above and bridges this component's events to props — see React.