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.
HTML Form Structure
CompleteWhy 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
requiredattribute: 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. UsesetCustomValidity()for custom messages.- Input types:
email,number,url,tel,datehave universal support. Thenumberstepper 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>usesdisplay: flexordisplay: grid. Workaround: wrap fieldset content in a<div>.nameattribute: Every form control needs anameto appear inFormData. Checkbox groups with the samenameproduce 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-describedbyon 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 thetypeattribute. - 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
nameattributes: Inputs withoutnamedon't submit values — the #1 cause of "my form sends nothing" bugs. Verify every form control has aname. - Duplicate
idvalues:<label for="name">targets only the first element withid="name". Duplicate IDs break label association (WCAG 4.1.1). Use unique IDs per page. - Case-sensitive
name:name="email"andname="Email"are different keys inFormData. 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.
<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><input> inside a <label> automatically associates them — no for attribute needed. Great for radio/checkbox groups.Form Styling
CompleteWhy 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.:focuspseudo-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:focuswith:focus-visibleis the modern pattern: use:focus-visiblefor the aesthetic ring and:focusas fallback.- CSS
transition: Supported since IE10+. Works on all animatable properties. Input elements may not transition betweendisplay: noneanddisplay: block— useopacityandvisibilityfor error message animations. flex-wrap: Supported universally including IE11 (with prefix-ms-flex-wrap). Thegapproperty 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-shadowwith 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. Thergba(0, 212, 170, 0.15)alone fails contrast on dark backgrounds — but combined with the#00d4aaborder color, the total visual indicator passes. Consider addingoutline: 2px solid transparentas 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
labelwrapping 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-wrapon inline groups prevents horizontal overflow at any zoom level.
UX considerations
- Focus ring for keyboard users: The
:focusstyle 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:hoverdarkens the background from#00d4aato#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 whereselectelements have different default widths thaninputelements. Thebox-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 withwidth: 100%+ padding + border overflow their container by default. Always set*, *::before, *::after { box-sizing: border-box; }globally or explicitly on form elements. - Inconsistent
fontinheritance: Inputs and textareas don't inheritfont-familyby default in some browsers (especially Safari). Explicitly setinput, select, textarea, button { font-family: inherit; }to match your design. appearance: nonepitfalls: Settingappearance: noneon<select>removes the native dropdown arrow. To restore it, you must provide a custom arrow viabackground-imageor::afterpseudo-element. The native arrow is important for UX — users expect it.- Safari
fieldsetflex bug: Applyingdisplay: flexon<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.
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 CSS property tints radio buttons and checkboxes to match your brand. One line: input[type="radio"] { accent-color: #00d4aa; }Validation & UX
CompleteWhy 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/:invalidpseudo-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-shownsolves.: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.patternattribute: 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.requiredon checkboxes: When applied to a checkbox group, only the first checkbox with the samenameneeds 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-msgsibling that appears on:invalid:not(:placeholder-shown). However, the error message is not programmatically linked to the input. Addingaria-describedby="name-error"on the input andid="name-error"on the error element makes screen readers announce the error when the input receives focus (WCAG 3.3.1 Error Identification). aria-liveregion for dynamic errors: Wrap the error container inaria-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-msgtext provides a non-color cue, but ensure the text itself is descriptive ("Please enter at least 2 characters") rather than just "Invalid". requiredattribute announcement: Screen readers announce "required" when focusing a field with therequiredattribute. This is built-in behavior in NVDA, JAWS, and VoiceOver. Do not suppress the nativerequiredattribute unless providing an equivalent announcement viaaria-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 puttingrequiredon every individual checkbox.
UX considerations
- The
:placeholder-shownUX 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
patternanchoring:pattern="[a-zA-Z]"only matches single-character inputs because the regex is auto-anchored (^(?:...)$). For minimum length via pattern, usepattern=".{3,}"— butminlength="3"is clearer. :invalidon empty optional fields:type="email"withoutrequiredis ":valid" when empty (no value = valid). Buttype="number"withoutrequiredwhere the user types "abc" is ":invalid." Test both required and optional fields separately.aria-describedbypointing to non-existent IDs: If the error element has a conditional display (display: none), thearia-describedbyreference still works — screen readers recognize the element even when hidden. But if the error element doesn't exist in the DOM at all, thearia-describedbyis silently ignored. Always render error elements in the HTML (even if hidden) if usingaria-describedby.- Validation on
selectelements: A<select>withrequiredand 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 usedisabled selected hiddenattributes for the best UX. - Browser-form fill bypass: Browsers that auto-fill credentials or saved data may trigger
:valid/:invalidevaluation on page load, causing validation styles to appear before user interaction. The:placeholder-shownguard 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.
/* 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; }
: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.Submission & Response
CompleteWhy 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
FormDataAPI: Supported since Chrome 7 (2011), Firefox 4, Safari 5, Edge 12, IE 10. Available in all modern browsers and Node.js 18+. TheFormData.entries()iterator: supported since Chrome 52, Firefox 44, Safari 10.1, Edge 14. In older browsers, usefor...ofonFormDatadirectly without.entries().for...ofonFormData: Iterating withfor (let [key, val] of data)requiresFormData[Symbol.iterator]support — available since Chrome 52+, Firefox 44+, Safari 10.1+. For maximum compatibility, usedata.entries()explicitly. For older browsers (IE 10-11), use a polyfill or the fallback withdata.get()/data.getAll()per field name.event.preventDefault(): Supported universally. The patternform.addEventListener('submit', (e) => { e.preventDefault(); ... })works in all browsers including IE9+.- CSS
@keyframesanimation (spinner): Supported since Chrome 43 (2015), Firefox 16, Safari 9, Edge 12, IE 10. For IE10, use-ms-prefix. Thetransform: rotate()inside keyframes: supported universally. The spinner element usesborder: ... solid transparentwithborder-top-color— this creates a partial circle that rotates. This technique works in all browsers supporting CSS animations. disabledattribute on buttons: Supported universally. A disabled button cannot be clicked and is excluded from tab order. Its opacity/styling can be customized withbutton:disabled { ... }. Note: disabled buttons do not fireclickevents, 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 setaria-busy="true"on the form during submission, to announce the loading state to assistive technology. - Disabled button during submission: Setting
disabled = trueprevents 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 withtabindex="-1"to move focus to the new content (WCAG 2.4.3 Focus Order). Addrole="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
disabledattribute 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. Thedisabledapproach 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 bedisplay: inline-blockto 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 calle.preventDefault()as the first line of the submit handler. FormData.get()returns only the first checkbox: For checkbox groups with the samename,data.get('languages')returns only the first checked value. Usedata.getAll('languages')to get an array of all selected values, or the loop pattern shown in the code.FormDatadoesn't include disabled fields: Inputs with thedisabledattribute are excluded fromFormDataentirely. Usereadonlyinstead ofdisabledif you want the field value to be included in submission.FormDatadoesn't include unchecked checkboxes: Unchecked checkboxes produce no entry inFormData. 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, thesetTimeoutcallback still fires and attempts to manipulate DOM elements that no longer exist. UseclearTimeout()in a cleanup function, or replace withfetch()+AbortControllerfor cancellable requests. innerHTMLsecurity: If any submitted value contains HTML (e.g., a user types<script>alert('xss')</script>in the name field), usinginnerHTMLto render the response card executes the script. UsetextContentorinnerTextinstead, or sanitize the output withtextContentassignment. 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 thevalueattribute,reset()restores that value, not an empty string. This can surprise users who expected the form to clear entirely.
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);
});name attribute produce multiple entries in FormData. Loop through .entries() and collect duplicates into arrays — otherwise each check only captures the last value.Lessons Learned — Build Process
The AI challenges, design insights, and pipeline improvements from building Survey Form with AI.
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.
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.
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.