Skip to content

Forms

Form elements

tty.css styles semantic, browser-native controls without replacing their built-in behavior. Use explicit labels, appropriate input types, and native validation before adding application JavaScript.

Form basics

Associate each control with a visible label. A matching for and id keeps the label clickable and gives the control an accessible name. Use help text with aria-describedby; placeholders are examples, not labels.

A small immediately after a text-like input, select, or textarea gets muted block helper styling and a negative top margin, regardless of ancestry. Input types checkbox, radio, range, hidden, button, submit, and reset are excluded. For wrapped or grouped controls, use small.helper: it uses margin: 0 0 1rem, even directly after a control. IDs and wrappers alone do not imply helpers.

Use a unique deployment name.
<form>
  <label for="project-name">Project name</label>
  <input id="project-name" name="project" type="text" placeholder="search-production" aria-describedby="project-help">
  <small id="project-help">Use a unique deployment name.</small>

  <label for="project-region">Region</label>
  <select id="project-region" name="region">
    <option value="">Choose a region</option>
    <option value="us-east">US East</option>
    <option value="eu-central">EU Central</option>
  </select>

  <label for="project-notes">Deployment notes</label>
  <textarea id="project-notes" name="notes" rows="4" placeholder="Optional context for operators"></textarea>
</form>
Keep this token private.
<label>API token <input name="api-token" type="password" aria-describedby="token-help"></label>
<small class="helper" id="token-help">Keep this token private.</small>

Common states

Browsers submit enabled controls, including read-only controls. Disabled controls are not editable, focusable, or submitted. Use readonly when a value still belongs in the submission and disabled when the control is unavailable.

<div class="grid">
  <div>
    <label for="state-default">Default</label>
    <input id="state-default" name="default" type="text" value="Editable value">
  </div>
  <div>
    <label for="state-placeholder">Placeholder</label>
    <input id="state-placeholder" name="placeholder" type="text" placeholder="Enter a value">
  </div>
  <div>
    <label for="state-readonly">Read-only</label>
    <input id="state-readonly" name="readonly" type="text" value="Submitted value" readonly>
  </div>
  <div>
    <label for="state-disabled">Disabled</label>
    <input id="state-disabled" name="disabled" type="text" value="Unavailable value" disabled>
  </div>
</div>

Textual input types

Choose the type that describes the expected data. Browsers may provide specialized keyboards, autofill, parsing, and constraint validation for email, password, tel, url, and number. These hints improve entry but do not replace server-side validation.

<div class="grid">
  <div>
    <label for="input-text">Text</label>
    <input id="input-text" name="display-name" type="text" autocomplete="name" value="Ada Operator">
  </div>
  <div>
    <label for="input-email">Email</label>
    <input id="input-email" name="email" type="email" autocomplete="email" placeholder="ops@example.com">
  </div>
  <div>
    <label for="input-password">Password</label>
    <input id="input-password" name="password" type="password" autocomplete="current-password" value="terminal-passphrase">
  </div>
  <div>
    <label for="input-tel">Telephone</label>
    <input id="input-tel" name="telephone" type="tel" autocomplete="tel" placeholder="+353 1 555 0100">
  </div>
  <div>
    <label for="input-url">URL</label>
    <input id="input-url" name="endpoint" type="url" inputmode="url" placeholder="https://api.example.com">
  </div>
  <div>
    <label for="input-number">Replica count</label>
    <input id="input-number" name="replicas" type="number" min="1" max="12" step="1" value="3">
  </div>
</div>

Date and time

Date and time controls retain the browser’s native picker, keyboard behavior, localized display, and validation. Submitted values use standardized machine-readable formats. datetime-local represents local wall-clock time and carries no time-zone offset.

Picker UI and support vary by browser and operating system; unsupported types degrade to text inputs. The browser may position or omit its own picker indicator differently, and Firefox intentionally keeps its native presentation.

<div class="grid">
  <div>
    <label for="schedule-date">Date</label>
    <input id="schedule-date" name="date" type="date" value="2026-08-31">
  </div>
  <div>
    <label for="schedule-datetime">Date and time</label>
    <input id="schedule-datetime" name="datetime" type="datetime-local" value="2026-08-31T14:30">
  </div>
  <div>
    <label for="schedule-month">Month</label>
    <input id="schedule-month" name="month" type="month" value="2026-08">
  </div>
  <div>
    <label for="schedule-week">Week</label>
    <input id="schedule-week" name="week" type="week" value="2026-W36">
  </div>
  <div>
    <label for="schedule-time">Time</label>
    <input id="schedule-time" name="time" type="time" value="14:30">
  </div>
</div>

Checkboxes, radios, and switches

Checkboxes represent independent choices. Radios share a name so only one option can be selected. A checkbox with role="switch" exposes an on/off switch while preserving native checkbox form behavior.

Index options
Visibility
Runtime features
<div class="grid">
  <fieldset>
    <legend>Index options</legend>
    <label><input name="full-text" type="checkbox" checked> Build full-text index</label>
    <label><input name="analytics" type="checkbox"> Enable analytics</label>
  </fieldset>
  <fieldset>
    <legend>Visibility</legend>
    <label><input name="visibility" type="radio" value="private" checked> Private</label>
    <label><input name="visibility" type="radio" value="public"> Public</label>
  </fieldset>
  <fieldset>
    <legend>Runtime features</legend>
    <label><input name="autoscaling" type="checkbox" role="switch" checked> Autoscaling</label>
    <label><input name="maintenance" type="checkbox" role="switch"> Maintenance mode</label>
  </fieldset>
