Skip to content

Forms

Business applications are mostly forms, so this is the part worth getting right.

Use the macros

{% from "ads/form.html" import field, select, textarea, checkbox, button %}

<form method="post" class="ads-stack">
  {{ field("email", label="Email", type="email", required=true,
           help="We only use this for sign-in.",
           errors=form.errors.get("email")) }}

  {{ textarea("bio", label="Bio", rows=6, value=user.bio) }}

  {{ select("role", label="Role", value=user.role,
            options=[("admin", "Administrator"), ("editor", "Editor")]) }}

  {{ checkbox("digest", label="Send me the weekly digest", checked=user.digest) }}

  <div class="ads-cluster">
    {{ button("Save", type="submit") }}
    {{ button("Cancel", variant="secondary") }}
  </div>
</form>

What the macros do that hand-written markup usually forgets:

  • aria-describedby wired to both the help text and the error, in that order
  • aria-invalid="true" when there are errors
  • required on the control, not only an asterisk on the label
  • a <label for> that matches, or an implicit label for the checkbox

There is no .is-invalid

The invalid state keys off aria-invalid="true" in form.css — the attribute the field has to carry anyway:

.ads-input[aria-invalid="true"] { border-color: var(--ads-danger);  }

So a field cannot look wrong without announcing that it is wrong, and the two cannot drift apart. The same principle covers aria-current for navigation and aria-pressed for toggles.

Hand-writing a field

When you need something the macros do not cover:

<div class="ads-field">
  <label class="ads-label" for="q">Search</label>
  <div class="ads-input-icon">
    <span class="ads-input-icon__addon">{{ icon("search") }}</span>
    <input class="ads-input" id="q" name="q" type="search"
           aria-describedby="q-help">
  </div>
  <span class="ads-help" id="q-help">Try a company name.</span>
</div>

The control vocabulary

class what it is
ads-input text, email, number, search, date…
ads-select a <select>, with the chevron drawn by CSS (RTL-aware)
ads-textarea multi-line
ads-check the label wrapping a checkbox or radio
ads-switch a checkbox for a setting that applies immediately
ads-choice a radio or checkbox presented as a card
ads-range a slider
ads-chips--input a tag input: real <input>, chips beside it
ads-field the wrapper: label, control, help, error
ads-label--required adds the asterisk (decorative, aria-hidden)

Checkboxes and radios are drawn (appearance: none, a 20px box, the tick as an SVG background) so they can be sized with the system and match the reference. The rule is keyed on the control, not on a wrapper class — a wrapper is a promise every author has to remember, and three did not, which is how three native 13px radios shipped next to our 20px drawn ones.

Buttons

<button class="ads-btn ads-btn--primary">Save</button>
<button class="ads-btn">Cancel</button>
<button class="ads-btn ads-btn--ghost">Dismiss</button>
<button class="ads-btn ads-btn--danger">Delete</button>
<button class="ads-btn ads-btn--sm">Small</button>

A toggle button carries its state as an attribute:

<button class="ads-btn" aria-pressed="true">List</button>

Target size

Anything a person has to hit is at least --ads-tap (24 CSS pixels), or has 24 pixels of clear space around it. If you build a control of your own, that is the floor — and the browser sweep enforces it across every route, applying WCAG 2.5.8's exceptions rather than skipping them: a control inside its own <label> is hit by hitting the label, and a target with nothing near it passes on spacing.