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.