Skip to content

Form Design Playbook

Forms are the conversion gatekeepers of the web. A well-designed form respects users’ time, reduces cognitive load, and builds trust. A poorly designed form causes frustration, abandonment, and support tickets.

This playbook provides both human-readable guidance and machine-parseable specifications for AI agents implementing form functionality.


Quick Reference: Form Design Specifications

Section titled “Quick Reference: Form Design Specifications”
ElementMinimum SizeRecommendedWCAG Reference
Touch target (buttons, inputs)24×24 CSS px44×44 CSS px2.5.5, 2.5.8
Input height36px44-48px
Font size (labels, inputs)16px16-18px1.4.4
Tap spacing between targets8px12px+2.5.8
ElementMinimum RatioWCAG Reference
Label text4.5:11.4.3
Input text4.5:11.4.3
Placeholder text4.5:11.4.3
Input borders3:11.4.11
Error text4.5:11.4.3
Focus indicator3:12.4.7
Field TypeAutocomplete Value
Full namename
First namegiven-name
Last namefamily-name
Emailemail
Phonetel
Street addressstreet-address
Cityaddress-level2
State/Provinceaddress-level1
Postal codepostal-code
Countrycountry-name
Credit card numbercc-number
Card expirationcc-exp
Card CVVcc-csc
Usernameusername
Current passwordcurrent-password
New passwordnew-password
One-time codeone-time-code

rules:
# Label Requirements
- id: form-label-visible
severity: error
check: "Every input has a visible, persistent label (not placeholder-only)"
selector: "input, select, textarea"
wcag: "1.3.1, 3.3.2 AA"
- id: form-label-associated
severity: error
check: "Labels are programmatically associated via for/id or wrapping"
selector: "label, input, select, textarea"
wcag: "1.3.1 AA"
- id: form-label-position
severity: warning
check: "Labels positioned above or to the left of inputs (not inside)"
selector: "label"
best_practice: true
# Input Configuration
- id: form-input-type
severity: warning
check: "Input uses appropriate type (email, tel, number, url, date)"
selector: "input"
wcag: "1.3.5 AA"
- id: form-autocomplete
severity: error
check: "Inputs for personal data have autocomplete attribute"
selector: "input[name*='name'], input[name*='email'], input[name*='phone'], input[name*='address']"
wcag: "1.3.5 AA"
- id: form-inputmode
severity: warning
check: "Numeric inputs use inputmode='numeric' or 'decimal'"
selector: "input[type='text']:has-numeric-content"
best_practice: true
# Error Handling
- id: form-error-association
severity: error
check: "Error messages linked to inputs via aria-describedby"
selector: ".error-message, [role='alert']"
wcag: "3.3.1 AA"
- id: form-error-visible
severity: error
check: "Errors use text, not color alone"
selector: "[aria-invalid='true']"
wcag: "1.4.1 AA"
- id: form-error-focus
severity: error
check: "First error field receives focus on submit"
selector: "form"
wcag: "3.3.1 AA"
# Required Fields
- id: form-required-indicated
severity: error
check: "Required fields marked visually AND with aria-required or required"
selector: "input[required], input[aria-required='true']"
wcag: "3.3.2 AA"
# Touch Targets
- id: form-target-size
severity: error
check: "Submit buttons ≥24×24 CSS px (44×44 preferred)"
selector: "button[type='submit'], input[type='submit']"
wcag: "2.5.5, 2.5.8 AA"
# Focus Management
- id: form-focus-visible
severity: error
check: "All form controls have visible focus indicator"
selector: "input:focus, select:focus, textarea:focus, button:focus"
wcag: "2.4.7 AA"
- id: form-focus-order
severity: error
check: "Tab order follows visual order"
selector: "form"
wcag: "2.4.3 AA"

Before designing fields, answer these questions:

WHAT is the user trying to accomplish?
WHY do we need each piece of information?
WHEN do we need it? (Now vs. later)
WHAT is the minimum viable form?

