Input & Textarea
A visible boundary at 3.06:1, no shadow, and an error state that is a border and a message — never a red fill.
--color-border-strong was #d4d4d4 in the Tailwind source and #949494 in the plain-CSS mirror. Real builds shipped 1.6:1 input borders for the life of that drift — the accessibility decision was made, written down, and applied only to the file nobody built from. It is now #8595a9 (3.06:1) in both. A diff between theme.css and tokens.css is a defect, not a nuance.
Required & optional
The two states side by side. The marking sits inside the label, in words, one step smaller than the field name and outside its weight — a state, not part of the name.
Every field is marked required or optional.
One strategy, product-wide. Mixing them inside a product is worse than either choice, because "no marking" then means two different things on two screens.
| Strategy | Marks | Use when |
|---|---|---|
| A — required | "Email address *", with "* Required" above the first field | Most fields are optional |
| B — optional | "Event website optional", nothing on required fields | Most fields are required |
| C — both ours | "Event name required" and "Event website optional" | Forms that mix the two throughout |
Sporting Scouter uses Strategy C. Every field states its own status, so no label depends on the reader having read a legend at the top, and none depends on an absence meaning something. Our forms are short enough that the repetition costs little, and they are filled in on phones, mid-scroll, by people who did not start at the top of the panel.
The cost of C is real and worth naming: every label grows, and the marking can drown the field name if it is styled like one. That is why the tag is sentence case, one step smaller than the label, and outside its uppercase run — it is a state, not part of the name.
- The indicator lives inside the label, never beside the field. "Event website optional", not a marker floating to the right of the input where it belongs to nothing.
- A form-level line still sits above the first field: "Every field is marked required or optional." Under C it is confirmation rather than a legend — but it is what tells the reader the marking is exhaustive, so an unmarked field is a bug rather than an inference.
- Words, not asterisks. An asterisk needs a legend and is read as "star" if it reaches the accessibility tree. If Strategy A were ever used, the glyph would be
aria-hidden="true". - Every required control carries
aria-required="true". The visible marking is for sighted users; this is what a screen reader announces. If Strategy A were ever used, the asterisk glyph would bearia-hidden="true"so it is not read as "star". - The optional tag is not styled as a label. Sentence case, one step smaller, outside the uppercase run — so it reads as a state, not as part of the field name.
- Never mark a field required and then accept it empty, and never show a required error on a field the user has not touched. Validation runs on submit.
- Minimise the required set first. The cheapest way to mark fewer fields is to ask for fewer things. Anything an organiser can fill in later is not required at this step.
Rules
| Aspect | Rule |
|---|---|
| Border | border-strong, not the hairline. A form field must be findable without hovering it. |
| Hover | Border darkens to ink. No fill change, no shadow. |
| Focus | Border goes border-focus and the 2px ring appears. Both, not either. |
| Height | 44px in comfortable, 32px in default on desktop. Never 32px on a touch surface. |
| Error | Border + message + aria-invalid + aria-describedby. Never a red background. |
| Read-only | surface-muted fill, hairline border, no hover. It is still selectable text. |
| Label | Always visible. A placeholder is not a label — it disappears the moment it is needed. |
React
From @sportingscouter/ui-react. The component adds no visual values of its own — every class comes from the same tv() recipe the design system defines, so the two cannot drift.
import { Input } from '@sportingscouter/ui-react';
<Input
label="Event name"
placeholder="Maratona de Lisboa"
required
/>
{/* error ⇒ aria-invalid + the message + a border. Never a red fill.
The hint STAYS while the error shows, and describedby lists the
error first — it is what the user needs in order to act. */}
<Input
label="Your email"
type="email"
hint="So we can reach you if this event needs checking."
error={showError ? 'Check for typos — invalid address.' : undefined}
required
/>
<Input
label="Event website"
type="url"
placeholder="https://"
optional
/>
<Input label="Registered" value="14 March 2026" readOnly />
<Input label="Rows per page" scale="sm" /> {/* 32px — desktop only */}theme.css.