Structuring Forms Semantically Correctly
AI generated
A11Y
WCAG
Accessibility · WCAG 2.1 · ARIA · Forms
Structuring Forms Semantically Correctly
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.

13 min read label/for · fieldset/legend · ARIA · Checkout Magento 2.4.8 · Hyva Theme · Alpine.js

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.

11. FAQ: Structuring Forms Semantically Correctly

1Why is placeholder text not enough as a label?
Placeholders disappear on focus, often lack contrast, and are read unreliably by some screen readers. A real label stays permanently available.
2How do you correctly connect a label to an input field?
Via matching for/id values, or by nesting the input field inside the label element. Both create the same programmatic connection.
3When do I need to use fieldset and legend?
For related fields like an address, or groups of radio buttons/checkboxes. legend provides the group context that would otherwise only be visual.
4How do I indicate required fields accessibly?
required + aria-required="true" plus a visible asterisk and an additional sr-only text "(required)" inside the label.
5What do aria-invalid and aria-describedby do?
aria-invalid signals the error state, aria-describedby points to the error message. Together the screen reader reads out status and reason.
6Why does autocomplete matter for accessibility?
Part of WCAG 1.3.5, reduces manual entries significantly, especially important for users with motor or cognitive impairments.
7Am I allowed to remove the focus outline with outline: none?
Only with an equally visible replacement like :focus-visible. Removing it without a replacement violates WCAG 2.4.7 and breaks keyboard usability.
8Where should focus move after a failed submission?
To the first invalid field or to an error summary with role="alert" at the top of the form, so no one has to search manually.
9Which tabindex values are allowed?
Only "0" and "-1". Positive values like "1" pull fields out of the natural DOM order and should be avoided.
10Which tools do I use to test forms for accessibility?
Automated with axe DevTools or WAVE, manual testing with NVDA or VoiceOver to check whether error messages are actually understandable.