Writing Accessible HTML Forms With LLMs in October 2026: Label Every Field, Announce Errors in Words, and Test With a Keyboard and Screen Reader

Ask a language model for a signup form and you will get one in seconds. It will usually look fine. The trouble shows up later, when someone tabs through it with a keyboard, uses a screen reader, or zooms to 200% on a phone. Generated forms tend to repeat the same handful of accessibility mistakes, and those mistakes are invisible in a quick visual check.

The good news is that the fixes are well understood and mostly mechanical. This guide covers how to prompt for accessible form markup, what to check in every draft, and how to test the result before it ships.

A simple web form with labeled text fields, checkboxes, radio buttons, and a submit button

A basic web form with text fields, checkboxes, and radio buttons. Image: Cburnett, CC BY-SA 3.0, via Wikimedia Commons.

Why generated forms go wrong

Models learn from a huge amount of real-world HTML, and a lot of real-world HTML is inaccessible. The most common patterns they reproduce are:

  • Placeholder text used as the label. The hint disappears as soon as the user starts typing, and many screen readers treat it inconsistently.
  • Clickable div elements instead of real buttons. A div with an onclick handler cannot be reached with the Tab key and is not announced as a button.
  • Error messages that only change color. A red border tells a colorblind user nothing, and a screen reader user may never hear that anything went wrong.
  • ARIA sprinkled on top of the wrong elements. Extra role and aria-* attributes on native controls often add confusion rather than fixing anything.
  • Radio buttons and checkboxes without a group label. The user hears "Yes" and "No" with no idea what question they answer.

None of these break the form for a mouse user with good eyesight, which is exactly why they survive a casual review.

Prompt for the constraints, not just the fields

A prompt like "make a contact form with name, email, and message" leaves every accessibility decision to the model. State the rules up front instead. A prompt that works well looks like this:

Write the HTML for a contact form with: full name (required),
email (required), phone (optional), preferred contact method
(email or phone, radio buttons), and message (required).

Rules:
- Use native elements only: form, label, input, select,
  textarea, fieldset, legend, button. No clickable divs.
- Every control gets a visible <label for="...">. Do not use
  placeholder as a label.
- Group the radio buttons in a fieldset with a legend.
- Mark required fields with the required attribute and say
  "(required)" in the label text, not only with an asterisk.
- Add autocomplete attributes for name, email, and tel.
- Put hint text in an element referenced by aria-describedby.
- Use ARIA only where no native element does the job.
- Show how an error message for the email field is linked to
  the input and announced.

Naming the rules does two things. It steers the model away from its most common habits, and it gives you a checklist to review the output against.

What a good result looks like

Here is the kind of markup to aim for, shown for the email field and the radio group:

<form action="/contact" method="post" novalidate>
  <div>
    <label for="email">Email (required)</label>
    <input id="email" name="email" type="email"
           autocomplete="email" required
           aria-describedby="email-hint email-error">
    <p id="email-hint">We only use this to reply to you.</p>
    <p id="email-error" class="error" hidden></p>
  </div>

  <fieldset>
    <legend>Preferred contact method</legend>
    <input type="radio" id="pref-email" name="pref" value="email" checked>
    <label for="pref-email">Email</label>
    <input type="radio" id="pref-phone" name="pref" value="phone">
    <label for="pref-phone">Phone</label>
  </fieldset>

  <button type="submit">Send message</button>
</form>

A few details matter here. The for attribute on each label matches the id of its input exactly, so clicking the label focuses the field and assistive technology reads the label when the field gets focus. The autocomplete value lets browsers and password managers fill the field, which helps everyone and especially people with motor or memory impairments. The submit control is a real button with type="submit", so it works with the Enter key and the Space bar without any JavaScript.

Handle errors so everyone notices them

Error handling is where generated forms most often fall short, because it involves both markup and script. A sound pattern is:

  1. Write the error in words. "Enter an email address in the format name@example.com" is useful. A red outline alone is not.
  2. Link the message to the field. Reference the error element from the input's aria-describedby, as in the example above, so it is read when the field is focused.
  3. Mark the field as invalid. Set aria-invalid="true" on the input while the error stands, and remove it once the value is fixed.
  4. Move focus on submit. When a submit fails, either move focus to the first invalid field or show an error summary at the top of the form with links to each problem field, and move focus to that summary.
  5. Do not shout on every keystroke. Validating as the user types and announcing each change through a live region is noisy. Validate when the field loses focus or on submit.