Every field you add has a cost:

FieldsCompletion Rate Impact
3 fieldsBaseline
4 fields-3% to -5%
5-6 fields-10% to -15%
7+ fields-20% or more

Research insight: Expedia increased profits by $12 million by removing one redundant field from their booking form.

FOR each field:
IF field is required by law/regulation:
KEEP (mark as required)
ELSE IF field is essential for the transaction:
KEEP
ELSE IF field can be derived from other data:
REMOVE (auto-populate later)
ELSE IF field can be asked later:
DEFER to post-completion
ELSE IF field is "nice to have":
REMOVE
RESULT: Minimum viable form
Data TypeConsiderations
PII (name, email)Privacy policy link, GDPR basis
Financial (card, bank)PCI compliance, security indicators
HealthHIPAA compliance (US), special category (GDPR)
LocationClear purpose explanation

Baymard Institute research shows:

  • 16% of sites use multi-column forms that cause abandonment
  • Users complete single-column forms 15.4 seconds faster
  • Multi-column layouts cause field-skipping errors

Exception: Related fields can be inline:

  • First name + Last name
  • City + State + ZIP
  • Card number + Expiration + CVV
<fieldset>
<legend>Contact Information</legend>
<!-- Name, email, phone fields -->
</fieldset>
<fieldset>
<legend>Shipping Address</legend>
<!-- Address fields -->
</fieldset>
  1. Identity: Who is this? (name, email)
  2. Details: What do they need? (product, quantity, options)
  3. Fulfillment: How to deliver? (address, shipping)
  4. Payment: How to pay? (card, billing)
  5. Confirmation: Is this correct? (review, submit)

When to use multi-step:

  • More than 8-10 fields
  • Multiple distinct categories
  • Legal/compliance requires separation
  • Complex conditional logic

Multi-step requirements:

  • Clear progress indicator with step labels
  • Back button on every step (except first)
  • Persist data between steps
  • Allow review before final submit
<nav aria-label="Checkout progress">
<ol>
<li aria-current="step">1. Contact</li>
<li>2. Shipping</li>
<li>3. Payment</li>
<li>4. Review</li>
</ol>
</nav>

<!-- Correct: Visible label -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="email">
<!-- Wrong: Placeholder as label -->
<input type="email" placeholder="Email address">

Why placeholders fail:

  • Disappear on focus (memory load)
  • Often low contrast (accessibility)
  • No persistent reference for validation
  • Confuses password managers
PositionUse CaseNotes
Above inputDefault, best for scanningWorks for all field types
Left of inputCompact horizontal formsAlign labels right for clean edge
FloatingLimited spaceEnsure contrast when floated
<label for="password">Password</label>
<input type="password" id="password"
aria-describedby="password-hint">
<p id="password-hint" class="hint">
At least 8 characters with one number and one symbol
</p>
DataTypeInputmodeKeyboard Effect
Emailemail@ key, domain suggestions
PhonetelPhone dial pad
Number (integer)textnumericNumber pad, no spinner
Number (decimal)textdecimalNumber pad with decimal
URLurl/ key, .com suggestions
SearchsearchSearch/enter key
DatedateNative date picker
Credit cardtextnumericNumber pad
Options CountRecommended Control
2 optionsRadio buttons or toggle
3-5 optionsRadio buttons
6-10 optionsDropdown/select
10+ optionsAutocomplete/combobox
Yes/NoCheckbox or toggle
Multiple selectionsCheckboxes or multi-select

