I've spent years building design systems, and if there's one thing I've learned, it's that forms are the most common place where accessibility falls apart. It's easy to make a form look pretty, but making it work for everyone—including screen reader users and keyboard navigators—requires a bit more intentionality. I personally love the challenge of balancing sleek UI with rock-solid A11Y standards.

Semantic Structure and Labelling

The foundation of any accessible form is the humble label, and I always ensure every input has a programmatically linked label element.

<!-- The standard, foolproof approach -->
<div class="field-group">
  <label for="user-email">Email Address</label>
  <input type="email" id="user-email" name="email" placeholder="e.g. alex@example.com">
</div>

If you're dealing with a complex layout where a visible label isn't possible, I'd suggest using aria-label, but remember that visible labels are always better for cognitive accessibility.

<!-- Using aria-labelledby for complex structures -->
<h3 id="shipping-heading">Shipping Method</h3>
<div role="radiogroup" aria-labelledby="shipping-heading">
  <label>
    <input type="radio" name="shipping" value="standard"> Standard (3-5 days)
  </label>
  <label>
    <input type="radio" name="shipping" value="express"> Express (Next day)
  </label>
</div>

Grouping Related Fields

When I'm building address forms or sets of checkboxes, I find the fieldset and legend elements are non-negotiable for providing context to screen readers.

/* CSS to make fieldsets look modern */
fieldset {
  border: 1px solid var(--neutral-200);
  border-radius: 8px;
  padding: 1.5rem;
  margin-bottom: 2rem;
}

legend {
  font-weight: 600;
  padding: 0 0.5rem;
  color: var(--primary-700);
}

This structure ensures that as a user tabs through radio buttons, the 'legend' is announced, giving them the necessary context for the choices.

<fieldset>
  <legend>Communication Preferences</legend>
  <div class="checkbox-wrapper">
    <input type="checkbox" id="news" name="pref" value="news">
    <label for="news">Newsletter updates</label>
  </div>
  <div class="checkbox-wrapper">
    <input type="checkbox" id="promo" name="pref" value="promo">
    <label for="promo">Promotional offers</label>
  </div>
</fieldset>

Handling Error States Gracefully

What I find fascinating is how often developers rely only on colour to show errors; I always use aria-describedby to link error messages to their inputs.

/* Styling error states with high contrast */
.input-error {
  border-color: #dc2626;
  outline: 2px solid transparent;
}

.error-message {
  color: #dc2626;
  font-size: 0.875rem;
  margin-top: 0.25rem;
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

Here is the React-style pattern I typically use to ensure the error is announced immediately when it appears.

<div class="field">
  <label for="postcode">Postcode</label>
  <input 
    type="text" 
    id="postcode" 
    aria-invalid="true" 
    aria-describedby="postcode-error"
  >
  <p id="postcode-error" role="alert" class="error-message">
    <span aria-hidden="true">⚠️</span> Please enter a valid UK postcode
  </p>
</div>

Mastering Focus Indicators

Never remove the default focus ring without providing a better one; I like to create high-visibility rings that match the brand's aesthetic.

/* Custom focus ring that works in dark mode too */
:focus-visible {
  outline: 3px solid #06b6d4;
  outline-offset: 2px;
  border-radius: 4px;
}

/* Removing default only when using focus-visible */
:focus:not(:focus-visible) {
  outline: none;
}

For custom components like styled checkboxes, I use a hidden-but-accessible approach so the keyboard interaction remains native.

/* The 'Visually Hidden' pattern */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

.custom-checkbox:focus-within .checkbox-visual {
  box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.5);
}

Helper Text and Hints

Placeholder text is not a replacement for a label or a hint; here's how I link persistent instructions to an input.

<div class="field">
  <label for="password">Password</label>
  <span id="password-hint" class="hint-text">
    Must be at least 12 characters and include a symbol.
  </span>
  <input 
    type="password" 
    id="password" 
    aria-describedby="password-hint"
    required
  >
</div>

I often use CSS to ensure that hint text remains legible and doesn't get confused with the label itself.

.hint-text {
  display: block;
  color: #64748b;
  font-size: 0.825rem;
  margin-bottom: 0.5rem;
  line-height: 1.4;
}

Wrapping Up

Building accessible forms isn't just about passing a checklist; it's about creating a frictionless experience for every user who interacts with your interface. By using semantic HTML and the correct ARIA attributes, you make your site more robust and easier to maintain.

  • Always link labels to inputs using the 'for' and 'id' attributes.
  • Use aria-describedby to connect error messages and hint text to the relevant fields.
  • Ensure focus states are highly visible and never rely on colour alone to convey meaning.

I encourage you to open your latest project and try navigating your forms using only your keyboard—it's often an eye-opening experience! If you found this helpful, I'd love to connect! Follow me on Twitter/X or LinkedIn for more CSS and design system tips.