Every form state, live, in one place.
The lead-capture kit as working specimens — type into them, trip their states, reset them, and copy the markup, never a screenshot. Each piece below is one obligation from the written law, demonstrated at full behaviour.
Nothing on this page sends anywhere. Every form is an inert specimen and says so in its own frame; the bot check is a simulated stub, marked as such.
The contract — seven pieces, none optional
- An accessible label on every control. Visible
<label for>;aria-labelonly on a single-field component; a placeholder is never the label. → F01 - Per-field error lines. Shown on submit, cleared on input (
changefor selects), focus to the first invalid field — never one note under the button. → F02 - Helper text that stays. An always-visible hint with
aria-describedbyand reserved height — guidance never lives in the placeholder. → F03 autocompleteon name and email. Browsers fill most of a lead form; a form that blocks that loses the lead. → F04- The bot check gates honestly. Pending blocks with a stated reason; never-loaded passes through after a grace window — a visitor is never trapped client-side. → F05
- Consent locks submit — and never plays dead. Dimmed and
aria-disabled, an intercepted click flags the row and takes focus. Never pre-checked. → F06 - Success replaces the form. A confirmation with
role="status"and a focus move — the task is over and the surface says so. → F07
F08 assembles all seven into the full lead form — the shape a new form starts from. Specimen numbers are permanent: F-numbers never renumber, a retired specimen keeps its number, new pieces append.
Three picks stay open for EC, none decided here: the label register (live in F01), kit packaging (shared components vs copyable specimens), and the production register’s opt-in default — details in GUIDE.forms.md, Open picks.
Label law
in the kitThree ways a field announces itself — two allowed, one forbidden. Type into the third and watch the only thing naming the field disappear; that demonstration runs in pure CSS, no script involved.
The label register — which style becomes the kit’s
open pickTwo label styles are live in the estate today. One of them becomes the kit’s register; the pick is EC’s, and both stay demonstrated here until it lands (GUIDE.forms.md, Open picks).
<label for> on every input, select, textarea · single-field component only → aria-label on the control · placeholder = format example, never the label · label register: .fk-label (A) vs .fk-label-alt (B) — open pick
Per-field error lifecycle
in the kitSubmit the form empty: an error line appears at each field — never one aggregate note under the button — and focus lands on the first invalid field. Fix a field and its line clears alone, the moment you type; the select clears the moment you choose. Errors appear on submit, not while someone is still typing.
.fk-field > label + control + .fk-err[id] · control: aria-describedby → its err id, aria-invalid while flagged · submit → flag each invalid + focus the first · input clears its own field · select clears on change · the message says what to do and never discards what was typed
Helper text
in the kitGuidance the label can’t carry lives in an always-visible hint under the field — never the placeholder, never a tooltip. Type something and the hint changes state without the layout shifting (its height is reserved); trip the error and the hint survives beneath it, because hint and error are separate lines and aria-describedby carries both.
.fk-hint[id] always visible, min-height reserved · control: aria-describedby="hint err" · aria-live="polite" where the hint changes state · guidance never in the placeholder · autocomplete="off" where autofill would be wrong (this field)
Autocomplete + input hygiene
in the kitThe attribute set that lets a browser fill the form in one gesture — each control’s live attributes are printed under it. On a phone, the email field raises the email keyboard. Paste an email with stray spaces and leave the field: the spaces go, nothing else is touched.
autocomplete=given-name / family-name / email · email: type=email + inputmode=email + spellcheck=false + autocapitalize=none + autocorrect=off · trim on blur (data-fk-trim) — whitespace only, never rewrite what was typed · autofill off only where it would be wrong
Bot check — pending vs unloaded
in the kit simulatedThe widget below is a simulated stub — no challenge script loads on this page. That’s deliberate: a real widget can’t be driven into its never-loaded state on demand, and the distinction is the whole lesson. Drive it: pending holds submit and a click on it names the reason; never loaded runs a grace window and then lets the form through, because the server verifies and a visitor is never trapped client-side. Pass the check and the token is single-use — submitting consumes it.
three states · token held → submit enabled · pending → submit dimmed + an inline nudge naming the reason · never loaded → grace window, then submit unblocks and the server verifies · tokens are single-use: a failed submit resets the widget before retry · expiry/error callbacks null the token and re-hold submit
Consent lock
in the kitFill the email, then click Request access before checking the box: the button is dimmed and aria-disabled, but it never plays dead — the click is intercepted, the consent row flags red, and focus moves to the checkbox, so the form answers the attempt by pointing at the reason. Check it and the button wakes instantly. The whole padded row is the control, and it is never pre-checked.
submit: aria-disabled + dimmed, never the disabled attribute (disabled is unfocusable and silent) · click AND Enter intercepted → row flags red + focus moves to the checkbox · checking clears the flag and unlocks · the padded row is one target, ≥44px · never pre-checked
Confirmation screen
in the kitSuccess replaces the form — the task is over and the surface says so, rather than leaving a filled form under a toast. The panel carries role="status" so it announces itself, and focus moves onto it so keyboard and screen-reader users arrive with it. Give a first name and the headline addresses you. Reset re-arms the specimen and hands focus back to the first field.
You’re on the list.
We review requests in small batches — watch your inbox.
panel: role="status" + tabindex="-1" + .focus() on arrival · replaces the form (form hidden), never a toast over it · entrance is opacity/transform on the panel only — reduced motion gets the complete frame instantly · reset returns the form, clears state, focuses the first field
The assembled form
in the kitEvery piece above in one form, wired by the same script hooks as the specimens — assembled, not designed. Submit it empty to watch all of it fire at once: per-field lines, the consent answer, the held check. Then fill it, complete the check, consent, and go — the confirmation takes the form’s place. This is the shape a new lead form starts from.
You’re on the list.
We review requests in small batches — watch your inbox.
assembly order (GUIDE.forms.md §Wiring): labels + autocomplete → hints (aria-describedby) → validation lifecycle → bot check → consent lock → confirmation → run the checkers and render at both widths · every colour resolves to a --rly-* token · a live form posts to a real endpoint; a specimen is inert and says so