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

ControlUse it for
Checkbox / radioForm DATA submitted later. Multiple choice vs exclusive choice.
SwitchImmediate on/off state changes (settings that apply on toggle).
Input groupA control with a bound prefix/suffix — protocol, unit, action button.
StepperSmall counted values adjusted in steps — replicas, retries, seats. Typing stays available; for free-range numbers use a plain input.
StepsMulti-stage flows; the indicator only. Pane logic is yours.
Error summaryLong server-rendered forms: one panel at the top listing every error as a link to its field.

Examples

Stacked layout

The default — labels above controls; required mark on the label:

Shown on the profile and in activity feeds.

Required and optional marks

The modern direction: mark the few optional fields, leave required ones bare (the native required attribute still carries the requirement to assistive tech):

Checkbox, radio, switch

Checkboxes = many, radios = one, switch = applies immediately:

Notifications
Visibility
Options

Fieldset group — stacked

The legend names the group and is announced with each option:

Notification level

Fieldset group — horizontal

In horizontal forms the legend floats into the label column:

Access

Segmented control

A joined radio-group for small, exclusive view/mode switches: pure CSS over native radios; the checked segment gets the primary-subtle fill:

View

Native radio behaviour throughout: arrow keys move the selection within the group, and a disabled segment is skipped:

Range

Horizontal layout

Labels in a fixed 145px start column, for dense settings/admin forms (below 560px the layout stacks automatically — the fixed column is a desktop pattern):

Used for timestamps across the console.

Horizontal layout, long labels

.soma-form-long-label widens the label column to 250px:

Per environment; further deployments queue.

Width scale

Size the control to the expected answer — a postcode is not a description:

Auto-growing textarea

Type (or paste) a few lines; the control grows with its content, capped at 320px:

Pure CSS (field-sizing: content); in Firefox, which hasn't shipped it, this is an ordinary fixed-height textarea.

Selects — single and multiple

The chevron comes from the icon set; [multiple] drops it:

Hold Ctrl/Cmd to select several. For a searchable picker use Select.

Validation states

State classes on the field wrapper tint the control and message:

That doesn't look like an email address.

Available.

Accessible wiring

The full contract: description and message ids listed in aria-describedby (description first), aria-invalid on the errored input:

Shown on the profile and in activity feeds.

Used only to sign you in.

That doesn't look like an email address.

Interaction-aware validation

Zero JavaScript: type "anna@" below and tab away; :user-invalid tints the control, fixing it flips to :user-valid:

Nothing happens until you interact; a pristine form never lights up red.

Error summary

The GOV.UK pattern for long forms: a danger panel at the top, every error a link to its field:

Enter a valid email address.

Choose a handle.

Disabled controls

The native disabled attribute — neutral fill, muted text, not-allowed cursor:

Flags

Input groups

Joined prefix/suffix rows: addon, input, button; -full stretches:

https://
project:

Number stepper

Joined −/+ buttons around a native number input (spinners hidden). Typing, ArrowUp/Down and min/max clamping stay native; the buttons are four lines of your JS:

min 1, max 12 — stepUp()/stepDown() clamp natively. Last change: (none)

Range and file

Native inputs with Soma chrome: accent slider, styled picker button:

Form actions

The submit row — primary action first, quiet cancel last:

Wizard steps

Indicator only — pane switching is your logic:

  1. Account
  2. Profile
  3. Team
  4. Confirm

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

ClassEffect
.soma-formThe form container; fields inside space themselves vertically.
.soma-fieldField wrapper; states -error / -success tint the control and the -message.
.soma-field-label / -required / -optional / -description / -messageThe 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-validZero-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-textareaControl 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-autogrowOn 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-horizontalOn 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-groupOn 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-switchLabel rows around native inputs (switch adds .soma-switch-track). Native disabled dims and blocks them.
.soma-segmentedJoined 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-fileRange slider and file input styling.
.soma-stepsWizard indicator; -item + -marker, done via .soma-steps-done, current via aria-current="step".
.soma-form-actionsThe 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.