Here is a small script for the email case that follows those steps:

const form = document.querySelector('form');
const email = document.getElementById('email');
const error = document.getElementById('email-error');

form.addEventListener('submit', (event) => {
  if (!email.validity.valid) {
    event.preventDefault();
    error.textContent =
      'Enter an email address in the format name@example.com';
    error.hidden = false;
    email.setAttribute('aria-invalid', 'true');
    email.focus();
  }
});

email.addEventListener('blur', () => {
  if (email.validity.valid) {
    error.hidden = true;
    error.textContent = '';
    email.removeAttribute('aria-invalid');
  }
});

The novalidate attribute on the form turns off the browser's built-in error bubbles so your own messages are the only ones shown. If you would rather keep the browser's native validation, drop novalidate and the custom script, but then test how those native messages read in each browser you support, because they vary.

Review checklist for every generated form

Before accepting a draft, read through the markup and confirm each of these:

  • Every input, select, and textarea has a visible label whose for matches its id, and every id is unique on the page.
  • No label exists only as placeholder text.
  • Related radio buttons and checkboxes sit in a fieldset with a legend.
  • Buttons are button elements, and links are only used for navigation.
  • Required fields say so in text, not only through an asterisk or color.
  • Input types fit the data: email, tel, url, and so on, which also brings up the right keyboard on phones.
  • Every aria-describedby and aria-labelledby points to an id that actually exists. Models sometimes invent references to elements they never wrote.
  • Any role attribute on a native control is questioned and usually removed.
  • Focus styles are visible. Watch for outline: none in generated CSS without a replacement style.
  • Text and borders have enough contrast against the background, including error text and disabled states.

Test it like a user would

Reading markup catches a lot, but a few minutes of hands-on testing catches the rest.

Keyboard only

Put the mouse aside. Press Tab from the top of the page and check that focus reaches every field and button in a sensible order, that you can always see where focus is, that radio groups move with the arrow keys, and that you can submit with Enter. If any control cannot be reached or operated, that is a blocking bug.

Automated scanning

Run an automated checker such as axe DevTools, Lighthouse in Chrome DevTools, or WAVE against the page. These tools are good at finding missing labels, broken ARIA references, duplicate IDs, and low contrast. They cannot judge whether a label or error message actually makes sense, so treat a clean scan as a minimum, not a pass. If you already run end-to-end tests, the open-source axe-core library can be added to them so these checks run on every change.

A screen reader pass

A refreshable braille display, one of the devices screen reader users rely on to read web forms

A refreshable braille display. Image: Eddau, CC0, via Wikimedia Commons.

Use the screen reader built into your system, such as VoiceOver on macOS and iOS or Narrator on Windows, or the free NVDA on Windows. Tab through the form and listen. Each field should announce its label, its type, whether it is required, and its hint. Then submit with an invalid email and confirm you hear the error. This step usually takes under ten minutes once you know the basic keys, and it finds problems nothing else will.

Zoom and small screens

Zoom the browser to 200% and narrow the window. Labels should stay next to their fields, nothing should overlap, and you should not need to scroll sideways to fill in the form.

Using the model as a reviewer

Models are also reasonably useful at reviewing existing forms. Paste your current markup and ask for a list of issues against specific criteria, such as "missing or mismatched labels, placeholder-only labels, ungrouped radio buttons, non-button controls, and broken aria-describedby references." Ask it to quote the exact line for each finding. Then verify each claim yourself, because a model can flag problems that are not there or miss ones that are. Treat its review as a fast first pass that points you at lines to check, not as an audit.

The short version

Generated form markup is a good starting point and a poor finished product. Put the accessibility rules in the prompt, prefer native HTML over ARIA, write errors in words and link them to their fields, and then test with a keyboard, an automated scanner, and a real screen reader. That routine takes far less time than retrofitting a form after users report they cannot get through it.

Comments

Popular posts from this blog

Grok Bot - a step closer to AGI

Structured Outputs and Constrained Decoding for Reliable LLM APIs in Production (September 2026)

Tencent Hy4 Preview: Open 770B MoE Built for Real Work