Build a form that validates itself
Your form goes red on the first keystroke, because an empty required field is invalid
the moment the page loads and :invalid says so. That is the problem worth
solving, and it does not need JavaScript — it needs a different pseudo-class. This page
is that, plus the anatomy of a Deck field and the four accessibility details that are
yours rather than the framework's.
The short answer
Use real HTML validation attributes and let Deck style
:user-invalid, which it already does. Write no CSS and no script.
<div class="field">
<label class="label" for="email">Email</label>
<input class="input" id="email" type="email" required>
</div>
:invalid matches from the moment the page loads, so a required field is
red before anyone has touched it. :user-invalid only matches after the
person has interacted with the control and left it in a bad state. Same styling, and
the difference is the whole complaint people have about native form validation.
Type something that is not an email, then click away.
<div class="stack stack-4" style="max-inline-size:26rem">
<div class="field">
<label class="label" for="g-email">Email <span class="required">*</span></label>
<input class="input" id="g-email" type="email" required placeholder="you@example.com">
<p class="help">Type something that is not an email, then click away.</p>
</div>
</div>
<div class="stack stack-4" style="max-inline-size:26rem">
<div class="field">
<label class="label" for="g-email">Email <span class="required">*</span></label>
<input class="input" id="g-email" type="email" required placeholder="you@example.com">
<p class="help">Type something that is not an email, then click away.</p>
</div>
</div>
Try it. It stays neutral while you type, and turns red when you leave the field — which is when the feedback is useful and not before.
The anatomy of a field
.field is a stack. The gap between the label, the control and the help
text comes from the container, so none of the three carries a margin and you can add or
remove any of them without the spacing changing.
| Class | On | Does |
|---|---|---|
.field | The wrapper | Stacks label, control and help with one gap |
.label | The <label> | A flex row, so a marker sits beside the text |
.required | A span in the label | The asterisk. Visual only |
.optional | A span in the label | A muted "optional" marker |
.help | A <p> after the control | Small, muted supporting text |
.error | A <p> after the control | The error message |
.is-invalid | The .field | Forces the invalid look from your own validation |
.field-row | A wrapper round several fields | One column on a phone, two from 40rem |
Mark the minority. If most of your fields are required, mark the optional ones and say so once at the top; if most are optional, mark the required ones. Fifteen asterisks down a form is noise that stops being read by the third field.
Errors that come back from the server
The browser cannot know an email is already taken. For those, add
.is-invalid to the field yourself — it produces the same border and ring
as :user-invalid without the browser having to agree.
<div class="field is-invalid">
<label class="label" for="email">Email</label>
<input class="input" id="email" type="email" value="ada@ledgerly.io"
aria-invalid="true" aria-describedby="email-error">
<p class="error" id="email-error">That address is already registered.</p>
</div>
That address is already registered.
<div class="field is-invalid" style="max-inline-size:26rem">
<label class="label" for="g-taken">Email</label>
<input class="input" id="g-taken" type="email" value="ada@ledgerly.io" aria-invalid="true" aria-describedby="g-taken-err">
<p class="error" id="g-taken-err">That address is already registered.</p>
</div>
<div class="field is-invalid" style="max-inline-size:26rem">
<label class="label" for="g-taken">Email</label>
<input class="input" id="g-taken" type="email" value="ada@ledgerly.io" aria-invalid="true" aria-describedby="g-taken-err">
<p class="error" id="g-taken-err">That address is already registered.</p>
</div>
.is-invalid is the visual half and aria-invalid is the
announced half. Deck can only do the first. Both, or a screen reader user gets a field
that looks wrong to everyone but them.
The four things Deck cannot do for you
Every one of these is markup rather than styling, so no framework can supply them. They are also the four that most often go missing.
-
A
forand anidon every label and control. This is what makes the label clickable, what enlarges the tap target to the whole row, and what makes a screen reader announce the field's name. A.labelwith noforis decoration. -
aria-describedbypointing at the help or error text. Without it, help text is visible and unannounced — so the person who most needs the hint about password rules is the one who does not get it. -
A real
type.type="email",type="tel",type="url",type="number". This picks the phone keyboard, enables the browser's own validation, and turns on autofill.type="text"for an email address is three features thrown away. -
An
autocompletevalue.autocomplete="email","street-address","cc-number","one-time-code". It is the single highest-leverage attribute on a checkout form and it costs one word.
<div class="field">
<label class="label" for="tel">Mobile</label>
<input class="input" id="tel" name="tel" type="tel"
autocomplete="tel" aria-describedby="tel-help">
<p class="help" id="tel-help">We only text you about deliveries.</p>
</div>
Controls that are really controls
The switch and the choice card are one real input wearing a costume — a
<label> wrapped round a checkbox or radio, lit up with
:has(input:checked). No state class, no JavaScript, and the value posts
with the form because it is a checkbox.
<div class="stack stack-3" style="max-inline-size:28rem">
<label class="switch"><input type="checkbox" checked><span>Weekly summary email</span></label>
<label class="check"><input type="checkbox"><span>I agree to the terms</span></label>
<label class="check check-card">
<input type="radio" name="g-plan" checked>
<span class="check-text"><span>Team</span><span class="check-note">Up to 20 people, shared billing.</span></span>
</label>
</div>
<div class="stack stack-3" style="max-inline-size:28rem">
<label class="switch"><input type="checkbox" checked><span>Weekly summary email</span></label>
<label class="check"><input type="checkbox"><span>I agree to the terms</span></label>
<label class="check check-card">
<input type="radio" name="g-plan" checked>
<span class="check-text"><span>Team</span><span class="check-note">Up to 20 people, shared billing.</span></span>
</label>
</div>
That matters more than it looks. Keyboard support, screen reader announcement, form
serialisation, required on a checkbox, the browser's own validation
bubble, and autofill all work because nothing was reimplemented with a
<div> and a click handler. A custom switch has to rebuild every one
of those, and usually rebuilds three.
Each row is at least 44px tall, which is the minimum a finger can reliably hit. The visible box is smaller; the label makes up the difference.
Submitting
Let the browser block the submit when the form is invalid — that is what
required is for. Then use one toast for the whole round trip rather than
stacking a spinner and a result:
async function save(event) {
event.preventDefault();
const t = Deck.toast({ kind: 'loading', title: 'Saving…', duration: 0 });
try {
const res = await fetch('/api/settings', { method: 'POST', body: new FormData(event.target) });
if (!res.ok) throw new Error(await res.text());
t.update({ kind: 'good', title: 'Saved', duration: 4000 });
} catch (err) {
t.update({ kind: 'bad', title: 'Could not save', text: err.message, duration: 0 });
}
}
duration: 0 on the failure branch is deliberate. An error that dismisses
itself after five seconds is an error nobody read.
On a long form, .form-actions-sticky pins the buttons to the bottom of the
viewport with a blur behind them and padding past the home indicator, so the save
button is reachable without scrolling to the end.
Things not to do
-
Do not validate on every keystroke. Telling someone their email is
invalid while they are on the fourth character is technically true and practically
hostile.
:user-invalidwaits, and so should your JavaScript. - Do not use placeholder text as the label. It disappears the moment someone types, so the field has no name exactly when they are checking their answer, and its contrast is deliberately low. If you want the look, the floating label keeps a real label.
- Do not disable the submit button until the form is valid. A disabled button gives no reason and cannot be focused, so the person is stuck with no way to find out why. Let them submit and tell them what is wrong.
-
Do not put a
maxlengthon a phone number or a name. Names are longer than you think, and numbers have country codes.
Next
Every control has its own page with the full class table: input, select, textarea, checkbox and radio, switch, range, file and fieldset. For the composite controls, see the date picker and the combobox.