<!-- Default country based on locale -->
<select name="country" autocomplete="country-name">
<option value="US" selected>United States</option>
<!-- Other options -->
</select>
<!-- Pre-checked for common preference -->
<input type="checkbox" id="email-optin" checked>
<label for="email-optin">Send me order updates via email</label>
<label for="phone">Phone number</label>
<input type="tel" id="phone"
placeholder="(555) 123-4567"
aria-describedby="phone-format">
<p id="phone-format" class="hint">
Format: (555) 123-4567
</p>
<!-- Credit card with live formatting -->
<label for="card">Card number</label>
<input type="text" id="card" inputmode="numeric"
placeholder="1234 5678 9012 3456"
autocomplete="cc-number">
<!-- Show card type icon based on input -->
<span class="card-type-icon" aria-label="Visa detected"></span>

Important: Don’t block typing with auto-formatting. Let users type freely, format on blur or display separately.


TimingUse CaseUser Experience
On blurMost fieldsImmediate feedback after leaving
On input (debounced)Availability checksReal-time username/email check
On submitFinal validationCatch any missed errors
Never on focusDon’t show errors before user tries
ON field blur:
IF field is required AND empty:
SHOW "This field is required"
ELSE IF field has format constraint:
VALIDATE format
IF invalid: SHOW specific error
ELSE IF field needs server check (email exists, username taken):
DEBOUNCE 300ms, then check
SHOW result
ON form submit:
VALIDATE all fields
IF any errors:
MOVE focus to first error
SHOW error summary at top (anchored links)
PREVENT submission
ELSE:
SUBMIT form
<!-- Field with error -->
<div class="field field--error">
<label for="email">Email address</label>
<input type="email" id="email"
aria-invalid="true"
aria-describedby="email-error">
<p id="email-error" class="error" role="alert">
Please enter a valid email address (e.g., name@example.com)
</p>
</div>
BadGoodWhy
”Invalid input""Please enter a valid email address”Specific, actionable
”Error""Email is required”Identifies the problem
”Format error""Phone must be 10 digits”Explains the fix
”Required""Please enter your name”Conversational, clear
<div role="alert" aria-labelledby="error-summary-title">
<h2 id="error-summary-title">Please fix 2 errors</h2>
<ul>
<li><a href="#email">Email address is invalid</a></li>
<li><a href="#phone">Phone number is required</a></li>
</ul>
</div>
WRONG approach:
User types: 5551234567
System blocks: "Invalid format"
User confused about what's wrong
RIGHT approach:
User types: 5551234567
On blur, normalize: (555) 123-4567
OR display formatted preview separately

