This is the contact form on my own portfolio site, the first project where I used JavaScript. I built it with an AI design partner, which reviewed my code, explained concepts, and helped me debug. The code is in the repo and the site is live. This post is about one part of the form: the message that appears after you press "Send message", and why it took two attempts to get a screen reader to announce it.
The form validates in JavaScript, then sends the data to Formspree with fetch(). After that, the page tells the visitor one of two things: the message went through, or something went wrong. A sighted visitor sees the text appear. A screen reader user only hears it if the page announces it, which is what aria-live is for.
First attempt: aria-live on the paragraph I was already creating
At that point my code built the success and failure messages as new paragraphs. Create the element, set its text, add aria-live="polite", append it to the page. I chose polite over assertive because a form result isn't urgent enough to interrupt whatever the screen reader is in the middle of reading.
What VoiceOver did
I tested it with VoiceOver. The message appeared on screen, but VoiceOver didn't read it when I submitted. It read it only when I moved onto it myself, by hovering or navigating. The attribute was in the code and the text was right, and the result was still silence at the moment it mattered.
The explanation we settled on
Working through it with my design partner, the explanation came out like this. A live region announces changes to content inside a region that already exists in the accessibility tree. A paragraph added with appendChild arrives as a new element with its content already inside it, and screen readers don't consistently treat that as a change in a live region, even when the attribute is set. So the attribute was correct and the setup around it was wrong.
The fix: a region that exists before its content does
One permanent, empty paragraph in the HTML, present from page load:
<p id="form-status" aria-live="polite"></p>
I stopped creating and removing elements entirely. The two result functions now only change the text and class of that one element:
function onFormSuccess () {
submitStateMessage.textContent = 'Thanks for reaching out! We have received your message and will get back to you';
submitStateMessage.classList.remove('error-status-message');
submitStateMessage.classList.add('success-message');
}
function onFormFailure (){
submitStateMessage.textContent = 'Something went wrong on our end. Try again, or reach out directly using the links below.';
submitStateMessage.classList.remove('success-message');
submitStateMessage.classList.add('error-status-message');
}
The region itself never moves. Only what's inside it changes. I ran it in VoiceOver again, and on a successful submission the message was announced immediately, without me navigating to it.
The bug I introduced while wiring it up
Those two functions each remove the other state's class before adding their own, and they only do that now because the first version didn't. It added classes and never removed any, so a failure right after a success left both success-message and error-status-message on the element at once instead of swapping. The validation-failure branch had the same hole. It cleared the text but left whichever class the last attempt had set. Now it clears both:
submitStateMessage.textContent = "" ;
submitStateMessage.classList.remove('success-message', 'error-status-message');
This is the same kind of bug I'd already fixed earlier in the same form, where an invalid-email message stayed on screen after the field was cleared. Both were leftover state from a previous attempt. I tested this fix by triggering a failure straight after a success and checking that only the failure state was showing.
What this doesn't show
I tested with VoiceOver only. I haven't tried NVDA, JAWS, or TalkBack, so I can't say whether they handle an appended live region differently.
The three field error spans also have aria-live="polite". They already existed in the HTML, and they announced correctly when I tested them in VoiceOver. They're hidden with display: none and revealed by JavaScript, and I haven't worked out why they announced correctly when the appended paragraph didn't.
I haven't run an automated checker against this, and I don't know of one that would have caught it. A tool can tell you the attribute exists. It can't tell you whether anything was announced. I ran into the same limit with Lighthouse in an earlier post, and here too I only found the problem by listening.
Top comments (0)