label, fieldset and required fields without screen reader traps
Forms are the most critical touchpoint on any website for people using screen readers, because without correct label association, meaningful fieldset and legend grouping and clearly communicated required fields, a checkout simply becomes unusable. This article walks through a practical address form to show how Magento and Hyva developers build forms that are semantically correct, communicate errors accessibly and implement WCAG compliant input assistance.
Table of Contents
- 1. Why Semantic Forms Are Not a Nice-to-have
- 2. label/for: The Non-negotiable Baseline
- 3. fieldset and legend: Grouping Related Fields
- 4. Required Fields: Visual and Screen Reader Indication
- 5. Making Error Messages and Validation Accessible
- 6. Using Autocomplete and Input Assistance Correctly
- 7. Keyboard Operability and Focus Order
- 8. Practical Example: Accessible Checkout Address Form
- 9. Forms Compared: Common Mistakes and Solutions
- 10. Summary
- 11. FAQ
1. Why Semantic Forms Are Not a Nice-to-have
Forms are the point in every online store where a visitor turns into a customer. That is exactly why the technical structure of a form decides whether a screen reader user can complete a checkout at all. Sighted users recognize an input field by its visual position next to a label, by a border, or by color. A screen reader has no access to these visual signals and depends entirely on the programmatically exposed structure in the HTML. Without that structure, a user only hears "edit field, blank" with no context about which field is meant.
WCAG 2.1 requires in Success Criterion 1.3.1 (Info and Relationships) that structure and relationships between form elements are programmatically determinable, not just visually apparent. Success Criterion 3.3.2 (Labels or Instructions) additionally requires that every input field is provided with a label or instruction. Both criteria are not a nice-to-have but the baseline requirement for an operable form. In Magento and Hyva themes this mostly concerns custom-built checkout, contact and account forms, where Tailwind classes and Alpine.js can quickly tempt developers into neglecting semantic HTML in favor of styling freedom.
2. label/for: The Non-negotiable Baseline
Connecting <label for="id"> to the matching id attribute of the corresponding input field is the single most important rule for accessible forms. Only this programmatic association lets a screen reader read out the label text when the field receives focus, for example "First name, edit text". Without that connection, the screen reader only announces the field type, the label text sits unconnected in the DOM, and in the worst case is ignored entirely. Alternatively, the input field can be nested directly inside the <label> element, which creates the same programmatic connection without an explicit for attribute.
Placeholder text in the placeholder attribute never replaces a real label. Placeholders disappear on focus, often have insufficient color contrast, and are read unreliably or not at all by some screen readers. Where no visible label is wanted for layout reasons, for example a single search field, the label stays in the DOM and is hidden visually with the sr-only class instead of being removed entirely. That way the programmatic information for screen readers remains fully intact, while the visual appearance stays unchanged.
<!-- WRONG: label has no programmatic connection to the input -->
<label>First Name</label>
<input type="text" name="firstname" placeholder="Enter first name">
<!-- WRONG: mismatched for/id values -->
<label for="firstname">First Name</label>
<input type="text" id="first-name" name="firstname">
<!-- RIGHT: for/id match exactly -->
<label for="firstname" class="block text-sm font-medium text-gray-700 mb-1">
First Name
</label>
<input type="text" id="firstname" name="firstname"
class="w-full rounded-lg border border-gray-300 px-3 py-2">
<!-- RIGHT: implicit association via nesting, no explicit for/id needed -->
<label class="flex items-center gap-2">
<input type="checkbox" name="newsletter">
<span>Subscribe to newsletter</span>
</label>
<!-- RIGHT: visible label hidden visually, still read by screen readers -->
<label for="search" class="sr-only">Product search</label>
<input type="search" id="search" name="q" placeholder="Search...">
3. fieldset and legend: Grouping Related Fields
A single label describes a single field, but many forms consist of groups of related fields, for example street, house number, postal code and city within a shipping address. Without additional structure, a screen reader user jumps from field to field without knowing they are currently inside the "Shipping Address" block instead of "Billing Address". The <fieldset> element with a nested <legend> element solves exactly this problem: the screen reader announces the group title as soon as the user navigates into the group, and repeats it as context for every single field within it.
fieldset/legend is especially important for groups of radio buttons or checkboxes, for example when choosing a shipping method. Without grouping, a user only hears "radio button, DHL Standard" without knowing the overarching question "Choose shipping method". The same logic applies to address blocks: a form with separate fieldset groups for billing and shipping addresses prevents confusion that would otherwise remain invisible to screen reader users when the separation is only visual, made through headings. Nesting fieldset elements too deeply should be avoided, since it tends to hinder navigation rather than help it.
<!-- Shipping address as a properly grouped fieldset -->
<fieldset class="border border-gray-300 rounded-lg p-4 mb-6">
<legend class="px-2 font-semibold text-gray-800">Shipping Address</legend>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-2">
<div class="sm:col-span-2">
<label for="ship-street" class="block text-sm font-medium text-gray-700 mb-1">Street</label>
<input type="text" id="ship-street" name="ship_street" autocomplete="address-line1"
class="w-full rounded-lg border border-gray-300 px-3 py-2" required aria-required="true">
</div>
<div>
<label for="ship-housenumber" class="block text-sm font-medium text-gray-700 mb-1">House Number</label>
<input type="text" id="ship-housenumber" name="ship_housenumber" autocomplete="address-line2"
class="w-full rounded-lg border border-gray-300 px-3 py-2" required aria-required="true">
</div>
</div>
</fieldset>
<!-- Radio group requires fieldset/legend, a plain heading is not enough -->
<fieldset class="border border-gray-300 rounded-lg p-4">
<legend class="px-2 font-semibold text-gray-800">Choose Shipping Method</legend>
<label class="flex items-center gap-2 mt-2">
<input type="radio" name="shipping_method" value="standard" checked>
<span>Standard Shipping (2 to 3 business days)</span>
</label>
<label class="flex items-center gap-2 mt-2">
<input type="radio" name="shipping_method" value="express">
<span>Express Shipping (1 business day)</span>
</label>
</fieldset>
4. Required Fields: Visual and Screen Reader Indication
A red asterisk next to the label text is the common visual convention for required fields, but it tells a screen reader user nothing at all if the asterisk is just a plain special character in the text. Some screen readers read the character as "asterisk", others ignore it entirely depending on the pronunciation settings. The indication becomes reliable only through the combination of the native HTML5 required attribute and the ARIA attribute aria-required="true". Modern screen readers then explicitly announce "required" when the field receives focus, independent of the visual asterisk.
Extra reliability comes from an additional text that is readable by screen readers but visually hidden directly inside the label, for example <span class="sr-only">(required)</span> right after the visible asterisk. That way the visual indication stays compact, while screen reader users hear an unambiguous word instead of an ambiguous symbol. For optional fields in a form that is predominantly required, the reverse approach is often more sensible: instead of marking every required field, only the word "optional" is added to the few voluntary fields, which reduces the overall visual and audible information density.
/* Required marker: visible asterisk plus screen reader announcement */
.form-label-required::after {
content: "*";
color: #dc2626;
margin-left: 0.25rem;
font-weight: 700;
}
/* Visually hidden but still announced by screen readers */
.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;
}
/* Focus indicator must stay visible, never remove without a replacement */
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid #3f3f46;
outline-offset: 2px;
}
5. Making Error Messages and Validation Accessible
An error message communicated exclusively through a red border around the input field simply does not exist for a screen reader user. Accessible validation needs two programmatic connections: the aria-invalid="true" attribute on the offending field signals the error state, and aria-describedby points to the id of the actual error text. Only this combination makes a screen reader read out, when the field receives focus, both the "invalid entry" state and the concrete reason, for example "Postal code must consist of 5 digits".
After a failed submission attempt, an error summary should additionally appear at the top of the form, marked up with role="alert" or inside a region with aria-live="polite". That way screen reader users learn immediately that an error occurred, without having to search the entire form again. In Hyva themes this pattern works well with Alpine.js: an x-data object holds the validation state, sets aria-invalid reactively, and moves keyboard focus to the first invalid field when needed.
// Alpine.js form validation with accessible error announcements
document.addEventListener('alpine:init', () => {
Alpine.data('checkoutForm', () => ({
errors: {},
submitted: false,
validateField(field, value) {
if (field === 'postcode' && !/^\d{5}$/.test(value)) {
this.errors.postcode = 'Postal code must consist of 5 digits';
} else {
delete this.errors.postcode;
}
},
hasError(field) {
return Boolean(this.errors[field]);
},
async submitForm() {
this.submitted = true;
if (Object.keys(this.errors).length > 0) {
// Move focus to the error summary so screen reader users are not stranded
this.$refs.errorSummary?.focus();
return;
}
// proceed with actual submission
}
}));
});
6. Using Autocomplete and Input Assistance Correctly
The HTML autocomplete attribute with standardized values like given-name, family-name, street-address, postal-code or country is part of WCAG Success Criterion 1.3.5 (Identify Input Purpose) and serves two purposes at once. For sighted users it activates the browser's autofill feature and speeds up filling in the form considerably. For users with cognitive impairments or motor disabilities who rely on voice input or switch controls, it drastically reduces the number of manual entries required, which is a direct accessibility gain.
In addition, the inputmode attribute helps display the right virtual keyboard on mobile devices, for example inputmode="numeric" for postal codes or inputmode="tel" for phone numbers, without changing the semantic type of the field. autocomplete="off" should generally be avoided, except on security-critical fields like a new password, because blanket-disabled autofill forces all users into manual entry even though the browser already knows the information.
7. Keyboard Operability and Focus Order
Every form field, button and link within a form must be reachable and operable with the keyboard alone, without a mouse. The tab order should match the DOM order and therefore the visual reading direction. Positive values in the tabindex attribute, such as tabindex="1", pull fields out of this natural order and create navigation that is completely unpredictable for screen reader users. Only tabindex="0", to bring a naturally non-focusable element such as a div into the tab order, and tabindex="-1", to make an element focusable specifically via JavaScript without including it in the tab order, are permitted.
The visible focus indicator, by default a blue outline in the browser, must never be removed with outline: none without being replaced by an at least equally visible custom focus style. This exact mistake is one of the most common in custom-styled forms and violates WCAG Success Criterion 2.4.7 (Focus Visible). After a failed submission, focus should be actively moved via JavaScript to the first invalid field or to the error summary, so that keyboard and screen reader users do not have to search the form themselves to find where the problem is.
8. Practical Example: Accessible Checkout Address Form
The previous rules only work in combination: label/for association as the foundation, fieldset/legend to group the shipping address, a combined visual and programmatic required field indication, and aria-invalid plus aria-describedby for error messages. The following example shows a complete address form in the typical Hyva structure with Tailwind classes and a lean Alpine.js component for client-side validation, without sacrificing a single semantic element.
It is important that the Alpine.js logic only sets additional ARIA attributes reactively, but never replaces the underlying HTML structure. Every inline <script> element must subsequently be allowed for the Content Security Policy in Hyva via $hyvaCsp->registerInlineScript(). That way the form stays operable even if JavaScript fails to load for any reason, because required and the browser's native form validation keep working independently of Alpine.js.
<!-- phtml: accessible checkout shipping address form (Hyva Theme + Alpine.js) -->
<form
x-data="checkoutForm()"
@submit.prevent="submitForm()"
novalidate
class="max-w-xl"
>
<!-- Error summary, announced immediately after a failed submit -->
<div
x-show="submitted && Object.keys(errors).length > 0"
x-ref="errorSummary"
role="alert"
tabindex="-1"
class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4 text-red-800"
>
<p class="font-semibold mb-1">Please correct the following fields:</p>
<ul class="list-disc list-inside text-sm">
<template x-for="(message, field) in errors" :key="field">
<li x-text="message"></li>
</template>
</ul>
</div>
<fieldset class="border border-gray-300 rounded-lg p-4 mb-6">
<legend class="px-2 font-semibold text-gray-800">Shipping Address</legend>
<div class="mt-2">
<label for="ship-fullname" class="block text-sm font-medium text-gray-700 mb-1">
Full Name <span class="text-red-600" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input
type="text" id="ship-fullname" name="ship_fullname"
autocomplete="name" required aria-required="true"
:aria-invalid="hasError('fullname') ? 'true' : 'false'"
:aria-describedby="hasError('fullname') ? 'ship-fullname-error' : null"
@blur="validateField('fullname', $event.target.value)"
class="w-full rounded-lg border border-gray-300 px-3 py-2"
>
<p x-show="hasError('fullname')" id="ship-fullname-error" x-text="errors.fullname"
class="mt-1 text-sm text-red-600"></p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div class="sm:col-span-2">
<label for="ship-street" class="block text-sm font-medium text-gray-700 mb-1">
Street <span class="text-red-600" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input type="text" id="ship-street" name="ship_street" autocomplete="address-line1"
required aria-required="true" class="w-full rounded-lg border border-gray-300 px-3 py-2">
</div>
<div>
<label for="ship-housenumber" class="block text-sm font-medium text-gray-700 mb-1">
House Number <span class="text-red-600" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input type="text" id="ship-housenumber" name="ship_housenumber" autocomplete="address-line2"
required aria-required="true" class="w-full rounded-lg border border-gray-300 px-3 py-2">
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div>
<label for="ship-postcode" class="block text-sm font-medium text-gray-700 mb-1">
Postal Code <span class="text-red-600" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input
type="text" id="ship-postcode" name="ship_postcode"
autocomplete="postal-code" inputmode="numeric" required aria-required="true"
:aria-invalid="hasError('postcode') ? 'true' : 'false'"
:aria-describedby="hasError('postcode') ? 'ship-postcode-error' : null"
@blur="validateField('postcode', $event.target.value)"
class="w-full rounded-lg border border-gray-300 px-3 py-2"
>
<p x-show="hasError('postcode')" id="ship-postcode-error" x-text="errors.postcode"
class="mt-1 text-sm text-red-600"></p>
</div>
<div class="sm:col-span-2">
<label for="ship-city" class="block text-sm font-medium text-gray-700 mb-1">
City <span class="text-red-600" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input type="text" id="ship-city" name="ship_city" autocomplete="address-level2"
required aria-required="true" class="w-full rounded-lg border border-gray-300 px-3 py-2">
</div>
</div>
<div class="mt-4">
<label for="ship-country" class="block text-sm font-medium text-gray-700 mb-1">
Country <span class="text-red-600" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<select id="ship-country" name="ship_country" autocomplete="country"
required aria-required="true" class="w-full rounded-lg border border-gray-300 px-3 py-2">
<option value="DE">Germany</option>
<option value="AT">Austria</option>
<option value="CH">Switzerland</option>
</select>
</div>
</fieldset>
<button type="submit" class="rounded-lg bg-zinc-800 text-white font-semibold px-6 py-3">
Save Address
</button>
</form>
Mironsoft
Accessible forms, WCAG audits and Hyva implementation
Forms that everyone can actually use?
We audit your checkout and contact forms for label association, fieldset grouping, required field indication and error handling, and implement the fixes directly in Magento and Hyva.
Form Audit
Screen reader testing with NVDA and VoiceOver plus automated axe analysis
Refactoring
Retrofit label/for, fieldset/legend and aria attributes cleanly
Hyva Implementation
Accessible Alpine.js components for validation and error display
9. Forms Compared: Common Mistakes and Solutions
The following overview compares the most common accessibility mistakes in forms with the corresponding accessible solutions. Each row describes a pattern that regularly shows up in Magento and Hyva checkouts, and shows which minimal change actually makes a field usable for screen reader users.
| Task | Inaccessible | Accessible Solution | Benefit |
|---|---|---|---|
| Label association | placeholder instead of a label |
<label for> with a matching id |
Screen reader reads out the field name |
| Field group | Heading via div/h3 |
fieldset + legend |
Group context announced with every field |
| Required field | Red asterisk in text only | required + aria-required + sr-only text |
Unambiguous "required" announcement |
| Error message | Red border only | aria-invalid + aria-describedby |
Error reason gets read out |
| Focus indicator | outline: none without a replacement |
:focus-visible with a visible outline |
Keyboard position stays identifiable |
| Tab order | tabindex="1", "2", "3" |
DOM order, tabindex="0"/"-1" |
Predictable navigation |
In practice, almost all of these patterns can be caught automatically with tools like axe DevTools or WAVE, before a manual screen reader test is even necessary. Manual testing with NVDA on Windows or VoiceOver on macOS remains indispensable, though, because automated scanners reliably detect missing label associations but cannot judge whether an error message is actually understandable in content.
10. Summary
Semantically correct forms stand and fall with a few clearly defined rules: connect label and input field programmatically via for/id, group related fields in fieldset with legend, indicate required fields via required/aria-required plus visible and screen reader readable text, and communicate errors via aria-invalid/aria-describedby instead of relying on color alone. Each of these rules addresses a concrete gap that stays invisible to sighted users but determines the usability of the entire form for screen reader users.
The biggest lever is building these rules into Hyva components from the start, instead of patching them into existing checkout forms afterward. An address form that correctly combines label/for, fieldset/legend, required field indication and Alpine.js supported error handling from the beginning stays maintainable and WCAG compliant through future extensions, without every new checkout variant needing to be re-checked for accessibility.
Structuring Forms Semantically Correctly, the Essentials at a Glance
label/for
Every input field needs a programmatically connected label. A placeholder never replaces a real label.
fieldset/legend
Related fields such as a shipping address or radio button groups need a shared group context.
Required Fields
required + aria-required + visible and readable hint text instead of just an asterisk.
Errors & Focus
aria-invalid/aria-describedby for error messages, actively move focus to the first error.