What Your Contact Form Sounds Like to a Screen Reader
A form is announced as a sequence of names, states and descriptions. Here is what each control sounds like, the four things that decide whether yours is usable, and why a validation error can be obvious on screen and silent in audio.
The ShipMyForm team
· 6 min read
A screen reader announces each form control as a short sentence: its name, its role, its state, then its description. Everything that decides whether your form is usable comes down to what ends up in that sentence. A label supplies the name, a fieldset supplies the question a group of options belongs to, aria-describedby supplies the hint, and aria-invalid plus moved focus is what turns a validation error from a red outline into something anyone can hear.
Full disclosure: ShipMyForm is our product, and we mention our form checker near the end for the mechanical part of this. The markup is standard HTML and works with any backend.
The sentence your markup is writing
Most accessibility advice is a list of attributes with no explanation of what they do, which makes it impossible to reason about a form you did not write. The useful mental model is that each control is announced as roughly:
name, role, state, description
A properly marked email field is heard as something like:
"Email, edit text, required, we only use this to reply"
Four separate pieces of markup produced that. The name came from the <label>. The role came from <input type="email">. The state came from required. The description came from aria-describedby pointing at the hint text. None of it came from how the field looks.
That is the whole discipline: anything expressed only through colour, position or size is not in that sentence. A red border says "invalid" to one person and nothing to another.
The label is the name, and nothing else is
A control with no accessible name is announced as "edit text" with no indication of what belongs in it. There are four ways to give it one, and they are all fine:
<!-- 1. A label pointing at the id — the most flexible for layout -->
<label for="email">Email</label>
<input id="email" name="email" type="email">
<!-- 2. Wrapping, which needs no ids at all -->
<label>Email <input name="email" type="email"></label>
<!-- 3. aria-label, when there is genuinely no visible text to use -->
<input name="q" type="search" aria-label="Search">
<!-- 4. aria-labelledby, when existing visible text is the name -->
<h2 id="billing">Billing address</h2>
<input name="street" aria-labelledby="billing street-hint">What is not fine is a placeholder standing in as the name. It vanishes the instant someone types, so anyone who is interrupted, distracted, or simply checking their answers before submitting has lost the question. It renders in low-contrast grey by default. And whether it is announced at all depends on the screen reader and browser pairing. Keep the placeholder for an example of the format ([email protected]), and give the field a real label.
Visually hiding a label is legitimate when the design has no room for it, but hide it the accessible way, not with display: none:
/* Removed from the page visually, still in the accessibility tree.
display:none and visibility:hidden remove it from both. */
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}A group of options needs the question attached
This is the failure I see most often in otherwise careful forms. Each radio has a label, so nothing looks wrong, and the group is still unusable:
<!-- Heard as "Monthly, radio button, 1 of 3" — one of what? -->
<p>Billing period</p>
<label><input type="radio" name="period" value="monthly"> Monthly</label>
<label><input type="radio" name="period" value="yearly"> Yearly</label>That <p> is decoration as far as the accessibility tree is concerned. The question has to be bound to the group:
<fieldset>
<legend>Billing period</legend>
<label><input type="radio" name="period" value="monthly"> Monthly</label>
<label><input type="radio" name="period" value="yearly"> Yearly</label>
</fieldset>Now each option is announced with the question: "Billing period, Monthly, radio button, 1 of 2". The same applies to a set of related checkboxes, and to a single checkbox whose meaning depends on a heading above it.
Hints belong in the description, not the name
A field often needs a note: a format, a reason you are asking, a character limit. Putting that in the label makes the name unwieldy, because the whole thing is then re-announced every time focus returns to the field. aria-describedby is the right slot:
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel"
autocomplete="tel"
aria-describedby="phone-hint">
<p id="phone-hint" class="hint">Only if you would rather we called than emailed.</p>aria-describedby takes a list of ids, which is what makes it the right mechanism for errors too. More than one id is read in order:
aria-describedby="phone-hint phone-error"Required and invalid are states, not colours
required is announced, and it also gets you the browser's own validation. An asterisk on its own is not reliably announced and does not mean anything to someone who has not been told the convention, so say the word somewhere, even if visually hidden.
For a field that has failed validation, the state that matters is aria-invalid:
<input id="email" name="email" type="email" required
aria-invalid="true"
aria-describedby="email-error">
<p id="email-error" class="error">Add the part after the @, like [email protected].</p>Set aria-invalid when validation fails and remove it when the field is corrected. Without it, the error text may be read while the field itself still sounds fine.
The error nobody hears
Here is the most common accessibility bug in forms, and it is invisible in code review because the markup is correct: someone submits, the page draws three red error messages, and a screen reader user is told nothing at all. Focus is still on the submit button, no state changed under it, and new text appearing elsewhere on the page is not announced by default.
Three things fix it, and you want all three.
Announce that something happened. A container with role="alert" (or aria-live="assertive") is read when its contents change:
<div role="alert" id="form-error">
<!-- filled in on a failed submit -->
</div>Move focus to where the problem is. For a short form, focus the first invalid field. For a long one, render a summary at the top and focus that, with each item a link to the field:
form.addEventListener("submit", (event) => {
const invalid = form.querySelectorAll("[aria-invalid='true']");
if (invalid.length === 0) return;
event.preventDefault();
// Focusing the field means its name, state and error are all announced.
invalid[0].focus();
});Keep the message in the field's description, so it is still there when someone tabs back later. An alert is announced once; aria-describedby persists.
required, type="email" and pattern give you announced states and focus
handling with no JavaScript, because the browser implements this properly. If
you replace native validation with your own, you are taking on the announcing
and the focus management yourself. Plenty of teams do that and then ship only
the visible half.
A form with all of it in place
<form action="https://shipmyform.com/f/YOUR_FORM_ID" method="POST">
<div role="alert" id="form-status"></div>
<label for="name">Name</label>
<input id="name" name="name" type="text" required autocomplete="name">
<label for="email">Email</label>
<input id="email" name="email" type="email" required
autocomplete="email" aria-describedby="email-hint">
<p id="email-hint">We only use this to reply.</p>
<fieldset>
<legend>How should we get back to you?</legend>
<label><input type="radio" name="reply" value="email" checked> Email</label>
<label><input type="radio" name="reply" value="phone"> Phone</label>
</fieldset>
<label for="message">Message</label>
<textarea id="message" name="message" required
aria-describedby="message-hint"></textarea>
<p id="message-hint">A couple of sentences is plenty.</p>
<!-- Hidden from everyone, including assistive tech: bots only -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
aria-hidden="true" style="display:none">
<button type="submit">Send</button>
</form>Note the honeypot carries aria-hidden="true" as well as display: none. A field a human must leave empty should not be announced to anyone, and a screen reader user filling in a hidden field they were told about is a real way to get a legitimate submission classified as spam.
What a checker can tell you, and what it cannot
The mechanical failures are worth automating, because they are the ones that slip in during a redesign. Our HTML form checker flags a control with no accessible name (counting all four mechanisms above), a placeholder doing a label's job, and a field typed as text when email or tel would carry the right state. Paste your markup in; it runs in the browser.
What it cannot do is the part that matters most:
- Whether your label is meaningful.
<label>Field 1</label>passes every automated check. - Whether the focus order matches the visual order, which depends on CSS, not markup.
- Whether your error text tells someone what to actually do.
- Whether a group has the right question attached, as opposed to any
legendat all. - How any of it sounds in sequence.
So use the checker for the floor and your ears for the ceiling. On macOS, VoiceOver is already installed: Cmd+F5, then tab through your own form with your eyes shut. On Windows, NVDA is free. You do not need to be an expert to hear a field announced with no name, or to notice that submitting an incomplete form told you nothing. Two minutes of that finds more than an afternoon of reading attribute references.
The short version
- Every control has a
<label>, or a deliberatearia-label. - Radio and checkbox groups live in a
fieldsetwith alegend. - Hints and errors go in
aria-describedby, not in the label. requiredandaria-invalidcarry state; colour never carries it alone.- A failed submit announces something and moves focus.
- The honeypot is hidden from assistive tech too.
Next: form UX decisions that change completion rates for the design side of the same form, and validation timing and error copy for the words themselves.
Frequently asked questions
- Is a placeholder enough of a label?
- No. A placeholder disappears the moment someone types, which strands anyone who is interrupted or reviewing their answers; it is rendered in low-contrast grey by default; and support for announcing it varies by screen reader and browser pairing. It is a hint, not a name. Use a <label> and keep the placeholder for an example of the format, or drop it.
- What does a screen reader announce for a form field?
- Roughly: its accessible name, its role, its state, then its description. So a well-marked email field is announced as something like "Email, edit text, required, we only use this to reply". The name comes from the label, the state from attributes like required and aria-invalid, and the description from aria-describedby. Anything you only express with colour or position is not in that sentence.
- Why do radio buttons and checkboxes need a fieldset and legend?
- Because an individual option's label is meaningless without the question. Heard on its own, "Monthly, radio button, 1 of 3" does not say what is being chosen. A fieldset with a legend attaches the question to every option in the group, so it is announced as part of each one.
- How should a form announce a validation error?
- Three things together: put the message in the field's accessible description with aria-describedby so it is read when the field is focused, mark the field aria-invalid="true" so the state is announced, and move focus to the first invalid field (or to an error summary that links to each one) on submit. A message that merely appears on screen is silent.
- Can an automated checker tell me if my form is accessible?
- It can tell you about the mechanical failures — a control with no label, a placeholder standing in for one, a field typed as text when it should be email. It cannot tell you whether your label is meaningful, whether the focus order matches the visual order, whether your error text explains what to do, or how any of it sounds in practice. Automated checks catch the floor, not the ceiling.
- How do I test a form with a screen reader myself?
- On macOS, VoiceOver is built in: Cmd+F5 to start, then Tab through your form with your eyes closed. On Windows, NVDA is free. You do not need expertise to learn something useful — tabbing through your own form and listening for a field announced with no name, or an error you never hear, finds most of what is wrong in about two minutes.
Related guides
Form UX: The Decisions That Actually Change Completion Rates
Most form UX advice is decoration. These are the decisions that measurably change whether people finish: field count, labels, validation timing, error copy, and letting the browser help.
Form Validation UX: When to Validate, and Error Messages That Actually Help
Validation timing and error copy decide whether a form feels helpful or hostile. The three moments to validate, a formula for messages people can act on, the ARIA that wires them up, and a complete working example.
HTML Contact Form Template That Emails You (Copy and Paste)
Ready-to-use HTML email form code: a minimal form, a full contact form with honeypot and subject, and a file-upload version. Paste, set one attribute, and submissions land in your inbox.