1990s Forms & Inputs — Collect user input with text fields, dropdowns, radio buttons, checkboxes, and textareas. HTML forms are the backbone of every interactive web app. Inspired by freeCodeCamp's Responsive Web Design certification.

01

HTML Form Structure

Complete

Why learn this?

Forms are how users talk to your app. Every sign-up, search, checkout, and survey is a form. Mastering input types, labels, fieldset, and legend is the foundation of all web interaction. The choice of semantic HTML over generic <div>-based forms pays dividends in accessibility, readability, and built-in browser validation — at zero extra cost.

Design decisions & tradeoffs

Explicit label via for/id vs implicit wrapping: Using for="name" decouples label placement from the input DOM — the label and input can be in separate flex/grid containers. The tradeoff: you must keep for and id values in sync manually, a maintenance burden when reordering fields. The alternative (wrapping <input> inside <label>) removes ID coupling but prevents independent CSS layout of label vs input. Production codebases typically use explicit for/id for complex forms and implicit wrapping for simple radio/checkbox groups.

fieldset + legend for grouping: A <fieldset> creates a semantic group that screen readers announce as containing the legend text as the group label. The tradeoff: <fieldset> carries default browser border and padding (~3 lines of CSS reset per fieldset). The alternative <div role="group" aria-labelledby="..."> gives identical semantics without visual baggage but requires more ARIA and has inconsistent support in older screen readers.

Input type selection: type="email" triggers native email validation (checks for @ + domain) and shows an email-specific mobile keyboard (@ and .com keys). type="number" shows a numeric keypad on mobile and enforces digit-only input. The tradeoff: type="number" strips leading zeros in some browsers, interfering with ZIP codes or phone numbers — use type="tel" or inputmode="numeric" for non-mathematical numeric fields.

Browser compatibility

  • required attribute: Supported since IE10+. The browser prevents submission with a native tooltip ("Please fill out this field" — localized but not styleable). On iOS Safari, the tooltip appears above the keyboard and can obscure the field itself. Use setCustomValidity() for custom messages.
  • Input types: email, number, url, tel, date have universal support. The number stepper UI varies: Chrome/Edge show up/down arrows, Firefox shows none on macOS, Safari hides them until hover.
  • fieldset / legend: Supported since HTML 4.01. Known edge case: Safari breaks <legend> display when the <fieldset> uses display: flex or display: grid. Workaround: wrap fieldset content in a <div>.
  • name attribute: Every form control needs a name to appear in FormData. Checkbox groups with the same name produce multiple entries with the same key.

Accessibility details

  • Label association: Every <input>, <select>, and <textarea> needs a programmatic label — either via <label for="id"> or wrapping. Unlabeled inputs fail WCAG 1.1.1 (Non-text Content) and 3.3.2 (Labels or Instructions). Screen readers announce the label on focus, critical for voice navigation.
  • fieldset / legend: Grouping radio buttons inside a <fieldset> with <legend> announces group context between options (WCAG 1.3.1). Without it, a screen reader user hears "Front-End, Back-End, Full-Stack" with no context — they won't know these are role options.
  • Focus order: Tab order follows DOM order. Arrange inputs top-to-bottom, left-to-right. Avoid tabindex > 0 — it overrides natural order and confuses keyboard users.
  • Error association: Production forms should use aria-describedby on inputs pointing to error message IDs, making screen readers announce errors automatically on focus.

UX considerations

  • Mobile input mode: inputmode="email" on email fields, inputmode="numeric" on numbers, inputmode="url" on URLs — these improve mobile keyboard selection independently of the type attribute.
  • Autocomplete: autocomplete="name", autocomplete="email" let browsers pre-fill saved data — a free UX win requiring only one attribute per field.
  • Placeholder is not a label: Placeholders disappear on input (losing context) and fail WCAG 1.4.3 contrast requirements. Use them only for formatting hints (e.g., "e.g., Jane Doe"), never as label replacements.

Common pitfalls

  • Missing name attributes: Inputs without name don't submit values — the #1 cause of "my form sends nothing" bugs. Verify every form control has a name.
  • Duplicate id values: <label for="name"> targets only the first element with id="name". Duplicate IDs break label association (WCAG 4.1.1). Use unique IDs per page.
  • Case-sensitive name: name="email" and name="Email" are different keys in FormData. Pick a casing convention and stick to it.
  • Nested fieldset: Valid HTML but can confuse screen reader users. Limit to one level unless the form is genuinely hierarchical.

