Form UX Design skill for Claude Code
Fewer fields, clearer errors, more finished forms. Claude designs the form before anyone codes it. Every field must justify itself or get cut, deferred or inferred. It then sets layout, controls, input types and autocomplete tokens, validation timing, error placement and wording, and decides between one page and steps with a check-answers page. A bundled auditor flags missing labels, autofill gaps, number-type misuse and blocked paste in rendered HTML.
- name
form-ux-design - size240 lines · ≈3,200 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs forms people finish — cutting fields with a question protocol, single-column layout with top-aligned labels, optional-not-required marking, input types, inputmode and autocomplete tokens, inline validation timing, error message placement and an error summary, one-page versus multi-step wizards with progress and a check-answers page, mobile keyboards and touch targets, and WCAG 2.2 form criteria. Ships an HTML form auditor. Use when designing or reviewing a sign-up, checkout, onboarding, application or settings form, when completion or conversion is low, when users hit errors repeatedly, or when deciding between a wizard and a single page. Try asking
- Our sign-up form converts badly. Review it and tell me what to cut and change
- Design the checkout flow: one page or steps, and which fields we actually need
- Write the field spec for this insurance application form, including all error messages
Use it when
- Designing or reviewing sign-up, checkout, onboarding, application or settings forms
- Form completion is low or users hit the same errors repeatedly
- Deciding between a single page and a multi-step wizard
- Autofill, mobile keyboards or error messages behave badly
Skip it when
- You need schemas, server validation and form library wiring — use forms-validation instead
- You are writing interface copy across the whole product — use ux-microcopy instead
How to check the result
The audit script reports no errors on the rendered form, browser autofill fills every known field in one action, and an empty submit followed by fixing one field at a time shows, links and clears each error without losing input.
The common mistake it prevents
Polishing the layout and error styling of fields that should not be asked at all; run the question protocol before designing anything.
A good form asks only what is needed, in the order people expect, in a way their browser can fill for them, and tells them exactly how to fix a mistake without losing what they typed. Done well means: every field has a written reason to exist, the form scans in one column, autofill completes the known fields, errors appear at the right moment in the right place, and completion is measured per field.
This skill owns design decisions. Implementation (schemas, server validation, React Hook Form) belongs to forms-validation; product-wide wording belongs to ux-microcopy.
Workflow
- [ ] 1. Inventory fields and run the question protocol; cut, defer or infer
- [ ] 2. Decide structure: single page or steps; order and grouping
- [ ] 3. Lay out: one column, top labels, hints, field widths, one primary action
- [ ] 4. Specify each field: control, type, inputmode, autocomplete, optional marking
- [ ] 5. Specify validation timing and every error message
- [ ] 6. Check mobile and accessibility rules
- [ ] 7. Audit the built HTML (scripts/audit_form.py) and test with real users or analytics
1. Question protocol (cut fields first)
For every field, answer in the field spec:
| Question | If the answer is weak |
|---|---|
| Who uses this answer, and for what? | Remove the field |
| What happens if we do not ask? | Remove or make optional |
| Is it needed now, or later? | Defer to after sign-up or to the moment it is needed |
| Can we infer, look up, or default it? | Infer (country from locale as a default, card type from number) — never force the inferred value |
| Is the answer reliable when asked this way? | Change the question (free text instead of a guessed list) |
- Ask one full-name field instead of first and last unless a system truly needs them split; many names do not fit a first/last model.
- Remove confirmation fields (confirm email, confirm password); offer show-password and a summary review instead.
- Never ask for what you already have (WCAG 2.2 SC 3.3.7 Redundant Entry): prefill, or offer “same as shipping address”.
2. Structure: one page or steps
| Situation | Choose |
|---|---|
| One topic, fits roughly one or two screens, no branching | Single page |
| Distinct topics (account, address, payment) or many fields | Steps, one topic per step |
| Branching questions (answers change later questions) | Steps; “one thing per page” for complex or infrequent tasks |
| Expert users repeating the task daily | Denser single page with sections; research before splitting |
Multi-step rules:
- Show progress as text plus step names (“Step 2 of 4: Delivery”). For branching flows, where the count changes, show section names without a fixed count.
- Name the button “Continue” on intermediate steps and the real action on the last (“Pay £24.00”, “Submit application”).
- Back keeps every answer. Refresh and returning later keep progress (server draft or storage) for long forms.
- End with a check-your-answers page listing each answer with a “Change” link that returns to the field and back to the summary.
- Put the easiest, least sensitive questions first; ask for payment and personal data once the user is committed.
3. Layout
- One column. Exceptions: short related fields that are read as one unit (city / state / postcode, card expiry and CVC) may share a row on wide screens; stack them on narrow.
- Labels above fields: fastest to scan, survive long translations and zoom, work on mobile. Left-aligned labels only for dense expert forms on desktop.
- Hint text sits under the label and above the input, so it is read before typing and is not covered by autofill dropdowns or the keyboard. Put format examples there (“For example, 27 3 2007”).
- Field width suggests the expected length (postcode short, street long); do not stretch every input to full width on desktop.
- Group related fields under a heading or
fieldset/legend(“Delivery address”). - One primary button, left-aligned under the last field, with a verb label. No “Reset” or “Clear” buttons. Secondary actions (Cancel, Save draft) look secondary.
- Never use the placeholder as the label; it disappears on input and usually fails contrast. Floating labels shrink to small text — prefer a static label.
4. Field specification
Marking: mark optional fields with “(optional)” in the label (in the legend for radio or checkbox groups). Do not rely on asterisks; if most fields are optional, question why the required ones are not the only ones.
Choose the control:
| Answer shape | Control |
|---|---|
| One of 2 to about 6 options | Radios, all visible (not a select) |
| One of many known options (country) | Select, or a combobox with type-ahead for very long lists |
| Several of a few options | Checkboxes in a fieldset |
| Yes/no consent | Single checkbox with the full statement as the label |
| Precise number | Text input (not a slider) |
| Memorable date (birth date) | Three text inputs: day, month, year |
| Near-future date (booking) | Date picker, with typed entry still allowed |
| Long free text | Textarea sized to the expected length, with a character count if limited |
Types, keyboards, and autofill:
| Field | Markup |
|---|---|
type="email" autocomplete="email" autocapitalize="off" spellcheck="false" | |
| Full name | type="text" autocomplete="name" (or given-name / family-name) |
| Phone | type="tel" autocomplete="tel"; accept spaces, dashes, +; do not force a format |
| Postal code | type="text" autocomplete="postal-code"; inputmode="numeric" only where codes are digits |
| Street address | autocomplete="address-line1" / address-line2; lookup plus “Enter address manually” |
| City / region / country | address-level2 / address-level1 / country or country-name |
| Card number | type="text" inputmode="numeric" autocomplete="cc-number"; allow spaces |
| Expiry | autocomplete="cc-exp" as one MM/YY field (or cc-exp-month + cc-exp-year) |
| CVC | type="text" inputmode="numeric" autocomplete="cc-csc" |
| Sign-up password | type="password" autocomplete="new-password" + show-password toggle + visible rules |
| Sign-in | autocomplete="username" (add webauthn for passkeys) and current-password |
| One-time code | type="text" inputmode="numeric" autocomplete="one-time-code", one input (or split boxes that accept paste of the full code) |
| Quantity, age | type="text" inputmode="numeric" with validation; type="number" only for true spinner values |
- Never
type="number"for codes, card numbers, phone or postcodes: it drops leading zeros, acceptse, and changes on scroll. - Separate sections that repeat (shipping vs billing) with
shipping/billingprefixes so autofill fills the right one. - Use
enterkeyhint="next"on intermediate fields of a step and"done"or"go"on the last for a clearer mobile keyboard.
5. Validation timing and errors
Default (“reward early, punish late”):
| Moment | Behavior |
|---|---|
| First time typing in a field | No error |
| Leaving a field with a definite problem (bad email format) | Show that field’s error |
| Editing a field that shows an error | Re-check on each change; remove the error as soon as it is fixed |
| Submit or Continue | Validate all; show error summary; move focus to it |
| Rules the user must meet while typing (password rules) | Neutral live checklist, not red errors |
| Server checks (username taken) | On blur, with a visible “Checking…” state |
For short public-sector-style forms, validating only on submit is a valid simpler choice (GOV.UK’s pattern). Never validate on every keystroke from the first character.
Placement and presentation:
- Error message directly above the input, after the label and hint, so it is visible with the keyboard open and read before the field by screen readers. Keep the same position everywhere.
- Error summary at the top on submit: heading “There is a problem”, one link per error
pointing to the field, same text as the inline message. Prefix the page
<title>with “Error: ” so screen readers announce it early. - Icon plus text plus color; never color alone (SC 1.4.1). Thicker border on the field.
- Keep every value the user entered, valid or not.
- Keep the submit button enabled; a disabled button gives no reason.
Message formula: what is wrong plus how to fix it, specific to the cause, in the label’s words. “Enter your date of birth”, “Enter a postcode, like SW1A 1AA”, “Date of birth must be in the past”. Never “Invalid input”, “Error”, or blame. Write one message per failure type per field in the spec. For a product-wide voice and glossary, use ux-microcopy.
6. Mobile and accessibility rules
- Input font size at least 16 px, or iOS Safari zooms the page on focus.
- Touch targets: WCAG 2.2 SC 2.5.8 requires at least 24×24 CSS px (or spacing); aim for platform sizes on touch-first products (44 pt iOS, 48 dp Android).
- Fixed footers and banners must not cover the focused field or the submit button when the keyboard is open (SC 2.4.11 Focus Not Obscured).
- WCAG criteria to check: 1.3.1 (fieldset/legend, label associations), 1.3.5 Identify Input Purpose (autocomplete tokens), 3.3.1 Error Identification, 3.3.2 Labels or Instructions, 3.3.3 Error Suggestion, 3.3.7 Redundant Entry, 3.3.8 Accessible Authentication (allow paste and password managers; no puzzle-only checks), 2.2.1 Timing Adjustable (warn before session timeouts and allow extension).
- Never block paste. Never disable autofill on fields autofill can fill.
7. Audit and measure
Run the auditor on rendered HTML (save the page, or copy outerHTML from DevTools for
client-rendered apps):
python3 scripts/audit_form.py checkout.html
python3 scripts/audit_form.py signup.html --max-fields 6 --strict
It counts questions per form and flags missing labels, placeholder-only labels, missing
or disabled autocomplete tokens on recognizable fields, type="number" misuse, blocked
paste, asterisk-only marking, radio groups without a fieldset and legend, two-option
selects, and disabled submit buttons. It exits 1 on errors (or warnings with --strict).
It reads markup only: judge wording, order and validation timing yourself.
Measure after launch: completion rate per step, drop-off per field, error rate per field, and time per field from form analytics. The field with the highest error rate is the next design fix. Before launch, run a small moderated usability round on the spec or prototype to catch questions people misread.
Gotchas
- Splitting a short form into many steps adds clicks without reducing effort; split by topic, not by field count alone.
- Address autocomplete with no manual entry fallback blocks users whose address is not in the database.
- Country-specific formats (postcodes, phone, names) break with US-only patterns; let the country choice drive the rest of the address fields.
- Inline success ticks on every field add noise; reserve them for checks the user cannot judge (username availability).
- Hiding requirements until an error (“Password must contain…”) makes users fail first; state rules up front in the hint.
- Masks that insert characters as you type break paste, autofill, and caret position; format on blur or display formatting without altering the stored value.
Verification
- Every field in the spec has a reason, owner and error messages; removed fields are listed with the reason.
scripts/audit_form.pyreports no errors on the rendered page.- Browser autofill (Chrome and Safari, saved profile) fills name, email, address, phone and card fields correctly in one action.
- On a phone: correct keyboard per field, no zoom on focus, submit reachable with the keyboard open.
- Submit empty, then fix one field at a time: errors appear on submit, clear as each field is fixed, the summary links move focus to the field, and input is never lost.
- Keyboard and screen reader pass: labels, hints, optional marking and errors are announced for each field.
Output contract
## Form spec — <form name>
**Goal:** <what completing it achieves> **Structure:** single page | N steps (<names>)
| # | Question (label) | Hint | Control / type / inputmode | autocomplete | Optional | Errors (per failure) | Why we ask |
|---|---|---|---|---|---|---|---|
| 1 | Email address | We'll send your receipt here | text input / email | email | no | Empty: "Enter your email address"; Format: "Enter an email address like [email protected]" | Receipt and account |
**Removed or deferred:** Company (deferred to profile), Confirm email (removed)
**Validation timing:** on blur after first edit; summary on submit
**Measures:** completion per step, error rate per field Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/audit_form.py
#!/usr/bin/env python3
"""Audit the forms in rendered HTML for common form-UX defects.
Checks each visible field for: a programmatic label, placeholder used as the only
label, a missing or disabled autocomplete token on fields whose purpose is
recognizable (email, name, address, phone, card, password, one-time code), wrong
input types (type="number" for phone, postal code or card data; text for email),
blocked paste, asterisk-only required marking, radio/checkbox groups without a
fieldset and legend, tiny selects that should be radios, and disabled submit buttons.
Usage:
python3 audit_form.py <file.html> [--max-fields N] [--strict]
--max-fields N also report an error when a form asks more than N questions
(a radio or checkbox group counts as one question)
--strict exit 1 on warnings as well as errors
Save rendered HTML first (View Source of the live page, or
`curl -s URL > page.html`; for client-rendered apps copy document.documentElement.outerHTML).
Exit codes: 0 clean, 1 findings at the failing level, 2 unreadable input.
Standard library only.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
SKIP_TYPES = {"hidden", "submit", "button", "reset", "image"}
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
# (pattern on name/id/label, expected autocomplete token, expected type or None)
PURPOSES: list[tuple[str, str, str | None]] = [
(r"e-?mail", "email", "email"),
(r"one.?time|otp|verification.?code|\bsms.?code", "one-time-code", None),
(r"(new|confirm|repeat).?pass", "new-password", "password"),
(r"pass(word|wd)?\b|\bpwd\b", "current-password", "password"),
(r"user.?name|\blogin\b", "username", None),
(r"first.?name|given.?name|\bfname\b|forename", "given-name", None),
(r"last.?name|family.?name|surname|\blname\b", "family-name", None),
(r"full.?name|^name$|your.?name", "name", None),
(r"phone|mobile|\btel\b|telephone", "tel", "tel"),
(r"zip|postal|post.?code", "postal-code", None),
(r"address.?(line)?.?2|addr2|apartment|suite", "address-line2", None),
(r"address.?(line)?.?1|addr1|street", "address-line1", None),
(r"\bcity\b|\btown\b", "address-level2", None),
(r"\bstate\b|province|county|region", "address-level1", None),
(r"country", "country", None),
(r"card.?num|cc.?num|\bpan\b|credit.?card", "cc-number", None),
(r"cvc|cvv|csc|security.?code", "cc-csc", None),
(r"expir|\bexp\b|cc.?exp", "cc-exp", None),
(r"name.?on.?card|card.?holder|cc.?name", "cc-name", None),
(r"birth|\bdob\b|bday", "bday", None),
(r"company|organi[sz]ation|employer", "organization", None),
]
NUMBER_IS_WRONG = {"tel", "postal-code", "cc-number", "cc-csc", "cc-exp", "one-time-code"}
@dataclass
class Field:
tag: str
attrs: dict[str, str]
line: int
label_text: str = ""
in_label: bool = False
in_fieldset_with_legend: bool = False
@dataclass
class Form:
line: int
fields: list[Field] = field(default_factory=list)
submits: list[tuple[dict[str, str], int]] = field(default_factory=list)
selects: list[tuple[Field, int]] = field(default_factory=list)
class Collector(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.forms: list[Form] = []
self.current: Form | None = None
self.labels_for: dict[str, str] = {}
self.label_stack: list[dict] = []
self.ids_text: dict[str, str] = {}
self.text_capture: list[tuple[str, str, list[str]]] = []
self.fieldsets: list[dict] = []
self.select: Field | None = None
self.option_count = 0
self.button: tuple[dict[str, str], int] | None = None
def _form(self) -> Form:
if self.current is None: # fields outside any <form> are grouped as a pseudo-form
self.current = Form(line=self.getpos()[0])
self.forms.append(self.current)
return self.current
def handle_starttag(self, tag, attrs_list):
a = {k: (v or "") for k, v in attrs_list}
line = self.getpos()[0]
if "id" in a and tag not in VOID:
self.text_capture.append((tag, a["id"], []))
if tag == "form":
self.current = Form(line=line)
self.forms.append(self.current)
elif tag == "label":
self.label_stack.append({"for": a.get("for"), "text": [], "fields": []})
elif tag == "fieldset":
self.fieldsets.append({"legend": False})
elif tag == "legend" and self.fieldsets:
self.fieldsets[-1]["legend"] = True
elif tag in ("input", "textarea", "select"):
t = a.get("type", "text").lower() if tag == "input" else tag
if tag == "input" and t in ("submit", "image"):
self._form().submits.append((a, line))
return
if tag == "input" and t in SKIP_TYPES:
return
f = Field(tag=tag, attrs=a, line=line)
f.in_fieldset_with_legend = any(fs["legend"] for fs in self.fieldsets)
if self.label_stack:
f.in_label = True
self.label_stack[-1]["fields"].append(f)
self._form().fields.append(f)
if tag == "select":
self.select, self.option_count = f, 0
elif tag == "option" and self.select is not None:
self.option_count += 1
elif tag == "button":
t = a.get("type", "submit").lower()
if t == "submit":
self._form().submits.append((a, line))
def handle_endtag(self, tag):
if tag == "form":
self.current = None
elif tag == "label" and self.label_stack:
lab = self.label_stack.pop()
text = " ".join("".join(lab["text"]).split())
if lab["for"]:
self.labels_for[lab["for"]] = text
for f in lab["fields"]:
f.label_text = text
elif tag == "fieldset" and self.fieldsets:
self.fieldsets.pop()
elif tag == "select" and self.select is not None:
self._form().selects.append((self.select, self.option_count))
self.select = None
if self.text_capture and self.text_capture[-1][0] == tag:
_, ident, buf = self.text_capture.pop()
self.ids_text[ident] = " ".join("".join(buf).split())
def handle_data(self, data):
for lab in self.label_stack:
lab["text"].append(data)
for _, _, buf in self.text_capture:
buf.append(data)
def purpose_of(f: Field, label: str) -> tuple[str, str | None] | None:
hay = " ".join([f.attrs.get("name", ""), f.attrs.get("id", ""), label]).lower()
hay = re.sub(r"[_\-\[\]]", " ", hay)
for pattern, token, typ in PURPOSES:
if re.search(pattern, hay):
return token, typ
return None
def audit(path: Path, max_fields: int | None) -> tuple[list[str], list[str]]:
html = path.read_text(encoding="utf-8", errors="replace")
c = Collector()
c.feed(html)
errors: list[str] = []
warns: list[str] = []
groups: dict[str, list[Field]] = {}
for n, form in enumerate(c.forms, 1):
tag = f"form {n} (line {form.line})"
visible = form.fields
grouped = {f.attrs.get("name") or id(f) for f in visible
if f.tag == "input" and f.attrs.get("type", "").lower() in ("radio", "checkbox")}
singles = [f for f in visible
if not (f.tag == "input" and f.attrs.get("type", "").lower() in ("radio", "checkbox"))]
questions = len(singles) + len(grouped)
print(f"{tag}: {questions} questions ({len(visible)} controls)")
if max_fields is not None and questions > max_fields:
errors.append(f"{tag}: {questions} questions exceeds --max-fields {max_fields}; justify or remove fields")
for f in visible:
a = f.attrs
ident = a.get("name") or a.get("id") or f.tag
where = f"{tag}, field '{ident}' (line {f.line})"
label = f.label_text or c.labels_for.get(a.get("id", ""), "")
if not label and a.get("aria-labelledby"):
label = " ".join(c.ids_text.get(i, "") for i in a["aria-labelledby"].split()).strip()
if not label:
label = a.get("aria-label", "")
itype = a.get("type", "text").lower() if f.tag == "input" else f.tag
if itype in ("radio", "checkbox"):
if a.get("name"):
groups.setdefault(f"{tag}|{a['name']}", []).append(f)
if not label:
errors.append(f"{where}: {itype} has no label")
continue
if not label:
if a.get("placeholder"):
errors.append(f"{where}: placeholder \"{a['placeholder']}\" is the only label; add a visible <label>")
else:
errors.append(f"{where}: no programmatic label (label[for], wrapping label, aria-labelledby)")
if "*" in label and "required" not in label.lower():
warns.append(f"{where}: asterisk in label; mark optional fields with \"(optional)\" instead, or explain the asterisk once")
if re.search(r"return\s+false|preventDefault", a.get("onpaste", "")):
errors.append(f"{where}: paste is blocked")
p = purpose_of(f, label)
ac = a.get("autocomplete", "").strip().lower()
if p:
token, want_type = p
if not ac:
warns.append(f"{where}: add autocomplete=\"{token}\" (WCAG 1.3.5, autofill)")
elif ac in ("off", "nope", "false", "disabled"):
warns.append(f"{where}: autocomplete=\"{ac}\" blocks autofill; use \"{token}\"")
elif token not in ac.split() and not (token.endswith("password") and "password" in ac):
warns.append(f"{where}: autocomplete=\"{ac}\" — expected a token like \"{token}\"; check the purpose")
if itype == "number" and token in NUMBER_IS_WRONG:
errors.append(f"{where}: type=\"number\" for {token}; use type=\"text\" inputmode=\"numeric\" (or type=\"tel\" for phone)")
if want_type == "email" and itype not in ("email",):
warns.append(f"{where}: use type=\"email\" for the email keyboard and autofill")
if want_type == "tel" and itype not in ("tel",):
warns.append(f"{where}: use type=\"tel\" for the phone keypad")
if token == "new-password" and re.search(r"confirm|repeat|again", (ident + label).lower()):
warns.append(f"{where}: confirm-password field; a show-password toggle usually replaces it")
for sel, count in form.selects:
ident = sel.attrs.get("name") or sel.attrs.get("id") or "select"
real = count - (1 if count > 0 else 0) # allow one placeholder option
if 0 < real <= 2:
warns.append(f"{tag}, select '{ident}' (line {sel.line}): {real} real options; radios show the choice without a click")
if not form.submits:
warns.append(f"{tag}: no submit button found")
for attrs, line in form.submits:
if "disabled" in attrs:
warns.append(f"{tag}, submit (line {line}): disabled submit hides why the form cannot be sent; keep it enabled and show errors")
for key, fields in groups.items():
if len(fields) > 1 and not all(f.in_fieldset_with_legend for f in fields):
tag, name = key.split("|", 1)
warns.append(f"{tag}: group '{name}' ({len(fields)} options) needs <fieldset> with <legend> holding the question")
if not c.forms:
warns.append("no form fields found — is this the rendered HTML?")
return errors, warns
def main() -> int:
args = sys.argv[1:]
if not args or args[0] in ("-h", "--help"):
print(__doc__)
return 2
strict = "--strict" in args
max_fields = None
if "--max-fields" in args:
i = args.index("--max-fields")
try:
max_fields = int(args[i + 1])
except (IndexError, ValueError):
print("error: --max-fields needs an integer", file=sys.stderr)
return 2
path = Path(args[0])
if not path.is_file():
print(f"error: cannot read {path}", file=sys.stderr)
return 2
errors, warns = audit(path, max_fields)
for e in errors:
print(f"ERROR {e}")
for w in warns:
print(f"WARN {w}")
print(f"\n{len(errors)} error(s), {len(warns)} warning(s)")
if errors or (strict and warns):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.