Forms
Summary
Fields and controls for server-rendered forms: labelled fields
with descriptions and validation messages, shared control chrome
for inputs/selects/textareas, native-tinted checkboxes and
radios, a toggle switch, joined
input groups, a number
stepper, range and file inputs, and the
wizard steps indicator. Validation state is a
class on the field wrapper: the server can render it directly,
:user-invalid covers the zero-JS client side, and
long forms get an error summary panel linking
to each errored field.
When to use
| Control | Use it for |
|---|---|
| Checkbox / radio | Form DATA submitted later. Multiple choice vs exclusive choice. |
| Switch | Immediate on/off state changes (settings that apply on toggle). |
| Input group | A control with a bound prefix/suffix — protocol, unit, action button. |
| Stepper | Small counted values adjusted in steps — replicas, retries, seats. Typing stays available; for free-range numbers use a plain input. |
| Steps | Multi-stage flows; the indicator only. Pane logic is yours. |
| Error summary | Long server-rendered forms: one panel at the top listing every error as a link to its field. |
Examples
HTML
A stacked form: a labelled field with description, a field in
the error state, and the actions row. The
-description is a persistent hint; the
-message is validation feedback whose color follows
the field's state class:
<form class="soma-form">
<div class="soma-field">
<label class="soma-field-label" for="name">Name
<span class="soma-field-required" aria-hidden="true">*</span></label>
<input class="soma-input" id="name" type="text" required
aria-describedby="name-desc" />
<p class="soma-field-description" id="name-desc">Shown on the profile.</p>
</div>
<div class="soma-field soma-field-error">
<label class="soma-field-label" for="email">Email</label>
<input class="soma-input" id="email" type="email"
aria-invalid="true" aria-describedby="email-msg" />
<p class="soma-field-message" id="email-msg">That doesn't look like an email address.</p>
</div>
<div class="soma-form-actions">
<button class="soma-button soma-button-primary" type="submit">Save</button>
</div>
</form>
The required mark is visual (aria-hidden); keep
the input's native required attribute so assistive
technology announces the requirement too. The full wiring
contract is under Accessible wiring below.
The horizontal layout with a fieldset group, the accessible pattern for radio/checkbox sets:
<form class="soma-form soma-form-horizontal soma-form-long-label">
<div class="soma-field">
<label class="soma-field-label" for="tz">Timezone</label>
<select class="soma-select soma-select-medium" id="tz">…</select>
</div>
<fieldset class="soma-field soma-fieldset-group">
<legend>Notification level</legend>
<label class="soma-radio"><input type="radio" name="n" checked /> Everything</label>
<label class="soma-radio"><input type="radio" name="n" /> Mentions only</label>
</fieldset>
</form>
CSS classes
| Class | Effect |
|---|---|
.soma-form | The form container; fields inside space themselves vertically. |
.soma-field | Field wrapper; states -error / -success tint the control and the -message. |
.soma-field-label / -required / -optional / -description / -message | The field's text parts. Description = persistent hint (muted); message = validation feedback (colored by the state class); -optional = muted "(optional)" label suffix — the recommended direction over starring required fields. |
:user-invalid / :user-valid | Zero-JS complement to the state classes: inputs/selects/textareas get the same error/success control tint from native constraint validation, but only after the user has interacted. The message element still needs the state class. |
.soma-form-errors (+ -title, -list) | Error summary: danger panel for the top of long forms; the list holds <a href="#field-id"> links to each errored field. |
.soma-input / .soma-select / .soma-textarea | Control chrome. Width scale: -short (75px), -medium (165px), default 320px, -long (500px), -full (100%); size the control to the expected answer. All cap at max-width: 100%. |
.soma-select[multiple] | Multiple-choice list box; the chevron glyph is dropped automatically. |
.soma-textarea-autogrow | On a .soma-textarea: grows with its content via CSS field-sizing: content, capped at 320px (then scrolls). Progressive: Chromium + Safari; Firefox keeps the fixed height. |
.soma-form-horizontal | On the form: labels move into a fixed start column (145px) for dense settings pages; .soma-form-long-label widens it to 250px. The column width is the --soma-form-label-width custom property. |
.soma-fieldset-group | On a <fieldset class="soma-field …">, the accessible way to group radio/checkbox sets; the <legend> names the group and is announced with each option. |
.soma-checkbox / .soma-radio / .soma-switch | Label rows around native inputs (switch adds .soma-switch-track). Native disabled dims and blocks them. |
.soma-segmented | Joined radio-group on a <fieldset>: assistive <legend> names the group, each segment is a <label> wrapping a hidden native radio + visible <span>. Checked segment: primary-subtle fill; padding follows the density scale; native disabled dims a segment. |
.soma-input-group (+ -full, -addon) | Joined prefix/suffix rows mixing addons, inputs and buttons. |
.soma-stepper (+ -button) | Number stepper: joined −/+ .soma-stepper-buttons around a centred .soma-input[type="number"] (native spinners hidden, 75px). Icon-only buttons: aria-label mandatory; you wire them to stepUp()/stepDown(). |
.soma-range / .soma-input-file | Range slider and file input styling. |
.soma-steps | Wizard indicator; -item + -marker, done via .soma-steps-done, current via aria-current="step". |
.soma-form-actions | The submit/cancel row. |
JavaScript
None — forms are pure CSS over native controls; validation classes are yours to set (server- or client-side; a client-side wiring example is under Validation states below), and the number stepper's buttons are four lines of your JS (under Number stepper). The date picker and the searchable select are documented separately.
Width scale
Size the control to the expected answer: the same suffixes
work on .soma-input, .soma-select
and (where they make sense) .soma-textarea:
<input class="soma-input soma-input-short" /> <!-- 75px — counts, codes -->
<input class="soma-input soma-input-medium" /> <!-- 165px — postcodes, ports -->
<input class="soma-input" /> <!-- 320px — names, hostnames -->
<input class="soma-input soma-input-long" /> <!-- 500px — one-line summaries -->
<input class="soma-input soma-input-full" /> <!-- 100% of the container -->
Every control caps at max-width: 100%, so a
-long field in a narrow widget shrinks instead of
overflowing. Textareas add a 88px minimum height and vertical
resize only.
Auto-growing textarea
Add .soma-textarea-autogrow and the textarea tracks
its content height as the user types: no JS, no hidden mirror
element, just CSS field-sizing: content. It keeps
the usual 88px floor, grows line by line, and stops at a 320px
cap, beyond which it scrolls like a plain textarea (so a pasted
wall of text can't swallow the form):
<textarea class="soma-textarea soma-textarea-full soma-textarea-autogrow"></textarea>
Browser note: Chromium and Safari grow it natively; Firefox
hasn't shipped field-sizing yet and ignores the
declaration gracefully; the control falls back to the standard
fixed height with manual resize.
Selects
.soma-select is chrome over the native element:
the dropdown chevron is painted from the icon-token set
(--soma-icon-chevron-down) and mirrors to the
other side under RTL. A [multiple] select drops
the chevron and renders as a list box:
<select class="soma-select soma-select-medium">
<option>production</option>
<option>staging</option>
</select>
<select class="soma-select" multiple size="4">
<option selected>us-east</option>
<option>us-east</option>
</select>
This is deliberately the native picker — zero JS, works
everywhere forms do. When the option list is long enough to
need type-ahead filtering or pills, upgrade to
Soma.select2, which
enhances this same markup.
Checkboxes, radios and switches
All three are label rows wrapping a native input: the wrapping
<label> associates text and control without
for/id, and the native input keeps
every platform behaviour (forms, keyboard, AT). Checkboxes and
radios are tinted via accent-color:
<label class="soma-checkbox"><input type="checkbox" checked /> Email me on mentions</label>
<label class="soma-radio"><input type="radio" name="vis" checked /> Everyone</label>
The switch hides its checkbox visually and paints a track; the
input carries role="switch" (announced as a
switch, not a checkbox) and the track is decorative
(aria-hidden). Use a switch only when toggling
applies immediately — data submitted later stays a checkbox:
<label class="soma-switch">
<input type="checkbox" role="switch" checked />
<span class="soma-switch-track" aria-hidden="true"></span>
Auto-deploy on merge
</label>
Disable any of them with the native disabled
attribute; the row dims and gets a not-allowed cursor. The
thumb travel mirrors under RTL, and its transition is dropped
under prefers-reduced-motion.
Fieldset groups
A set of radios or checkboxes is one question — group it in a
<fieldset> so the <legend>
names the question and screen readers announce it with each
option. The fieldset doubles as the field wrapper:
<fieldset class="soma-field soma-fieldset-group">
<legend>Notification level</legend>
<label class="soma-radio"><input type="radio" name="n" checked /> Everything</label>
<label class="soma-radio"><input type="radio" name="n" /> Mentions only</label>
<label class="soma-radio"><input type="radio" name="n" /> Nothing</label>
</fieldset>
In the horizontal layout the legend floats into the label column automatically (browsers special-case the legend box, so it can't be a grid item — the component handles this; nothing extra to author).
Segmented control
A joined radio-group for small, exclusive view or mode switches: table vs board, day vs week. It is a form control: it holds a value and submits like any radio group. For switching panels of content in place use tabs; for "which subset am I looking at" filtering use chips.
<fieldset class="soma-segmented">
<legend class="soma-assistive">View</legend>
<label><input type="radio" name="view" value="table" checked /><span>Table</span></label>
<label><input type="radio" name="view" value="board" /><span>Board</span></label>
<label><input type="radio" name="view" value="timeline" /><span>Timeline</span></label>
</fieldset>
Pure CSS over native radios — no JS. The
<legend> names the group (visually hidden
with .soma-assistive, announced with each
option); each segment is a <label> wrapping
a visually-hidden native radio and the visible
<span>, so keyboard behaviour (arrow keys
move the selection within the group), form participation and
screen-reader semantics are all the native radio's. The
checked segment gets the primary-subtle fill with
--soma-color-primary-subtle-fg text; keyboard
focus draws the focus ring on the segment; segment padding
follows the density scale
(--soma-density-control-y), so the control
tightens under cosy/compact like every other control. Under
forced colors (Windows High Contrast) the checked segment is
repainted with the system Highlight colors. Disable a segment
with the native disabled attribute on its radio.
Horizontal layout
.soma-form-horizontal turns each field into a
two-column grid: label in a fixed end-aligned start column,
control beside it. Descriptions and messages stay in the
control column, optionless rows (a leading checkbox/radio, the
actions row) start in the control column, and the actions row
indents to line up with the controls. The presets are 145px
and (with .soma-form-long-label) 250px; for
anything else, override the custom property:
/* One form with an in-between label column: */
.settings-form {
--soma-form-label-width: 200px;
}
Reserve the horizontal layout for dense settings/admin pages where the operator scans many short fields; stacked labels read better on entry-style forms and narrow panes.
Validation states
Validation state lives on the field wrapper:
.soma-field-error or
.soma-field-success tints the control's border,
its focus ring and the .soma-field-message. The
server can render the classes directly:
<div class="soma-field soma-field-error">
<label class="soma-field-label" for="email">Email</label>
<input class="soma-input" id="email" type="email" value="anna@"
aria-invalid="true" aria-describedby="email-msg" />
<p class="soma-field-message" id="email-msg">That doesn't look like an email address.</p>
</div>
Point aria-describedby at the message (and set
aria-invalid) so the feedback is announced, not
just tinted. Client-side, the same classes wire to native
constraint validation in a few lines:
// Client-side wiring: toggle the state classes from checkValidity().
const field = document.querySelector('#email-field');
const input = field.querySelector('.soma-input');
const message = field.querySelector('.soma-field-message');
input.addEventListener('blur', () => {
const ok = input.checkValidity();
field.classList.toggle('soma-field-error', !ok);
field.classList.toggle('soma-field-success', ok && input.value !== '');
input.setAttribute('aria-invalid', String(!ok));
message.textContent = ok ? '' : input.validationMessage;
});
Accessible wiring
The canonical field wires four things: the
label[for] points at the input[id];
the description and any validation message carry ids the input
lists in aria-describedby (description first, then
message), so both are announced with the control; an errored
input adds aria-invalid="true". This is the
Accessible wiring demo above, exactly as rendered:
<form class="soma-form">
<div class="soma-field">
<label class="soma-field-label" for="name">Full name
<span class="soma-field-required" aria-hidden="true">*</span></label>
<input class="soma-input soma-input-full" id="name" type="text" required
aria-describedby="name-desc" />
<p class="soma-field-description" id="name-desc">Shown on the profile and in activity feeds.</p>
</div>
<div class="soma-field soma-field-error">
<label class="soma-field-label" for="email">Email</label>
<input class="soma-input soma-input-full" id="email" type="email" value="anna@"
aria-invalid="true" aria-describedby="email-desc email-msg" />
<p class="soma-field-description" id="email-desc">Used only to sign you in.</p>
<p class="soma-field-message" id="email-msg">That doesn't look like an email address.</p>
</div>
</form>
A field with no description simply lists the message id alone
(and vice versa); aria-describedby takes any
space-separated set. Without the wiring the error is only
tinted: a screen-reader user tabbing into the input
hears the label and nothing else.
Interaction-aware validation
Inputs, selects and textareas also pick up the error/success
control tint from native constraint validation via
:user-invalid / :user-valid — no
classes, no JavaScript. Unlike bare :invalid
(deliberately unstyled), the :user-* forms match
only after the user has interacted with the control, so a
pristine required form doesn't open covered in red:
<!-- Tints itself on blur when the value fails the type/required
constraints — nothing to wire: -->
<input class="soma-input" type="email" required />
The pseudo-classes tint the control only; rendering a
.soma-field-message (and the summary below) still
needs the state class on the wrapper, server-set or via the
checkValidity() wiring under Validation
states. Server-rendered state classes remain the primary
API; this is the zero-JS client-side complement.
Error summary
On a long server-rendered form, per-field messages can sit below the fold when the page re-renders after a failed submit. The error summary (GOV.UK / Carbon pattern) puts one danger panel at the top listing every error as a link to its field:
<div class="soma-form-errors" role="alert" tabindex="-1">
<h2 class="soma-form-errors-title">There is a problem</h2>
<ul class="soma-form-errors-list">
<li><a href="#email">Enter a valid email address</a></li>
<li><a href="#handle">Choose a handle</a></li>
</ul>
</div>
Each link's href targets the errored input's
id, so following it jumps to the field; the linked
fields carry the usual .soma-field-error wiring.
tabindex="-1" lets your page script move focus to
the panel after a failed submit
(document.querySelector('.soma-form-errors').focus())
so the first thing announced is the problem list;
role="alert" makes a client-side injection
announce itself (on a server-rendered page it is optional; the
content was there at load). Heading level is yours: use
whatever fits the page outline.
Required and optional fields
Modern form convention marks the optional fields, not
the required ones: on a well-designed form most fields are
required, so a page of asterisks says nothing. Suffix the label
with a muted .soma-field-optional mark and leave
required labels bare. This is the recommended direction; the
asterisk stays available for legacy-shaped forms:
<!-- Recommended — mark the exception: -->
<label class="soma-field-label" for="slogan">Slogan
<span class="soma-field-optional">(optional)</span></label>
<input class="soma-input" id="slogan" type="text" />
<!-- The classic mark, when a form is mostly optional: -->
<label class="soma-field-label" for="name">Name
<span class="soma-field-required" aria-hidden="true">*</span></label>
<input class="soma-input" id="name" type="text" required />
Either way the announcement comes from the native
required attribute, not the mark: the asterisk is
aria-hidden and "(optional)" is read as part of
the label text.
Input groups
An input group joins addons, inputs, selects and buttons into one visual control: outer corners round, inner corners square, shared 1px borders collapse. Order is free; the flexible control stretches and the addons/buttons keep their natural width:
<div class="soma-input-group soma-input-group-full">
<span class="soma-input-group-addon">https://</span>
<input class="soma-input" type="text" aria-label="Site address" />
<button class="soma-button" type="button">Check</button>
</div>
The group itself is 320px (-full = 100%); the
focused control lifts above its joined neighbours so the focus
ring is never clipped. Addons are static text: give the input
its own label (a .soma-field-label or
aria-label); the addon doesn't name it.
Number stepper
A joined −/+ pair around a native
<input type="number"> for small counted
values: replicas, retries, seats. The input keeps every
platform behaviour (typing, ArrowUp/ArrowDown,
min/max/step clamping,
form participation, AT announcement); its own tiny spinners are
hidden because the explicit buttons replace them. The buttons
are icon-only, so the aria-labels are mandatory,
and type="button" keeps them from submitting:
<div class="soma-stepper">
<button class="soma-stepper-button" type="button" id="replicas-dec"
aria-label="Decrease replicas">
<span class="soma-icon soma-icon-minus"></span></button>
<input class="soma-input" id="replicas" type="number"
value="3" min="1" max="12" step="1" />
<button class="soma-stepper-button" type="button" id="replicas-inc"
aria-label="Increase replicas">
<span class="soma-icon soma-icon-plus"></span></button>
</div>
CSS-only — no library JS. The whole wiring is four lines:
stepUp()/stepDown() respect
min/max/step natively;
dispatch a change so listeners on the input hear
button steps too (programmatic steps don't fire events on
their own):
const input = document.querySelector('#replicas');
const fire = () => input.dispatchEvent(new Event('change', { bubbles: true }));
document.querySelector('#replicas-dec').addEventListener('click', () => { input.stepDown(); fire(); });
document.querySelector('#replicas-inc').addEventListener('click', () => { input.stepUp(); fire(); });
The buttons stretch to the input's height, so the control
tightens under cosy/compact density like every other control;
everything directional is logical, so the group mirrors under
RTL with the decrement first in reading order. Disable the
whole control with the native disabled attribute
on the input and both buttons. At a bound
min/max, stepDown()/stepUp()
simply hold the value — disable the matching button in your
change handler if you want the affordance to show it.
The live stepper demo above, exactly as this page wires it:
const stepInput = document.querySelector('#st-replicas');
const stepFire = () => stepInput.dispatchEvent(new Event('change', { bubbles: true }));
document.querySelector('#st-dec').addEventListener('click', () => { stepInput.stepDown(); stepFire(); });
document.querySelector('#st-inc').addEventListener('click', () => { stepInput.stepUp(); stepFire(); });
// The echo proves the dispatched change: typing AND button steps both land here.
stepInput.addEventListener('change', () => {
document.querySelector('#st-out').textContent = stepInput.value;
});
Range and file inputs
Both stay native. The range slider is tinted via
accent-color; the file input styles its picker
button through ::file-selector-button with
standard Soma button chrome:
<input class="soma-range soma-range-full" type="range" min="1" max="12" value="3" />
<input class="soma-input-file" type="file" />
A range input's value is invisible to the operator; echo it in the label, as this page's demo does:
// The live Replicas demo above, exactly as this page wires it:
const range = document.querySelector('#rf-range');
range.addEventListener('input', () => {
document.querySelector('#rf-range-out').textContent = range.value;
});
Wizard steps
The steps bar is a plain ordered list: numbering comes from
CSS counters, completed markers swap the number for a check,
and the current step carries aria-current="step":
<ol class="soma-steps">
<li class="soma-steps-item soma-steps-done"><span class="soma-steps-marker"></span>Account</li>
<li class="soma-steps-item" aria-current="step"><span class="soma-steps-marker"></span>Profile</li>
<li class="soma-steps-item"><span class="soma-steps-marker"></span>Confirm</li>
</ol>
It is the indicator only: advancing means your code moves
.soma-steps-done and aria-current and
swaps the pane (with tabs or app
logic). Steps are not links — jumping around a wizard is a
flow decision, not a navigation default.