⚠️ Design system CSS not found. Check the path in cms/docs.config.js → designSystemPath, then re-run npm run docgen.

Form

Inputs, controls, and data collection patterns

Website / Form
Download .md file
Open .md in new tab
On this page
  • Labels
  • Text Inputs
  • Focus State
  • Textarea
  • Select
  • Colour Input
  • Disabled State
  • Checkbox & Radio
  • Toggle / Switch
  • Layout Patterns
  • Segmented Control
  • Slider
  • Number Input
  • Radio Group
  • Usage rules
  • CSS reference
  • Use in another product

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-select on the select alone restyles the input and leaves the popup native.
  • The picker is a popover, so it is display: none when closed. Its entry transition needs transition-behavior: allow-discrete on display and overlay plus a @starting-style origin, 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. A size="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. sets display: flex itself, 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 its value attribute; 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:

Contact details
<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 declares CanvasText

Accessibility

  • Add role="group" and aria-label to the container
  • Use aria-pressed="true" on the active segment button
  • Toggle is-active and aria-pressed via JavaScript on click
  • The thumb variant needs none of the above script: use role="radiogroup" with an aria-label on 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.

75%
<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">&minus;</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.

Shipping method
<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.

On this page
  • Labels
  • Text Inputs
  • Focus State
  • Textarea
  • Select
  • Colour Input
  • Disabled State
  • Checkbox & Radio
  • Toggle / Switch
  • Layout Patterns
  • Segmented Control
  • Slider
  • Number Input
  • Radio Group
  • Usage rules
  • CSS reference
  • Use in another product
Previous Copy Button
Next Number Input

Was this page helpful?

We use this feedback to improve our documentation.

Thanks for your feedback

Send feedback

© 2026 By Default