</div>

Indeterminate checkbox

indeterminate is a JavaScript property, not an HTML attribute, and it affects presentation rather than the submitted checked value. Set it after the checkbox exists in the DOM and update it when child selections change.

<label><input id="select-services" name="select-services" type="checkbox"> Select all services</label>
<script>
  document.getElementById("select-services").indeterminate = true;
</script>

Range, file, and color

These controls keep browser-native interaction and accessibility while tty.css styles the exposed parts. Their internal widgets, dialogs, selected-file text, and color pickers differ across browsers and operating systems. Always provide a label; add visible output when users need the range’s exact value.

Native output uses tabular numerals by default, without imposing text alignment. Application code updates its value, as in the range example below.

<label for="replica-range">Replica target: <output for="replica-range">4</output></label>
<input id="replica-range" name="replica-target" type="range" min="1" max="8" step="1" value="4" oninput="this.previousElementSibling.querySelector('output').value = this.value">

<label for="schema-file">Schema file</label>
<input id="schema-file" name="schema" type="file" accept=".json,application/json">

<label for="label-color">Label color</label>
<input id="label-color" name="label-color" type="color" value="#ff9d46">

Validation

Set aria-invalid="true" only when a field is known to be invalid and connect its message with aria-describedby. ARIA defines aria-invalid="false" as explicitly valid; there is no aria-valid attribute. ARIA communicates state but does not perform validation or prevent submission.

Email address verified.
Enter an address containing @.
<form>
  <div class="grid">
    <div>
      <label for="valid-email">Verified email</label>
      <input id="valid-email" name="valid-email" type="email" value="ops@example.com" aria-invalid="false" aria-describedby="valid-email-message">
      <small id="valid-email-message">Email address verified.</small>
    </div>
    <div>
      <label for="invalid-email">Email with an error</label>
      <input id="invalid-email" name="invalid-email" type="email" value="ops.example.com" aria-invalid="true" aria-describedby="invalid-email-message">
      <small id="invalid-email-message">Enter an address containing @.</small>
    </div>
  </div>
</form>

Browser validation and :user-invalid

tty.css also styles :user-invalid. The pseudo-class follows native constraint validation after the browser considers the user to have interacted with the field, often after a submission attempt. Exact timing and validation messages vary by browser. Try submitting this empty required field.

Enter at least three characters.
<form>
  <label for="required-cluster">Cluster name</label>
  <input id="required-cluster" name="cluster" type="text" required minlength="3" aria-describedby="cluster-requirements">
  <small id="cluster-requirements">Enter at least three characters.</small>
  <button type="submit">Validate</button>
</form>

Fieldsets and legends

Use fieldset and legend to name a related set of controls. A disabled fieldset disables its descendant controls except descendants of its first legend. A read-only fieldset does not exist in HTML; apply readonly to each supported control instead.

Deployment identity
Unavailable environment
<div class="grid">
  <fieldset>
    <legend>Deployment identity</legend>
    <label for="deployment-id">Deployment ID</label>
    <input id="deployment-id" name="deployment-id" type="text" value="dep_01J8M4" readonly>
    <label for="deployment-owner">Owner</label>
    <input id="deployment-owner" name="deployment-owner" type="text" value="Platform team" readonly>
  </fieldset>
  <fieldset disabled>
    <legend>Unavailable environment</legend>
    <label for="disabled-environment">Environment</label>
    <select id="disabled-environment" name="environment">
      <option value="production">Production</option>
    </select>
    <label><input name="protect-environment" type="checkbox" checked> Protect environment</label>
  </fieldset>
</div>

Disclosure rows in a fieldset

Place details directly inside a fieldset to create divided disclosure rows. Native summary elements remain keyboard-operable and communicate their expanded state without JavaScript. The direct-child relationship is required for this fieldset-specific presentation.

Advanced deployment settings
Health checks
Shutdown behavior
<fieldset>
  <legend>Advanced deployment settings</legend>
  <details open>
    <summary>Health checks</summary>
    <label for="health-path">HTTP path</label>
    <input id="health-path" name="health-path" type="text" value="/healthz">
  </details>
  <details>
    <summary>Shutdown behavior</summary>
    <label for="grace-period">Grace period in seconds</label>
    <input id="grace-period" name="grace-period" type="number" min="0" max="300" value="30">
  </details>
</fieldset>

Control groups

.group is a spaced visual action layout that adds no accessibility semantics and stacks on narrow screens. Use it when nearby actions do not need a group announced. role="group" creates a connected semantic control group with collapsed borders and no button shadows; it stacks as a connected vertical group on narrow screens. Give a semantic group an accessible name with aria-labelledby or aria-label. Both layouts act on their direct children.

Visual .group

<div class="group">
  <button type="button">Deploy</button>
  <button type="button" class="secondary">Save draft</button>
  <button type="button" class="outline">Cancel</button>
</div>

Semantic role="group"

Log window

<p id="log-window-label">Log window</p>
<div role="group" aria-labelledby="log-window-label">
  <button type="button" aria-pressed="true">15 minutes</button>
  <button type="button" aria-pressed="false" class="secondary">1 hour</button>
  <button type="button" aria-pressed="false" class="secondary">24 hours</button>
</div>