Next up

Step 2 adds CSS to make the form visually organized — spacing, columns, focus states, and responsive layout.

HTML form ▶ Run
<fieldset>
  <legend>About You</legend>

  <label for="name">Full Name</label>
  <input type="text" id="name"
    name="name" required>

  <label>Primary Role</label>
  <label><input type="radio"
    name="role" value="frontend">
    Front-End</label>
  <label><input type="radio"
    name="role" value="backend">
    Back-End</label>

  <label for="years">Years Coding</label>
  <input type="number" id="years"
    name="years" min="0" max="50">
</fieldset>

<button type="submit">
  Submit Survey
</button>
🏷️ Label trick: Wrapping a <input> inside a <label> automatically associates them — no for attribute needed. Great for radio/checkbox groups.
02

Form Styling

Complete

Why learn this?

Bare HTML forms look terrible — cramped, misaligned, and hard to read. CSS turns them into something users actually want to fill out. Styling forms is different from styling static content because inputs have their own default rendering engines, pseudo-elements, and state-dependent styles that vary across browsers.

Design decisions & tradeoffs

Border + box-shadow focus ring vs outline: The project uses box-shadow for focus indicators because it respects border-radius — essential for rounded inputs. The tradeoff: box-shadow focus rings don't appear in Windows High Contrast Mode (accessibility concern). The alternative outline: 2px solid #00d4aa; outline-offset: 2px; works in high-contrast mode but renders as a rectangle that clips border-radius in older browsers. Best practice: pair box-shadow with a outline: 2px solid transparent to satisfy both visual design and accessibility.

accent-color vs custom-styled radio/checkbox: accent-color: #00d4aa tints native radio buttons and checkboxes in one line. The tradeoff: accent-color only applies to the checked/generic state — you can't control hover, focus, or disabled states. For full control, replace native inputs with a custom component using appearance: none + SVG background images, but that adds ~30 lines of CSS per input type and breaks if CSS fails to load. The accent-color approach is recommended for most projects as a "good enough, universally styled" solution.

Full-width button vs inline: A full-width submit button (via width: 100%) creates a clear call-to-action that works on mobile. The tradeoff: on wide desktop screens (>800px), a full-width button looks oversized. An alternative is max-width: 400px; margin: 0 auto; display: block; which keeps the button large on mobile but centered and proportioned on desktop.

transition: border-color 0.2s on all inputs: Smoothing the border color change when an input gains focus or becomes valid/invalid makes state changes feel polished rather than jarring. The tradeoff: transition on border-color combined with transition on box-shadow can cause subtle flickering in some browsers when both properties change simultaneously. A 0.15s-0.2s duration is the sweet spot — too fast (0.05s) feels unresponsive, too slow (0.4s) makes validation feedback lag.

Browser compatibility

  • accent-color: Supported since Chrome 93 (2021), Firefox 92, Safari 15.4, Edge 93. Not supported in any version of IE. Falls back to default browser coloring gracefully — the form remains functional, just without brand-tinted checkboxes.
  • :focus pseudo-class: Supported universally. Note that :focus-visible (Chrome 86+, Firefox 85+, Safari 15.4+) is a newer alternative that only shows the focus ring for keyboard users, not mouse clicks. Pairing :focus with :focus-visible is the modern pattern: use :focus-visible for the aesthetic ring and :focus as fallback.
  • CSS transition: Supported since IE10+. Works on all animatable properties. Input elements may not transition between display: none and display: block — use opacity and visibility for error message animations.
  • flex-wrap: Supported universally including IE11 (with prefix -ms-flex-wrap). The gap property on flexbox: supported since Chrome 84 (2020), Firefox 63, Safari 14.1, Edge 84. Not supported in IE11 — use margin workarounds for that browser.
  • box-shadow: Supported universally. When combining multiple shadows (focus ring + border), the comma-separated syntax is supported in all modern browsers. Note: box-shadow with large blur radii (>10px) can cause performance issues on low-end mobile devices when many inputs are visible.

