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.

Not red now. Red after you have typed in it and left

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.

The classes that make up a form field
ClassOnDoes
.fieldThe wrapperStacks label, control and help with one gap
.labelThe <label>A flex row, so a marker sits beside the text
.requiredA span in the labelThe asterisk. Visual only
.optionalA span in the labelA muted "optional" marker
.helpA <p> after the controlSmall, muted supporting text
.errorA <p> after the controlThe error message
.is-invalidThe .fieldForces the invalid look from your own validation
.field-rowA wrapper round several fieldsOne 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>
A server-side error, styled the same as a browser one

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.

  1. A for and an id on 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 .label with no for is decoration.
  2. aria-describedby pointing 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.
  3. 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.
  4. An autocomplete value. 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.

Three real inputs. Tab to them, press space
<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-invalid waits, 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 maxlength on 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.