Components
Forms
Labels above fields, one column, real HTML controls. Forms are where an interface most often becomes unusable, so the rules here are stricter than elsewhere.
Anatomy of a field
Every field is a .tmt-field containing a label, the control,
and optionally help text and an error. The order in the markup is the order they are
read.
Shown in the app and read aloud by voice assistants.
<div class="tmt-field">
<label class="tmt-label" for="device-name">Device name</label>
<input class="tmt-input" id="device-name" type="text"
aria-describedby="device-name-help">
<p class="tmt-help" id="device-name-help">
Shown in the app and read aloud by voice assistants.
</p>
</div>
- Labels sit above the field, always visible. Never a placeholder as a label — it disappears the moment someone types, and it fails contrast.
- Every label has
formatching the control'sid. That makes the label clickable and gives the control its accessible name. - Help text is linked with
aria-describedby, or it is invisible to a screen reader user. - Placeholders are examples, not instructions. If it matters, it goes in help text.
Controls
<div class="tmt-field">
<label class="tmt-label" for="email">
Email address <span class="tmt-label__required" aria-hidden="true">*</span>
</label>
<input class="tmt-input" id="email" type="email" autocomplete="email" required>
</div>
<div class="tmt-field">
<label class="tmt-label" for="room">
Room <span class="tmt-label__optional">(optional)</span>
</label>
<select class="tmt-select" id="room">…</select>
</div>
<textarea class="tmt-textarea" id="notes" rows="3"></textarea>
Inputs use an 8px radius rather than the system's pill, so it is obvious where text will land. The select's chevron is drawn on the inline-end edge and moves to the other side automatically in Arabic.
Required and optional
Mark whichever is rarer. If most fields are required, mark the optional ones; if most are optional, mark the required ones. Marking everything teaches people to ignore the marks.
The asterisk is aria-hidden because the
required attribute already conveys the state — otherwise a
screen reader announces "star" and "required" both.
Checkboxes and radios
Native controls, tinted with accent-color. They inherit the
platform's keyboard behaviour and its high-contrast rendering, which a custom-drawn
control has to reimplement and usually gets wrong.
<fieldset class="tmt-fieldset">
<legend class="tmt-legend">When should this run?</legend>
<label class="tmt-choice">
<input type="radio" name="when" checked>
<span class="tmt-choice__text">
<span>Every day</span>
<span class="tmt-choice__hint">Runs at the same time, including weekends.</span>
</span>
</label>
</fieldset>
A group of radios or checkboxes belongs in a
<fieldset> with a
<legend>. Without it, a screen reader user hears the
options but never the question.
Switches
A switch applies immediately. A checkbox waits for a submit. Using a switch inside a form with a Save button is the most common mistake with this control — people flip it, walk away, and lose the change.
<label class="tmt-switch">
<input type="checkbox" role="switch" checked>
<span class="tmt-switch__track"></span>
<span>Away mode</span>
</label>
The knob travels toward the inline end, so it moves right in English and left in Arabic. Because the state is carried by position rather than colour alone, the switch still reads correctly in greyscale — which it has to, in a monochrome system.
Form layout
- One column. Two columns make people's eyes zig-zag and hide fields from anyone scanning down the left edge. The exception is genuinely paired data — city and postcode, expiry month and year.
- 24px between fields (
--tmt-space-5), 8px between a label and its control. - Group with a fieldset when several fields answer one question.
- Match the field width to the content. A postcode field as wide as an address field tells the person the wrong thing about what to type.
- Actions at the bottom, primary last, in a cluster.
Autofill and input types
Getting these right is the cheapest usability win in a form — and for people using voice control or a switch device, it can be the difference between a form taking twenty seconds and taking five minutes.
- Set
autocompleteon anything personal:name,email,tel,street-address,one-time-code. - Use the right
type—email,tel,url,number— so mobile keyboards match. - Use
inputmode="numeric"for digit strings that are not really numbers, such as a verification code. - Never block paste. People paste passwords from managers and codes from messages.
Validation
The invalid state is carried by three signals at once — a 2px error border, an icon, and the message text — so it survives greyscale, colour blindness and a monochrome screen.
Enter an email address in the format name@example.com
<div class="tmt-field tmt-field--invalid">
<label class="tmt-label" for="email">Email address</label>
<input class="tmt-input" id="email" type="email"
aria-invalid="true" aria-describedby="email-error">
<p class="tmt-error-text" id="email-error">
Enter an email address in the format name@example.com
</p>
</div>
When and how to validate, and how to build the error summary at the top of a long form, are covered in Form validation.