Accessibility details

  • Focus indicators: WCAG 2.2 Success Criterion 2.4.13 (Focus Appearance) requires a focus indicator at least 2px thick with a color contrast ratio of at least 3:1 against adjacent colors. Our box-shadow: 0 0 0 3px rgba(0, 212, 170, 0.15) creates a ~3px ring. The rgba(0, 212, 170, 0.15) alone fails contrast on dark backgrounds — but combined with the #00d4aa border color, the total visual indicator passes. Consider adding outline: 2px solid transparent as a high-contrast mode fallback.
  • Color-only validation: Using green borders for valid and red borders for invalid is a color-only distinction that fails WCAG 1.4.1 (Use of Color). Add icons ( / ) or text labels alongside color changes to convey state without relying on color perception.
  • Touch targets: WCAG 2.5.8 (Target Size) recommends minimum 24x24px touch targets. The inputs have 0.6em + 0.8em padding, which at 16px base is about 32px tall — passing. But radio/checkbox click targets can be as small as 13x13px natively. The label wrapping expands the hit area — a key accessibility win.
  • Responsive zoom: The 600px breakpoint ensures the form remains usable when zoomed to 200% on mobile (WCAG 1.4.10 Reflow). The flex-wrap on inline groups prevents horizontal overflow at any zoom level.

UX considerations

  • Focus ring for keyboard users: The :focus style is essential for keyboard navigation. Without it, users tabbing through fields see no visual indication of their position. The 0.2s transition prevents a jarring flash when moving between fields.
  • Hover states on submit button: The button:hover darkens the background from #00d4aa to #00b894. This provides visual feedback that the button is interactive. On mobile/touch devices, hover persists after tapping, which is a known issue — use @media (hover: hover) to limit hover styles to devices that support it.
  • Consistent input sizing: Setting width: 100% on all inputs ensures they fill their container uniformly. This prevents the common issue where select elements have different default widths than input elements. The box-sizing: border-box (set globally) ensures padding doesn't overflow.
  • Label spacing: Labels placed above inputs (block layout) are more scannable than left-aligned labels on mobile. The vertical stacking matches how users read — top to bottom. On very wide forms, left-aligned labels can reduce vertical scroll but the tradeoff is reduced readability on narrow screens.

Common pitfalls

  • Forgetting box-sizing: Inputs with width: 100% + padding + border overflow their container by default. Always set *, *::before, *::after { box-sizing: border-box; } globally or explicitly on form elements.
  • Inconsistent font inheritance: Inputs and textareas don't inherit font-family by default in some browsers (especially Safari). Explicitly set input, select, textarea, button { font-family: inherit; } to match your design.
  • appearance: none pitfalls: Setting appearance: none on <select> removes the native dropdown arrow. To restore it, you must provide a custom arrow via background-image or ::after pseudo-element. The native arrow is important for UX — users expect it.
  • Safari fieldset flex bug: Applying display: flex on <fieldset> in Safari breaks the <legend> rendering. Wrap fieldset contents in a <div> and apply flex to that div instead.
  • Mobile zoom on focus: iOS Safari zooms into inputs with font-size < 16px when focused. Setting input, select, textarea { font-size: 16px; } prevents this unwanted zoom behavior.

Next up

Step 3 adds validation with :valid/:invalid CSS pseudo-classes and inline error messages.

Form CSS ▶ Run
fieldset {
  border: 1px solid #d0d5dd;
  border-radius: 10px;
  padding: 1.5em;
  background: #fff;
}

input, select, textarea {
  width: 100%;
  padding: 0.6em 0.8em;
  border: 1px solid #d0d5dd;
  border-radius: 6px;
  transition: border-color 0.2s;
}

input:focus {
  border-color: #00d4aa;
  box-shadow: 0 0 0 3px
    rgba(0, 212, 170, 0.15);
}

.inline-group {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5em 1.5em;
}

