Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Reference-grade guide to forms and data entry — single-column layout, persistent labels, the right input type + mobile keyboard + autocomplete token per field, blur-timed inline validation with :user-invalid and actionable errors, smart defaults, multi-step wizards, and data tables/grids at scale, all keyboard- and screen-reader-accessible across web and mobile.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | 242% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 250% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 168% | 0% |
| case-08 | ✓→✓ | = Same ✓ | 185% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 198% | 0% |
A form is a conversation, not an interrogation. Every field is a cost the user pays; every field you remove is a conversion you keep. The job: ask for the least, in the clearest order, with the right control and keyboard, validate kindly, and tell people exactly how to fix mistakes. Get layout, labels, input type, and validation right and the rest is polish.
Single column wins. A single top-to-bottom column has one unambiguous reading path; multi-column forms force the eye to zig-zag, double completion time, and routinely get fields skipped or mis-paired. The only sanctioned exceptions sit inside one logical field and read left-to-right anyway: City / State / ZIP, Expiry / CVC, First / Last. Never split unrelated fields across columns.
| Do | Don't | |---|---| | One vertical column, full-width fields | Two columns of unrelated inputs side by side | | Group Expiry+CVC, City+State+ZIP on one row | Put Name and Phone side by side | | Order fields the way the user thinks (name → email → address → payment) | Random or DB-schema order | | One thing per screen on mobile for long flows | Cram a 15-field form onto one mobile view |
<fieldset> + <legend> for related sets (an address block, a radio group). Section long forms with headings ("Contact", "Shipping", "Payment") and visual breaks so it reads as chunks, not a wall.The label is the contract. It must be visible, persistent, programmatically associated, and clickable.
html<!-- Always: real <label for> tied to input id. Clicking the label focuses the field. --> <label for="email">Email address</label> <input id="email" name="email" type="email" autocomplete="email" />
| Placement | Pros | Cons | Use when | |---|---|---|---| | Top (default) | Fastest scan, single eye-path, wraps on mobile, most room | Slightly taller form | Almost always — the safe default | | Left / inline | Compact vertical height | Slow eye-path, fragile RTL/i18n, weak on mobile | Dense desktop settings, short label set | | Floating | Compact at rest, label persists once filled | Tiny when floated, animation distracts, empty-state reads like placeholder | Space-constrained, when done carefully |
*. If you use *, define it and add aria-label="required" / required.aria-describedby so AT reads it.aria-live="polite"), shown as remaining ("28 left"), and never the sole signal — pair with maxlength only when a hard cap is real (don't cap names/addresses).The single highest-leverage detail: pick the type + inputmode + autocomplete that summons the right keyboard, enables autofill, and gives free validation. Critical rule: numeric strings are type="text" inputmode="numeric" + pattern, NOT type="number". type="number" is only for true quantities you'd do math on (with a stepper) — its spinner, scroll-to-mutate, and leading-zero stripping corrupt OTPs, card numbers, ZIPs, and PINs.
| Data | type | inputmode | autocomplete | Notes | |---|---|---|---|---| | Free text | text | — | name/off | default | | Full name | text | — | name | also given-name / family-name | | Email | email | email | email | @-key keyboard, format validation | | Phone | tel | tel | tel | telephone keypad | | URL | url | url | url | / .com keys | | Numeric quantity | number | numeric | — | true number, with stepper | | OTP / ZIP / PIN / card | text | numeric | one-time-code / postal-code / cc-number | never type=number; add pattern="[0-9]*" | | Decimal (price) | text | decimal | — | decimal-point keypad | | Search | search | search | — | "Search" return key, clear affordance | | Date | date | — | bday | native picker; or 3 selects if range is wide | | Password | password | — | current-password / new-password | reveal toggle; new-password triggers manager generation | | Username | text | — | username | passkey-aware (§5) |
html<!-- One-time code: text + numeric keypad + SMS autofill, no number spinner --> <label for="otp">Verification code</label> <input id="otp" name="otp" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" />
Autocomplete tokens are a fixed vocabulary — don't invent values. Common ones: name given-name family-name email tel username current-password new-password one-time-code street-address address-line1 postal-code country cc-name cc-number cc-exp cc-csc bday. Correct tokens unlock browser/OS autofill — a massive speed and accuracy win, especially on mobile.
Right control for the data — match cardinality:
| Choices | Control | |---|---| | 2 mutually exclusive (on/off, immediate effect) | Toggle/switch | | 2–5, pick one, all visible | Radio group | | Many (>5), pick one | Select / combobox (combobox once ~8+, to allow typeahead) | | Multi-select, few | Checkbox group | | Bounded number, fine adjust | Stepper (small range) or Slider (imprecise/visual) | | Single binary opt-in (terms) | Single checkbox |
Toggle = instant state change (saves on flip). Checkbox = a value submitted with the form. Don't use a toggle where the user must still press Save.
This is where forms are won or lost. Validate to help, not to punish. Two anti-patterns dominate: punishing-while-typing (red errors on the first keystroke) and preventing-then-yelling (a disabled submit that won't say why).
Timing:
Use :user-invalid / :user-valid, not :invalid. :invalid matches on page load — an empty required field is "invalid" before the user has touched anything, lighting your whole form red. :user-invalid fires only after the user has interacted and blurred, which is exactly blur-timed validation for free, no JS.
cssinput:user-invalid { border-color: var(--red-8); box-shadow: 0 0 0 1px var(--red-8); } input:user-valid { border-color: var(--green-8); }
Error message rules:
html<div class="field" data-invalid> <label for="pw">Password</label> <input id="pw" type="password" autocomplete="new-password" aria-invalid="true" aria-describedby="pw-help pw-err" required /> <p id="pw-help" class="help">At least 12 characters.</p> <p id="pw-err" class="error" role="alert">Password must be at least 12 characters — you entered 8.</p> </div>
html<!-- Error summary: focus this on submit failure --> <div role="alert" tabindex="-1" id="form-errors"> <h2>There are 2 problems</h2> <ul> <li><a href="#email">Enter a valid email address</a></li> <li><a href="#pw">Password must be at least 12 characters</a></li> </ul> </div>
autocomplete tokens (§3) let browsers/password managers fill name, address, payment, and OTP in one tap. This is the biggest mobile speed win available — never break it with custom widgets that hide the real <input>.autocomplete="username webauthn" to surface passkeys in the autofill (conditional-UI) menu alongside saved passwords. Offer passkey enrollment on account creation; it eliminates the password field entirely.A data table is a form for many records. The same discipline applies: right control per column, scannable alignment, and clear states.
| Column type | Alignment | Notes | |---|---|---| | Numbers / currency / % | Right | tabular figures (font-variant-numeric: tabular-nums), consistent decimals | | Text / names | Left | truncate with tooltip, don't wrap chaotically | | Dates | Left (or right if comparing) | one consistent format, relative + absolute on hover | | Status / tags | Left | icon + text chip, not color alone | | Actions | Right | icon buttons or a row menu (⋯) |
position: sticky; top: 0 on <thead>.<th> is a <button> with aria-sort="ascending|descending|none"; show the active sort glyph. Provide column filters and/or a global search for large sets; show active-filter chips with clear-all.| Requirement | How | |---|---| | Programmatic label | <label for>/id, or aria-label/aria-labelledby when no visible label | | Grouped controls | <fieldset> + <legend> for radio/checkbox groups and field clusters | | Error association | aria-invalid="true" + aria-describedby pointing at the error id (and helper id) | | Error summary | container with role="alert" tabindex="-1"; move focus to it on submit failure; links jump to fields | | Required | native required (+ visible "required"/"optional" text, not * alone) | | Autofill | correct autocomplete tokens (also an a11y win — less typing) | | Target size | ≥44×44px (iOS) / 48×48dp (Android); ≥24px WCAG 2.2 minimum | | Live feedback | aria-live="polite" for char counts/availability; role="alert" for errors |
aria-describedby can list multiple ids (aria-describedby="pw-help pw-err") — AT reads helper and error. Don't remove the label when an error appears; replace/augment the helper, keep the label.
type + inputmode (§3) — the email keyboard, number pad, URL keys. This alone removes huge friction.autofocus with caution: auto-focusing the first field on mobile pops the keyboard and can hide context/headings. Fine on a single-purpose screen (search, OTP); avoid on dense forms.| Don't | Do | |---|---| | Placeholder text as the label | Persistent <label for>; placeholder = optional hint only | | Multi-column layout of unrelated fields | Single column; group only same-row sub-fields | | :invalid styling (red on page load) | :user-invalid (only after interaction + blur) | | Validate aggressively on every keystroke | Validate on blur; forgive on input once fixed | | Vague errors ("Invalid input") | Specific + actionable, name field + rule + fix, at field + summary | | Disabled submit button to enforce validity | Keep enabled; validate on click, focus first error | | type="number" for OTP/ZIP/card/PIN | type="text" inputmode="numeric" + pattern | | Custom widgets that hide the real <input> | Real inputs with autocomplete tokens — keep autofill alive | | Clearing all fields when one errors | Preserve every entered value; only flag the bad one | | Ask for everything up front | Ask the minimum; derive/disclose progressively | | No saved state in multi-step wizard | Persist answers; working Back button; save partial progress | | Reset/Clear button next to Submit | Drop it — accidental data loss, almost never wanted | | Color-only error/status signal | Icon + text + color | | Centered spinner that shifts table layout | Skeleton rows matching the column layout | | Conflating "empty" and "no filter results" | Distinct states: empty (add first) vs no-results (clear filters) |
Checklist before "form done": single column · every field has a visible persistent <label for> · optional/required marked the right way · correct type+inputmode+autocomplete per field · numeric strings are text+inputmode not number · blur-timed validation via :user-invalid · errors specific + at field + summary with focus moved · submit not disabled to enforce validity · values preserved on error · autofill works · multi-step persists state · tables have loading/empty/error/partial + sticky header + right-aligned numbers · targets ≥44/48px · keyboard- and screen-reader-operable.
Other measured skills in the registry, with their headline benchmark lift.