omeryanbas.com

Ömer Yanbaş

General Manager, Ticofab Yazılım

WebPractice

Two accessibility mistakes almost every site makes

Heading levels chosen to match the layout, and an aria-label that hides the visible text. Both break keyboard and voice users, and both are cheap to fix.

A site passes the automated check with a good score. Contrast is fine, every image has alt text, every input has a label. Then someone opens it with a screen reader and the outline of the page is nonsense, and someone who drives the browser by voice says the name of a button out loud and nothing happens. Two mistakes cause most of that, and neither of them shows on screen: heading levels picked to match the layout, and an aria-label that does not contain the words printed on the control.

What actually happens

Headings are an outline, not a type scale

A screen reader can list every heading on a page, and that list is how most people using one move through a long document. They read the outline first, pick a section and jump into it. The levels are the structure of that outline: h2 is a section of the page, h3 is a part of that section, and a jump from h2 to h4 reads as a section that lost its opening.

The levels almost never get broken on purpose. They get broken because a heading needed to look smaller. A sidebar title is set as h4 because h4 was styled at the right size, and a card title is set as h2 because the design wanted it large. The page looks correct and the outline is scrambled.

The other half of the problem is order. In a two column layout the narrow column often sits first in the markup, because that was the easiest way to get it on the left. Visually it reads as secondary. In the document it comes before the main heading, so the outline opens with the side note.

The accessible name has to contain the visible label

aria-label does not add a description to a control. It replaces the name of that control entirely. Whatever is written inside the button stops being the name as far as assistive technology is concerned.

That is a problem for people who use speech control. They look at the screen, read what is written on the control and say it. The software matches the spoken words against the accessible name. A button that reads Send but carries aria-label="Submit the contact form" has no match, so the command fails with no explanation. The user can see the button and cannot operate it.

It is also confusing with a screen reader in a magnified window, where the visible text and the spoken text are both present and disagree. The requirement that covers this is short: the accessible name must contain the visible text, in the same order.

How to see it

Print the heading tree from the console. It takes ten seconds and it is more honest than reading the markup:

let previous = 0;
for (const h of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
  const level = Number(h.tagName[1]);
  const flag = previous && level > previous + 1 ? '  [skipped level]' : '';
  console.log(`${'. '.repeat(level - 1)}h${level} ${h.textContent.trim().slice(0, 50)}${flag}`);
  previous = level;
}

Read the output as a table of contents. If it does not make sense as a list of sentences, it does not make sense to anyone navigating by headings either. The accessibility tree panel in the browser dev tools shows the same thing with the computed names attached, which is useful for the second check.

For the labels, compare what is written with what is announced:

for (const el of document.querySelectorAll('[aria-label]')) {
  const visible = el.innerText.trim().toLowerCase();
  const name = el.getAttribute('aria-label').trim().toLowerCase();
  if (visible && !name.includes(visible)) {
    console.warn('label in name:', { visible, name, el });
  }
}

Controls with no visible text at all, such as an icon only button, are fine here and need a different check: they need a name, and the icon needs a tooltip so a voice user has something to say.

An automated audit belongs in the build as well, because it finds the missing names and the skipped levels faster than a person does. It cannot tell you whether the outline matches the content, and that limit is the same one you meet in the responsive images audit. The tool tells you where to look. You decide what is right.

The fix

Write the markup in reading order and place it visually afterwards. Almost every heading order bug I have fixed was a markup order bug with a CSS workaround on top, and the repair is smaller than it looks:

<main class="page">
  <h1>Pricing</h1>
  <section class="plans">
    <h2>Plans</h2>
    <h3>Team</h3>
  </section>
  <aside class="notes">
    <h2>What is included</h2>
  </aside>
</main>
.page {
  display: grid;
  grid-template-columns: 16rem 1fr;
  grid-template-areas:
    "title title"
    "notes plans";
}
.page > h1 { grid-area: title; }
.plans { grid-area: plans; }
.notes { grid-area: notes; }

/* size is a style decision, level is a structure decision */
.notes h2 { font-size: 1rem; font-weight: 600; }

The sidebar is painted on the left and read after the main section. Grid areas and flex ordering move the painting, not the document, so the keyboard and the screen reader both still follow the order you wrote.

For the labels, the shortest correct answer is usually to delete the attribute. The text inside the button was already a good name. When you genuinely need more than the visible words, keep the visible words at the front:

<button>Send</button>

<button aria-label="Send message to support">Send</button>

<button class="icon-btn" aria-label="Close" title="Close">
  <svg aria-hidden="true" focusable="false" width="16" height="16"><use href="#x"></use></svg>
</button>

The middle form is the compromise: extra context for a screen reader, and the spoken word send still matches. The icon button gets a name and a tooltip, so the name is discoverable rather than guessed.

While you are in that file, three more take a minute each:

  • Put the focus ring back. :focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } is enough, and it only appears for keyboard use. Keep it off a transition, so it lands the instant the key is pressed rather than a beat later, the same reason an entrance animation delays the largest paint.
  • Give every interactive target at least 24 by 24 CSS pixels, padding included. Small icon buttons in a toolbar are the usual offenders.
  • Add a skip link as the first focusable element, styled from your stylesheet rather than an inline style attribute, which also keeps a strict content security policy intact.

How to check it worked

Run the two console snippets again. The heading tree reads as an outline with no gaps, and the label check prints nothing:

h1 Pricing
. h2 Plans
. . h3 Team
. h2 What is included

Then put the mouse away. Press Tab from the address bar: the skip link should be the first thing that appears, every stop should be visible, and the order should follow the page you can see. That single pass finds more than any report.

What to watch out for

  • aria-label is ignored on elements with no role, such as a plain div or span. If you labelled a wrapper, nothing was announced at all.
  • Screen reader software and browsers disagree on a few details. Test the combination your audience actually uses rather than the one that is convenient on your machine.
  • A visually hidden class that uses display: none or visibility: hidden removes the text from the accessibility tree as well. Use the clip based pattern instead.
  • Heading levels inside a component depend on where the component is used. A card title that is h3 on the listing page becomes wrong when the card is dropped into a section one level deeper, so pass the level in as a prop rather than hard coding it.

Neither of these mistakes is exotic, and neither comes from not caring. They come from writing the markup to match a picture instead of writing it to match the document, and from treating aria-label as a description rather than a replacement. Check the outline and check that the announced name contains the visible one, and a whole category of complaints disappears before anyone has to file them.

Questions and answers

Does skipping a heading level really matter?
It matters to anyone who navigates by headings, which is how most screen reader users move through a long page. They pull up a list of headings and read the page as an outline, so an h2 followed by an h4 reads as a missing section. Sighted users get the same information from size and spacing, which is why the mistake is invisible during normal testing.
What is the label in name rule?
It is the requirement that the accessible name of a control contains the text a person can see on it. Speech control software matches what the user says to the accessible name, so a button that reads Send but is named Submit form cannot be clicked by voice. The practical rule is that an aria-label must start with, or contain, the visible words.
Can I fix heading order with CSS instead of moving the markup?
You can move the boxes, not the reading order. Grid areas and flex ordering change where an element is painted while the document order stays the same, and the document order is what assistive technology and the keyboard follow. So write the headings in the order they should be read and place them afterwards.
Is an automated audit enough?
It catches roughly a third of the real problems, which is still worth running on every build. It will flag a skipped heading level and a missing accessible name, and it cannot tell you whether the name makes sense or whether the outline matches the content. Pair it with a heading tree and one pass through the page using only the keyboard.