button {
  width: 100%;
  padding: 0.8em;
  background: #00d4aa;
  color: #fff;
  border: none;
  border-radius: 8px;
  font-weight: 700;
}
button:hover {
  background: #00b894;
}
🎨 Accent color: The accent-color CSS property tints radio buttons and checkboxes to match your brand. One line: input[type="radio"] { accent-color: #00d4aa; }
03

Validation & UX

Complete

Why learn this?

Forms that accept garbage data are worse than no form at all. HTML5 validation (required, pattern, type) catches errors before the data ever reaches your server. But naive validation — showing red borders on an empty form at page load — creates a hostile user experience. This step teaches the :placeholder-shown technique to delay validation until the user has actually interacted with each field.

Design decisions & tradeoffs

:valid/:invalid via CSS vs JavaScript validation: CSS pseudo-classes are reactive and require zero JavaScript — the browser evaluates validity automatically as the user types. The tradeoff: :valid/:invalid match even on empty non-required fields (they're "valid" by default), and you have limited control over error message content. JavaScript validation gives full control over message text, timing, and custom rules (e.g., password strength). Best practice: use CSS for visual states and setCustomValidity() + JavaScript for custom error messages — combining both approaches.

:not(:placeholder-shown) — the "user touched" gate: This technique shows validation styles only after the user has typed something. Before interaction, empty fields remain unstyled. The tradeoff: :placeholder-shown requires a placeholder attribute on every input, which may not be desired visually. Alternative approaches: use :focus:not(:focus-within) patterns, or toggle a CSS class via JavaScript's onblur event (touch-after-blur pattern). The :placeholder-shown approach is the cleanest CSS-only solution but couples your UX to the presence of placeholder text.

pattern attribute vs minlength: The pattern attribute accepts a regex for flexible validation (e.g., pattern=".{2,}" for minimum 2 characters). minlength is simpler but only works on text, email, url, tel, search, and password types — not on number or textarea. The pattern approach is more broadly applicable but the regex syntax can be confusing for beginners. Consider minlength for simple length checks and pattern for format validation (like alphanumeric-only).

Browser compatibility

  • :valid / :invalid pseudo-classes: Supported since IE10. These pseudo-classes are evaluated immediately on page load — every empty non-required field is ":valid" and every empty required field is ":invalid". This causes the "red flash on load" problem that :placeholder-shown solves.
  • :placeholder-shown: Supported since Chrome 47 (2016), Firefox 51, Safari 10, Edge 79. Not supported in IE11 or earlier Edge. For those browsers, the :not(:placeholder-shown) selector is ignored entirely, meaning validation styles never appear — a graceful degradation (fields are unstyled, not broken).
  • :not() negation with complex selectors: The :not(:placeholder-shown) syntax with a pseudo-class inside :not() is supported in all modern browsers. Older browsers (IE11, early Safari) only support simple selectors inside :not(). For broad compatibility, consider a JavaScript class-based approach.
  • pattern attribute: Supported since IE10. The regex is anchored implicitly — it wraps the pattern in ^(?:...)$. A pattern of [a-z]+ matches only if the entire value is lowercase letters, not if it contains lowercase letters.
  • required on checkboxes: When applied to a checkbox group, only the first checkbox with the same name needs to be checked. This can be confusing for multi-select required groups — you may need JavaScript validation for "at least N checkboxes must be checked."

Accessibility details

  • Error messages need aria-describedby: The current CSS approach uses a .error-msg sibling that appears on :invalid:not(:placeholder-shown). However, the error message is not programmatically linked to the input. Adding aria-describedby="name-error" on the input and id="name-error" on the error element makes screen readers announce the error when the input receives focus (WCAG 3.3.1 Error Identification).
  • aria-live region for dynamic errors: Wrap the error container in aria-live="polite" so screen readers announce validation changes without requiring focus. This is essential for users who navigate by scanning rather than tabbing.
  • Color-only error indication: As noted in Step 2, red borders alone fail WCAG 1.4.1 (Use of Color). The .error-msg text provides a non-color cue, but ensure the text itself is descriptive ("Please enter at least 2 characters") rather than just "Invalid".
  • required attribute announcement: Screen readers announce "required" when focusing a field with the required attribute. This is built-in behavior in NVDA, JAWS, and VoiceOver. Do not suppress the native required attribute unless providing an equivalent announcement via aria-required="true".
  • Fieldset/legend for required groups: If a whole group of checkboxes is required, announce this in the <legend> text (e.g., "Required — select all that apply") rather than putting required on every individual checkbox.

UX considerations

  • The :placeholder-shown UX pattern: This is one of the most elegant CSS-only UX patterns for forms. Fields appear neutral on load, turn green when typed into correctly, and turn red only when the user has entered invalid data. This avoids the "wall of red" on page load that drives users away from complex forms.
  • Validation timing: Real-time validation (as the user types) can feel intrusive — especially for fields where the user pauses to think (like a textarea). A common alternative is "on blur" validation: show errors only after the user leaves the field. This is gentler but means errors appear later. The CSS approach validates as the user types, which is better for short fields (name, email) and worse for long-form text.
  • Disabled submit button vs in-form errors: Some designs disable the submit button until all fields are valid. This prevents submission of invalid data but provides no feedback on which fields are wrong. The better UX pattern is to allow submission and highlight all errors on submit attempt — letting the user see everything wrong at once.
  • Green for valid fields: While green borders reinforce success, too much green (especially on fields that are "trivially valid" like optional text fields) can create visual noise. Consider showing valid indicator ( icon) inside the field rather than a full green border, or only styling invalid states and leaving valid fields neutral.

Common pitfalls

  • The "red flash on load" bug: Without :not(:placeholder-shown), every required empty field appears invalid on page load. Users see a form full of red borders before they've touched anything. This dramatically reduces form completion rates.
  • Misunderstanding pattern anchoring: pattern="[a-zA-Z]" only matches single-character inputs because the regex is auto-anchored (^(?:...)$). For minimum length via pattern, use pattern=".{3,}" — but minlength="3" is clearer.
  • :invalid on empty optional fields: type="email" without required is ":valid" when empty (no value = valid). But type="number" without required where the user types "abc" is ":invalid." Test both required and optional fields separately.
  • aria-describedby pointing to non-existent IDs: If the error element has a conditional display (display: none), the aria-describedby reference still works — screen readers recognize the element even when hidden. But if the error element doesn't exist in the DOM at all, the aria-describedby is silently ignored. Always render error elements in the HTML (even if hidden) if using aria-describedby.
  • Validation on select elements: A <select> with required and a default "choose one" option with empty value (<option value="">) is invalid until the user picks a real option. This is correct behavior but the "choose one" option should use disabled selected hidden attributes for the best UX.
  • Browser-form fill bypass: Browsers that auto-fill credentials or saved data may trigger :valid/:invalid evaluation on page load, causing validation styles to appear before user interaction. The :placeholder-shown guard handles this correctly because auto-filled fields have a value but no placeholder — but test this behavior across browsers.

Next up

Step 4 adds form submission with FormData API, loading state, and a response summary.

Validation CSS ▶ Run
/* Valid field — green */
input:valid {
  border-color: #27ae60;
  background: #f0faf4;
}

/* Invalid field — red,
   but only after user types */
input:invalid:not(:placeholder-shown) {
  border-color: #e74c3c;
  background: #fef5f5;
}

/* Error message — hidden
   until field is invalid */
.error-msg {
  display: none;
}
input:invalid:not(:placeholder-shown)
  ~ .error-msg {
  display: block;
}
⚡ UX tip: :not(:placeholder-shown) prevents the red glow from showing on empty fields when the page first loads. Errors only appear after the user has typed something invalid.
04

Submission & Response

Complete

Why learn this?

A form that goes nowhere frustrates users. Handling form submission — collecting data, showing a loading state, displaying a response — is the bridge between UI and backend. This step turns static HTML into a dynamic client-side application using the FormData API, async patterns, and DOM manipulation.

Design decisions & tradeoffs

FormData API vs manual querySelector collection: new FormData(form) automatically collects all named form controls — checkboxes, radios, selects, textareas, file inputs — without writing a selector per field. The tradeoff: FormData returns values as strings (or File objects for file inputs). It doesn't handle type conversion (string "42" vs number 42), and extracting checkbox groups into arrays requires the loop pattern shown in the code. The alternative — individual document.querySelector('input[name="name"]').value — gives full control but requires N lines of repetitive code and breaks silently if a field is removed from HTML but not from JS.

Loop-based array aggregation for checkbox groups: The for (let [key, val] of data) pattern detects duplicate keys from checkbox groups and aggregates them into arrays. The tradeoff: this mutates the values object in place and doesn't distinguish between a single checkbox (string) and a multi-select (array) in the output — downstream code must handle both types. An alternative pattern using data.getAll('languages') always returns an array (empty if no checkboxes checked), which simplifies type handling but requires knowing the field name in advance. The loop approach is more generic and works with dynamic form fields.

setTimeout simulation vs real backend: The 800ms delay simulates network latency. The tradeoff: a fixed delay doesn't account for variable response times. Real backends return in 50ms-2000ms, and the loading state should persist until the server actually responds — not a hardcoded timeout. For production, replace setTimeout with fetch() + async/await and handle both success and error responses (HTTP 4xx, 5xx, network failure). The simulated delay is appropriate for this tutorial but should be the first thing replaced when connecting to a real API.

Loading spinner via innerHTML vs <button> content swap: The current approach replaces submitBtn.innerHTML with a spinner + "Submitting..." text. The tradeoff: innerHTML re-parses the HTML on every assignment, which is inefficient and can reset event listeners on nested elements. A better production pattern: use distinct <span> elements for "normal" and "loading" states, toggling their display via CSS classes. This avoids innerHTML entirely and preserves any attached event handlers.

Browser compatibility

  • FormData API: Supported since Chrome 7 (2011), Firefox 4, Safari 5, Edge 12, IE 10. Available in all modern browsers and Node.js 18+. The FormData.entries() iterator: supported since Chrome 52, Firefox 44, Safari 10.1, Edge 14. In older browsers, use for...of on FormData directly without .entries().
  • for...of on FormData: Iterating with for (let [key, val] of data) requires FormData[Symbol.iterator] support — available since Chrome 52+, Firefox 44+, Safari 10.1+. For maximum compatibility, use data.entries() explicitly. For older browsers (IE 10-11), use a polyfill or the fallback with data.get()/data.getAll() per field name.
  • event.preventDefault(): Supported universally. The pattern form.addEventListener('submit', (e) => { e.preventDefault(); ... }) works in all browsers including IE9+.
  • CSS @keyframes animation (spinner): Supported since Chrome 43 (2015), Firefox 16, Safari 9, Edge 12, IE 10. For IE10, use -ms- prefix. The transform: rotate() inside keyframes: supported universally. The spinner element uses border: ... solid transparent with border-top-color — this creates a partial circle that rotates. This technique works in all browsers supporting CSS animations.
  • disabled attribute on buttons: Supported universally. A disabled button cannot be clicked and is excluded from tab order. Its opacity/styling can be customized with button:disabled { ... }. Note: disabled buttons do not fire click events, even if a user manages to click them.

Accessibility details

  • Loading state announcement: When the button text changes to "Submitting...", sighted users see the spinner + text. But screen readers may not announce this change because the button's content change happens within the same focused element. Use aria-live="polite" on a status region, or set aria-busy="true" on the form during submission, to announce the loading state to assistive technology.
  • Disabled button during submission: Setting disabled = true prevents accidental double-submission — a critical UX pattern for all forms. However, disabled buttons are removed from the tab order, meaning keyboard users lose their place. A more accessible pattern: keep the button enabled but use a JavaScript flag to ignore subsequent clicks (a "submitting" boolean), so keyboard users can still tab to it and know it's the submit target.
  • Focus management on response: When the form is replaced with the response card, focus moves to the new content area. Without focus management, the user's cursor jumps to the top of the page (because the form disappeared). Use .focus() on the response card with tabindex="-1" to move focus to the new content (WCAG 2.4.3 Focus Order). Add role="alert" to the response card so screen readers announce the submission result immediately.
  • Reset button accessibility: The "Start Over" button should reset focus back to the first input in the form. Without this, the user lands on a rebuilt form with focus at the top of the page, but a keyboard user won't know where they are. Use form.querySelector('input')?.focus() after resetting.
  • Response summary readability: The success card shows a summary of submitted data. Ensure this content is structured with semantic HTML (<dl> for key-value pairs, <h3> for the heading) rather than generic <div> elements.

UX considerations

  • The 800ms simulated delay: The loading state must be visible long enough for users to perceive it. 800ms is below the 1-second threshold where users start to feel frustrated. If the real backend responds in <100ms, consider adding a minimum 300ms loading state to prevent a "flash of success" that feels jarring.
  • Double-submission prevention: The disabled attribute on the submit button is the simplest and most effective pattern. Alternative approaches: using a boolean flag (let isSubmitting = false) that gates the submit handler, or removing the event listener during submission. The disabled approach is preferred because it provides visual feedback (greyed-out button) in addition to blocking interaction.
  • Spinner design: The CSS spinner uses @keyframes spin { to { transform: rotate(360deg); } } — a full rotation every 0.8-1s is the standard pace. Faster rotations (0.3s) feel frantic; slower (2s) feel unresponsive. The spinner should be display: inline-block to sit inline with the button text, sized at 1em to match the text height.
  • Error handling (not yet implemented): The current flow assumes success. Production forms must handle network errors (fetch() failures), server validation errors (HTTP 422 with field-specific messages), and timeout errors. The loading state must revert on error (re-enable the button, restore original text, show error messages). This is the single most important addition before going to production.
  • Response card patterns: The success card shows all submitted values. For real applications, consider whether showing all submitted data is appropriate — for a survey, yes; for a password change form, no. The "Start Over" button with form.reset() clears all fields and resets validation states, returning the form to its initial neutral state.

Common pitfalls

  • Forgetting e.preventDefault(): Without it, the form performs a full page navigation (GET or POST to the current URL), losing all JavaScript state. This is the #1 form submission bug. Always call e.preventDefault() as the first line of the submit handler.
  • FormData.get() returns only the first checkbox: For checkbox groups with the same name, data.get('languages') returns only the first checked value. Use data.getAll('languages') to get an array of all selected values, or the loop pattern shown in the code.
  • FormData doesn't include disabled fields: Inputs with the disabled attribute are excluded from FormData entirely. Use readonly instead of disabled if you want the field value to be included in submission.
  • FormData doesn't include unchecked checkboxes: Unchecked checkboxes produce no entry in FormData. If you need to distinguish "user unchecked the box" from "the field doesn't exist," add a hidden input with the same name before each checkbox, with a default "off" value. The browser sends the hidden value when the checkbox is unchecked and the checkbox value when checked.
  • Memory leaks from abandoned setTimeout: If the user navigates away or the component unmounts during the 800ms simulation, the setTimeout callback still fires and attempts to manipulate DOM elements that no longer exist. Use clearTimeout() in a cleanup function, or replace with fetch() + AbortController for cancellable requests.
  • innerHTML security: If any submitted value contains HTML (e.g., a user types <script>alert('xss')</script> in the name field), using innerHTML to render the response card executes the script. Use textContent or innerText instead, or sanitize the output with textContent assignment. This is a critical security concern for production forms.
  • Loss of form state on reset: The form.reset() method restores all fields to their default HTML values — not to the last user-entered state. If a field was pre-filled with a default value via the value attribute, reset() restores that value, not an empty string. This can surprise users who expected the form to clear entirely.
Submission JS ▶ Run
form.addEventListener('submit', (e) => {
  e.preventDefault();

  const data = new FormData(form);
  const values = {};
  for (let [key, val] of data) {
    if (!values[key]) {
      values[key] = val;
    } else {
      if (!Array.isArray(values[key]))
        values[key] = [values[key]];
      values[key].push(val);
    }
  }

  // Show loading state
  submitBtn.disabled = true;
  submitBtn.innerHTML =
    '<span class="spinner"></span> Submitting...';

  setTimeout(() => {
    showResponse(values);
  }, 800);
});
📋 FormData tip: Checkbox groups with the same name attribute produce multiple entries in FormData. Loop through .entries() and collect duplicates into arrays — otherwise each check only captures the last value.
Model
DeepSeek V4 Flash
Total Tokens
~33K
Est. Cost
$0.01
Steps
4
Tokens measured from actual output files at ~0.28 tok/byte. Input estimated from turns multiplied by system + AGENTS.md context.

✅ Survey Form Complete

All 4 steps built. Ready to move to the next project:

03
Product LandingHero section, feature cards, testimonials, pricing table — a real marketing page

Lessons Learned — Build Process

The AI challenges, design insights, and pipeline improvements from building Survey Form with AI.

🎮
Why Survey Form Was Popular

Survey forms taught accessible data collection at scale. The humble HTML form — with its labels, fieldsets, inputs, and validation — is the backbone of every web app that accepts user input. Before React form libraries, before AJAX submissions, the native HTML form worked everywhere. The key insight: a well-designed form reduces friction and increases completion rates. Every field must justify its existence, every label must be clear, every error message must be helpful. This principle transfers directly to modern signup flows, checkout forms, and survey tools.

⚠️
The Problem — AI Shortcomings

Label-input association was consistently wrong. The AI generated <label>Name:</label><input type="text"> instead of using the for attribute or wrapping the input inside the label. This passes visual inspection (the label appears next to the field) but fails accessibility — screen readers can't associate the label with the input. The form layout also broke on mobile: the AI used fixed-width columns that overflowed on narrow screens.

🛠️
The Fix — Pipeline Improvements

Accessibility checks are now part of the verification gate: every <input> must have either a <label for> or be wrapped in a <label>. Responsive breakpoints are tested at 320px, 768px, and 1024px before the form is considered complete. This caught the same issue in subsequent form-heavy pages.

Each app builds on the last. The bugs found in survey-form were fixed before the next app was built — and every bug saves time on every future app.