<!-- Pattern 1: Explicit label (preferred) -->
<label for="name">Full name</label>
<input type="text" id="name" autocomplete="name">
<!-- Pattern 2: Wrapped label -->
<label>
Full name
<input type="text" autocomplete="name">
</label>
<!-- Pattern 3: aria-labelledby for complex layouts -->
<span id="name-label">Full name</span>
<input type="text" aria-labelledby="name-label" autocomplete="name">
<label for="password">Password</label>
<input type="password" id="password"
aria-describedby="password-hint password-error"
aria-invalid="true">
<p id="password-hint" class="hint">
Minimum 8 characters
</p>
<p id="password-error" class="error" role="alert">
Password must contain at least one number
</p>
/* Ensure visible focus */
input:focus,
select:focus,
textarea:focus,
button:focus {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
/* Never remove focus entirely */
/* Wrong: */
/* :focus { outline: none; } */
<!-- Visual AND programmatic indication -->
<label for="email">
Email address
<span class="required" aria-hidden="true">*</span>
</label>
<input type="email" id="email" required aria-required="true">
<!-- Legend for form explaining asterisk -->
<p class="form-legend">
<span aria-hidden="true">*</span> Required field
</p>
<form aria-labelledby="form-title" novalidate>
<h1 id="form-title">Create an account</h1>
<!-- Error summary location -->
<div id="error-summary" role="alert" aria-live="polite"></div>
<fieldset>
<legend>Personal Information</legend>
<!-- Fields -->
</fieldset>
<button type="submit">Create account</button>
</form>

<label for="ssn">
Social Security Number
<button type="button" aria-label="Why we need this"
data-tooltip="Required for tax reporting per IRS regulations">
<span aria-hidden="true">?</span>
</button>
</label>
<input type="text" id="ssn" inputmode="numeric"
autocomplete="off">
<label for="password">Password</label>
<div class="password-field">
<input type="password" id="password"
autocomplete="new-password"
aria-describedby="password-requirements">
<button type="button"
aria-label="Show password"
onclick="togglePasswordVisibility()">
<span class="icon-eye"></span>
</button>
</div>
<ul id="password-requirements" class="hint">
<li>At least 8 characters</li>
<li>One uppercase letter</li>
<li>One number</li>
</ul>
IndicatorPlacementImpact
Security badgesNear payment fields+29% perceived trust
Privacy policy linkNear submitReduces hesitation
Secure connection (HTTPS)Browser shows thisBaseline expectation
”We’ll never share”Near email fieldReduces friction
// Autosave form data to localStorage
form.addEventListener('input', debounce(() => {
const formData = new FormData(form);
localStorage.setItem('draft-form', JSON.stringify(Object.fromEntries(formData)));
}, 1000));
// Warn before leaving
window.addEventListener('beforeunload', (e) => {
if (formHasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});

<section aria-labelledby="review-title">
<h2 id="review-title">Review your order</h2>
<dl class="review-summary">
<dt>Shipping address</dt>
<dd>
123 Main St, Anytown, ST 12345
<a href="#step-shipping">Edit</a>
</dd>
<dt>Payment method</dt>
<dd>
Visa ending in 4242
<a href="#step-payment">Edit</a>
</dd>
</dl>
<button type="submit">Place order</button>
</section>
ActionConfirmation Type
Order placedConfirmation page + email
Account createdSuccess message + email verification
Form submittedThank you page with next steps
Destructive actionUndo option (time-limited)
  1. Clear success message
  2. Reference number/order ID
  3. Summary of submitted data
  4. Expected next steps and timeline
  5. Contact information for support
  6. Print/save option

  • Form has accessible name (aria-labelledby or <legend>)
  • Labels associated with inputs (for/id or wrapping)
  • Required fields marked visually AND with required/aria-required
  • Hints and errors linked via aria-describedby
  • Fieldsets group related fields with legends
  • Form uses novalidate for custom validation
  • Submit button has clear, action-oriented text
  • Correct type attribute for each input
  • autocomplete tokens for all personal data fields
  • inputmode for numeric fields without spinners
  • pattern attribute where format validation needed
  • maxlength for character-limited fields
  • Client-side validation on blur and submit
  • Server-side validation (never trust client)
  • Error messages are specific and actionable
  • First error receives focus on submit
  • Error summary at top with anchor links
  • aria-invalid="true" on fields with errors
  • Error messages have role="alert"
  • Keyboard navigation works (Tab, Shift+Tab, Enter)
  • Focus order matches visual order
  • Focus indicator visible (3:1 contrast)
  • Screen reader testing completed (NVDA, VoiceOver)
  • Label contrast ≥4.5:1
  • Input border contrast ≥3:1
  • Touch targets ≥44×44px (minimum 24×24)
  • HTTPS on form page
  • CSRF token included
  • Sensitive data explanation where needed
  • Password show/hide toggle
  • Autosave for long forms
  • Data loss warning on navigation
  • Form loads without JavaScript (progressive enhancement)
  • Validation doesn’t block typing
  • Debounced server-side checks
  • Optimistic UI where appropriate
  • Idempotent submission handling

MetricWhat It Tells You
Step drop-off rateWhich steps lose users
Field-level abandonWhich fields cause friction
Time-to-completeOverall complexity
Error rate by fieldConfusing fields
Validation error rateFormat/requirement issues
Submission success rateEnd-to-end conversion
TestHypothesis
Remove optional fieldsHigher completion
Add progress indicatorLower abandon on multi-step
Inline vs. top error summaryFaster error recovery
Single vs. multi-columnFewer errors
Floating vs. above labelsComparable completion
Required asterisk vs. optional labelLower error rate
IssueLikely CauseFix
”Can’t submit form”Validation blocking invisiblyShow all errors clearly
”Lost my progress”No autosaveImplement draft saving
”Format confusion”Unclear requirementsAdd format hints
”Mobile keyboard wrong”Missing inputmode/typeAdd correct attributes
”Can’t find X field”Poor groupingReorganize with fieldsets

<div class="form-field" data-field="email">
<label for="email" class="form-label">
Email address
<span class="required-indicator" aria-hidden="true">*</span>
</label>
<input
type="email"
id="email"
name="email"
class="form-input"
autocomplete="email"
required
aria-required="true"
aria-describedby="email-hint email-error"
aria-invalid="false"
>
<p id="email-hint" class="form-hint">
We'll send your order confirmation here
</p>
<p id="email-error" class="form-error" role="alert" hidden>
<!-- Error message inserted by JavaScript -->
</p>
</div>
class FormValidator {
constructor(form) {
this.form = form;
this.fields = form.querySelectorAll('input, select, textarea');
this.fields.forEach(field => {
field.addEventListener('blur', () => this.validateField(field));
});
form.addEventListener('submit', (e) => this.handleSubmit(e));
}
validateField(field) {
const error = this.getFieldError(field);
this.showFieldError(field, error);
return !error;
}
getFieldError(field) {
if (field.required && !field.value.trim()) {
return `Please enter your ${this.getFieldLabel(field)}`;
}
if (field.type === 'email' && !this.isValidEmail(field.value)) {
return 'Please enter a valid email address';
}
// Add more validation rules
return null;
}
showFieldError(field, error) {
const errorElement = document.getElementById(`${field.id}-error`);
field.setAttribute('aria-invalid', error ? 'true' : 'false');
if (error) {
errorElement.textContent = error;
errorElement.hidden = false;
} else {
errorElement.hidden = true;
}
}
handleSubmit(e) {
const errors = [];
this.fields.forEach(field => {
if (!this.validateField(field)) {
errors.push(field);
}
});
if (errors.length > 0) {
e.preventDefault();
errors[0].focus();
this.showErrorSummary(errors);
}
}
}
.form-field {
margin-bottom: var(--space-6);
}
.form-label {
display: block;
margin-bottom: var(--space-2);
font-weight: var(--font-weight-medium);
color: var(--color-text-default);
}
.required-indicator {
color: var(--color-error);
margin-left: var(--space-1);
}
.form-input {
display: block;
width: 100%;
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-base);
line-height: 1.5;
color: var(--color-text-default);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
min-height: 44px;
}
.form-input:focus {
outline: 2px solid var(--color-interactive-default);
outline-offset: 2px;
border-color: var(--color-interactive-default);
}
.form-input[aria-invalid="true"] {
border-color: var(--color-error);
}
.form-hint {
margin-top: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.form-error {
margin-top: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-error);
}
.form-error::before {
content: "";
}

Baymard Institute research shows:

  • 18% of users abandon carts due to forms perceived as too long/complex
  • 16% of sites use multi-column layouts that cause errors
  • Average 35% increase in conversion with checkout UX improvements
  • US DOJ updated ADA Title II in April 2024 requiring WCAG 2.1 AA compliance
  • European Accessibility Act fully enforceable in 2025
  • Accessibility lawsuits increased 14% in 2024

WPForms research found:

  • 30%+ of marketers report highest conversions with 4-field forms
  • CAPTCHAs can reduce conversions by up to 40%
  • Adding social proof increases conversion up to 26%

Reform.app research confirms inline validation is preferred over submit-only validation, with real-time feedback reducing user frustration and abandonment.


Foundational Work:

Research:

Standards:

Practical Guides: