Can Blind Customers Actually Give E-Consent? Screen Reader Accessibility on a Page That Read Checked Boxes as "Not Checked"
Is electronic consent done once the customer checks and signs on screen? For a blind customer listening through a screen reader, a checked item could still be read as "not checked." Here are four problems an accessibility tree audit found on a mobile e-contract page that had already been fixed once, and how I fixed them without touching server code.

It's easy to think electronic consent is done once the customer looks at the screen, checks the boxes, and signs. But customers who can't see the screen get the same text message link. Blind customers listen to the page through a screen reader like TalkBack or VoiceOver, and if they can't confirm by ear what they're agreeing to, the consent only happened on the screen. Korean law also requires that people with disabilities get equal access to the electronic information a company produces and distributes. That's why e-consent pages need a screen reader accessibility check.
The mobile e-contract page I work on had already been patched once, in 2024, after a complaint from a blind customer. This time I pulled the accessibility tree that screen readers actually receive and found four problems still there, starting with consent checkboxes that would be read as "not checked" even after being checked. I fixed them in one shared script without touching server code or consent wording.
This post walks through that in order. It's written for front-end developers maintaining older web pages. I'll cover why the check matters, how I pulled the accessibility tree, the four problems and their fixes, how I verified them, and what I took away. One thing up front: verification is done at the accessibility-tree level. Listening to the result with TalkBack and VoiceOver on real devices is the next step and hasn't happened yet.
Why E-Consent Pages Need a Screen Reader Accessibility Check
What Korea's disability discrimination law requires
Article 21 of Korea's Act on the Prohibition of Discrimination against Persons with Disabilities and Remedy for Infringement of Their Rights sets a duty to provide reasonable accommodation in information and communication. In short, people with disabilities must be able to access and use the electronic information a business produces and distributes on equal terms with everyone else, and the business must provide the means to make that possible. The Enforcement Decree (Article 14) lists website accessibility as one of those accommodations.
Coverage started with public institutions in April 2009 and was widened in phases through a schedule in the Enforcement Decree. Falling short can lead to a complaint with the National Human Rights Commission and a corrective order from the Ministry of Justice, and the Act also has provisions on damages (Article 46) and court-ordered remedies (Article 48). Which phase applies to a given service is set out in Schedule 3 of the Decree. Outside Korea, similar obligations come from laws like the ADA or the European Accessibility Act. But before you even get to the law, if any of your customers use a screen reader, this is already a problem you have.
Consent means knowing what you're agreeing to
The reason you keep consent records is to prove what the customer confirmed before agreeing. For a screen reader user, confirming means listening. If a checked item is announced as "not checked," the customer can't tell whether they checked it. If an input has no name, they don't know what it's asking for. The checks and signature may be complete on screen, but for that customer it's consent they had no way to verify.
So I see accessibility on an e-consent page not as a convenience feature but as part of the consent process itself. Consent only counts as the same consent if a customer reading with their eyes and one listening with their ears can confirm the same content.
The Page: A Legacy Mobile E-Contract Flow
From a text message link to a signature
Customers arrive by tapping a link in a text message. On their phone they fill in an application, review the key terms of the contract, check and sign each item on an e-consent screen, and finally review the contract.
The pages are built with ASP.NET WebForms and jQuery Mobile. The server renders the page, UpdatePanel re-renders parts of it, and the consent items are drawn late, asynchronously. That structure turns out to matter a lot when choosing a fix. I covered the different roles identity verification and e-signatures play in an e-contract in my post on identity verification vs electronic signature.
Already patched once, in 2024
In 2024 a blind customer using VoiceOver filed a complaint. The problem described above came from a real customer. The page got an accessibility pass at the time, which added:
- role="checkbox" and a name (label) on the consent check elements
- a "new window" hint on the terms "View" links
- aria-hidden on the background whenever a notice popup opened
This time, while cleaning up the whole flow, I pulled the accessibility tree myself and found that those changes alone didn't make the page read correctly.
Checking Screen Reader Accessibility With the Accessibility Tree
What the accessibility tree is
Browsers don't hand the DOM straight to a screen reader. From the DOM and ARIA attributes they compute a separate accessibility tree, where each element has a role, a name, and states such as checked or expanded. TalkBack and VoiceOver read that tree.
So even if role and label are in the markup, a screen reader will announce exactly what the tree says. If the name is empty or the state never changes in the tree, that's what users hear. That's why you check the tree, not the code.
Attaching to a real device with remote debugging
Desktop Chrome's mobile emulation can show you the tree too, but I wanted something close to what customers actually use, so I connected Chrome on a physical Android phone over USB:
- Turn on developer options and USB debugging on the phone, then connect it to the PC.
- Run
adb forward tcp:9222 localabstract:chrome_devtools_remoteto forward the phone's Chrome debugging port to the PC. - Open
http://localhost:9222/jsonto get the list of open tabs and their WebSocket URLs. - Connect to that URL with the Chrome DevTools Protocol (CDP) and call
Accessibility.getFullAXTree.
That last call returns the names, roles, and states a screen reader receives. You can inspect elements one at a time in the DevTools Accessibility pane, but to count across a whole screen, pulling it through the protocol is faster.
A small audit script for names and states
From the tree I kept only buttons, links, checkboxes, and inputs, and tabulated whether each had a name and what its checked state was. Below is a short rewrite of the idea, not the actual code.
// nodes: the nodes array returned by Accessibility.getFullAXTree
const ROLES = ['button', 'link', 'checkbox', 'textbox', 'combobox', 'radio'];
function audit(nodes) {
const rows = [];
for (const n of nodes) {
const role = n.role && n.role.value;
if (n.ignored || !ROLES.includes(role)) continue;
const name = ((n.name && n.name.value) || '').trim();
const checked = (n.properties || []).find(p => p.name === 'checked');
rows.push({ role, name, checked: checked ? checked.value.value : '-' });
}
console.table(rows);
console.log('Unnamed:', rows.filter(r => !r.name).length);
}
Run it once before and once after checking the consent items, put the two tables side by side, and any state that should have changed but didn't stands out immediately. Scanning the name column also surfaces names that repeat the same words twice.
Four Problems the Accessibility Tree Revealed, and the Fixes
The ground rule: don't touch the server or the consent wording
Before fixing anything, I set the rules. In an e-contract, the consent wording and the processing flow are a legal record. What the customer saw and how they agreed has to be preserved as is, so changing it for the sake of a UI fix is off the table. I wrote about how to keep those records in my post on consent record retention.
- No changes to server code, consent wording, or consent scripts
- Only add to the one shared script the master page loads on every customer page
- Leave elements that already have a name (aria-label, label for) alone
1. Consent checkboxes that always read "not checked"
The 16 consent checkboxes had role="checkbox" and proper names. But checking them didn't change their state. The check mark was drawn purely by toggling a CSS class, on, and nothing anywhere updated aria-checked. Visually the box was checked; to a screen reader it would always be "not checked."
Adding role in 2024 was the right direction. But role="checkbox" is also a promise that you'll manage the state yourself. A native checkbox (input type="checkbox") reports its state automatically. Put the role on a div or span and keeping aria-checked in sync is your job.
To avoid touching the consent script, the least invasive option was to watch for class changes and mirror them into aria-checked. A MutationObserver watches class changes on role="checkbox" elements and sets true when on is present, false otherwise. For keyboard users I also added tabindex="0" and Space/Enter handling. A key press simply triggers the existing click handler, so the consent flow itself doesn't change.
The tricky part: consent items are drawn late and asynchronously, and UpdatePanel swaps whole elements out on partial updates. A single scan at load isn't enough. So whenever child nodes change, the script waits 50 ms and rescans. If updates arrive back to back, only the last one triggers a scan.
function syncCheckbox(el) {
el.setAttribute('aria-checked', el.classList.contains('on') ? 'true' : 'false');
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
}
function scanAll() {
document.querySelectorAll('[role="checkbox"]').forEach(syncCheckbox);
}
var timer;
new MutationObserver(function (records) {
records.forEach(function (r) {
if (r.type === 'attributes' && r.target.getAttribute('role') === 'checkbox') {
syncCheckbox(r.target); // sync the element whose class changed
} else if (r.type === 'childList') {
clearTimeout(timer); // late or swapped-in elements:
timer = setTimeout(scanAll, 50); // rescan once, 50 ms later
}
});
}).observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['class'] });
document.addEventListener('keydown', function (e) {
var el = e.target;
if (el.getAttribute && el.getAttribute('role') === 'checkbox' && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
el.click(); // reuse the existing consent handler
}
});
scanAll();
2. Buttons read twice, like "Agreed Agreed"
The "Agreed" and "Rewrite" buttons would have had their names read twice. Next to the visible text sat visually hidden text for screen reader users (a .blind class) with the same words. A screen reader reads both, so you get "Agreed Agreed."
Hiding every .blind element isn't the answer, though. The hidden "new window" text next to a "View" link adds information the visible text doesn't have. So I added one condition: set aria-hidden="true" on a .blind element only when its text matches the rest of its parent's text, and leave it alone otherwise.
document.querySelectorAll('.blind').forEach(function (b) {
var hidden = b.textContent.trim();
var rest = b.parentElement.textContent.replace(b.textContent, '').trim();
if (hidden && hidden === rest) b.setAttribute('aria-hidden', 'true');
});
In practice this also runs inside the rescan above, so it covers buttons that render late.
3. Inputs announced by their placeholder, or not at all
On the application screen, field labels were plain spans not associated with their inputs. So the name field was announced by its placeholder, "Applicant name," and the birth date field as "ex.20221031." Fields with no placeholder, like mobile carrier and gender, had no name at all. The application screen had six unnamed inputs.
A placeholder is only the browser's last-resort fallback when computing a name, and it disappears as soon as you type. When an example format is read out as if it were the name, it's hard to tell what the field is for.
Here's what the fix did:
- Give each field label (.cf-label) on the card-style screens an id, and connect any unnamed input in the same field group with aria-labelledby.
- When one field has several inputs, give each a distinct name, such as "Date of birth, 8 digits," "First digit of the back half of the resident registration number," or "Mobile carrier."
- For repeated inputs like the four card number boxes, number them: "Card number, box 1."
- Give radio groups such as gender a wrapping role="radiogroup" connected to the field label.
- Skip inputs that already have a name via aria-label or label for.
4. Notices you can't tell have opened
There were two kinds of notice windows: a hand-built modal and jQuery Mobile popups. Neither identified itself as a dialog, and opening one didn't move focus. A screen reader user had no good way to know a window had appeared.
I defined what happens on open and close:
- On open, add role="alertdialog", aria-modal="true", and aria-describedby pointing to the body text, then move focus to the OK button.
- On close, return focus to wherever it was just before the window opened.
- Exception: if the "take me to the field to fill in" feature moved focus to another input on close, that wins. If the notice is about an empty field, that field is where the user should land.
For jQuery Mobile popups I hooked the popupafteropen and popupafterclose events. For the hand-built modal I wrapped the existing show function and added the same handling before and after it. The original function's behavior stays as is; I only added steps around it.
Verification: The Same Audit, Before and After
For verification I reused the exact audit script that found the problems. I pulled the tree on the same screens with the same interactions, before and after adding the shared script, and compared.
| Item | Before | After (tree level) |
|---|---|---|
| Consent checkbox state | No change when checked | checked=true when checked |
| "Agreed" / "Rewrite" names | Read twice | Read once |
| "New window" hint on "View" links | Present | Kept |
| Unnamed inputs on the application screen | 6 | 0 |
That said, this is verification at the tree level. Having the right values in the tree is not the same as TalkBack and VoiceOver reading things the way I intend. Reading order, what actually gets announced when focus moves, and whether a notice is read out as soon as it opens all need to be heard on a real device. That check is planned for operational testing after deployment and, as of writing, hasn't been done.
What I Learned: role and label Are Only the Start
Adding attributes isn't the same as being read correctly
The 2024 pass went in the right direction. Still, whether states change and whether names get doubled can't be known just from adding attributes. It only showed up once I pulled the tree. It's especially easy to miss that once you put a role on an element yourself, managing its state is on you too.
A tree audit is a cheap, fast first filter
Testing with real screen readers on real devices is accurate but slow. You have to swipe through each screen and listen. A tree audit gives you every name and state on the screen in one script run. Empty names, duplicate names, and stuck states mostly get caught there, which leaves device testing to focus on things only a person listening can judge, like reading order and flow.
The older the page, the safer it is to add rather than change
Changing server code or markup widens the blast radius, and on pages tied to legal records like consent wording, changing anything is a burden in itself. Adding to one shared script is also easy to roll back: if something goes wrong, you remove just the added part.
There are limits. For a brief moment before the script runs, the page is in its old state, and fundamentally it would be better to rebuild with native elements (input, label). Adding on top is what you do when rebuilding isn't an option.
Visually hidden text should only add information
.blind text earns its place when it says something the visible text doesn't, like "new window." Repeating the visible text in hidden text isn't help; it's noise.
A Screen Reader Accessibility Checklist for Legacy Pages
Here's an order you can follow right away when fixing screen reader accessibility on an older page:
- Attach to a real device (or desktop Chrome) via CDP and pull the tree with Accessibility.getFullAXTree
- Count unnamed buttons, links, checkboxes, and inputs
- For custom elements like role="checkbox", check that state values change before and after interaction
- Look for names that repeat the same words twice
- Find inputs announced by their placeholder instead of a name
- Check that popups and modals identify as dialogs and move focus when they open
- When fixing, consider adding on top first, without touching the server or wording
- Compare before and after with the same audit, and schedule real-device screen reader checks separately at the end
Wrapping Up
Electronic consent isn't finished when the boxes are checked and the signature is on screen. A customer who listens to the page has to be able to confirm what they're agreeing to as well, or it isn't the same consent. The biggest thing I took from this work is how to check that: don't stop at adding attributes, and add one more step where you pull the accessibility tree and check what actually landed there. That one step turned up four problems (checkbox states, duplicate names, empty names, and notice windows) on a page that had already been fixed once. Screen reader accessibility is done only after checking once in the tree and once by ear on a real device. This page has passed the first of those checks, and listening on real devices is the next step.