Hello, and thank you so much for having me. My name is Manuel Matuzovic.

I’m a consultant, frontend developer, accessibility auditor, teacher, and speaker from Vienna, based in Graz. I specialize in HTML, CSS, and web accessibility, and I also wrote a book about these topics called “The Web Accessibility Cookbook”.

I’ve worked with a range of small and large companies, universities, and publications. I love working on the web and on the frontend, and today I’m here to talk about the frontend, especially accessibility. If you’ve been to a conference before, I assume you’ve also watched a talk about accessibility, and I imagine you learned how to describe images properly or structure your pages using headings. That’s important to know, but I feel like I’m in front of an educated, informed crowd, and I don’t want to repeat the basics. That is why, for my talk today, I tried to come up with stuff you may not already know.

And that’s why I’ve collected 19.5 things you didn’t know about accessibility in HTML and CSS. Now, of course, I could be wrong, and you already know all or most of these things. If so, please find me after my talk or at the after-show party and I’ll give you a high-five. Alright, let’s get into it.

I built a little demo where you can see something that looks like a blog post. At the top of the page, you have a header with a navigation, and there’s a small cookie banner. When I start scrolling, the header stays fixed at the top of the page, and the cookie banner stays at the bottom right. The cookie banner is a bit hard to spot, but overall that design is fine.

It gets problematic, though, when users zoom in. At about 200% zoom, the header and the cookie banner cover large parts of the screen. You can remove the cookie banner, but at 400% zoom, the header covers almost 50% of the screen’s height. The good news is that we can address that if we understand what happens in the browser when the user zooms in.

Viewports are pretty complex, but here’s a simplified explanation of what happens when the user zooms in on the browser. What you see here is a demo by my friend Bramus Van Damme. It looks wild, but it essentially logs the window’s inner and outer size. By default, the inner width and the outer width are the same. In this example, 2000px. The heights aren’t identical because the outer height also includes the browser chrome. When I zoom in, you can see the outer width stays the same, but the inner width shrinks. At 200% zoom, it’s at 1000 pixels. At 400%, it’s 500 pixels. That’s because when you zoom, the browser pretends the resolution is smaller, so everything gets bigger. Again, that’s a simplified explanation. The important bit for us here is that when we use media queries in CSS, they reference the inner width and height. So, for example, a media query that kicks in at a minimum width of 1,000 pixels doesn’t do anything at 400% zoom in this example because the referenced viewport size isn’t 2,000 but 500.

@media (min-height: 31.25rem) { /* 500px */
  .header {
    position: fixed;
  }

  .cookie {
    position: fixed;
  }
}

We can use this to fix our overlap problem. Instead of making our header and cookie banner always fixed, we use a condition. We make them fixed only when the viewport is at least 500px tall. 500px is an arbitrary number. I just manually tried what works best.

Now you can see that the header and cookie banner are still fixed, but only up to 200% zoom. At 200%, they become static and no longer take up valuable screen space. Of course, you can argue that the cookie banner should still be fixed, but that’s a different topic.

Okay, next: When you click on one of the menu items, a subnavigation pops up.

<button aria-haspopup="true">
  Demos
</button>

<div>
  <ul>
      …
  </ul>
</div>

Something I often see when I audit websites is that people set aria-haspopup="true" on the button that controls the subnavigation to indicate it controls a pop-up.

That sounds like a great idea, but it isn’t.

Looking at the spec, you’ll see that aria-haspopup controls a very specific set of elements: menus, listboxes, trees, grids, and dialogs. The value true defaults to menu. So the code sample I showed earlier indicates that the button controls a menu.

Another look at the spec will tell us that the menu role is meant for elements similar to a menu on a desktop application and that it should manage focus.

Using the screen reader JAWS, for example, you will hear “Demos, button, menu, press space to activate the menu, then navigate with the arrow keys” when you focus the button. When you don’t provide these keyboard shortcuts and others, by the way, then the navigation might not be accessible to people. Because simply adding the attribute doesn’t add any keyboard functionality. You have to do it manually. On top of that, as mentioned, a navigation isn’t a menu. A menu is something like a context menu or the menu in your operating system. So, a collection of items that perform certain actions.

<button aria-expanded="false">
  Demos
</button>

<div>
  <ul>
      …
  </ul>
</div>

A navigation is simply a collection of links. If it’s a more complex navigation, it also includes buttons that toggle the visibility of these links.

That’s why it’s a much better choice to use aria-expanded to indicate that a button controls another element, rather than aria-haspopup.

Or, even better, you can use the popover attribute in HTML. Here’s how it works. You have a button and an element that the button controls. On that element, add the popover attribute to make it disappear. Connect the element to the button by giving the element an ID and referencing that ID on the button using the popovertarget attribute. And that’s it. Now you’ve got the same functionality, but without any JavaScript. I’m a big fan of the popover attribute because it offers a range of useful features.

If you put the popover attribute on a generic element like a div, it changes its role. Here you can see the accessibility panel in Chrome DevTools showing that the div’s role is now “group” and not “generic” anymore.

According to the spec, the group role forms a logical collection and isn’t intended to be included in a page summary. Think of it as a softer, less important grouping of elements than a region.

<button popovertarget="sub2">
  Demos
</button>

<div popover id="sub2" role="dialog">
  <ul>
      …
  </ul>
</div>

Of course, you can also change the role by using the role attribute. In this example, I turn the popover into a dialog by using the role attribute.

<button popovertarget="sub2">
  Demos
</button>

<dialog popover id="sub2">
  <ul>
      …
  </ul>
</dialog>

The popover doesn’t have to be a div; you can also use a semantic element like dialog, for example.

It’s just important to understand that a dedicated element may come with extra functionality. Here you can see how opening the popover moves focus into the dialog element. If you want that, that’s fine. If not, you may want to use a div with the appropriate role instead.

Another interesting detail is focus management. Here, you see a button, followed by the popover it controls, followed by three more buttons. When I activate the button and press Tab, focus jumps into the popover. When I press Tab again, it jumps to the next button outside the popover. That makes sense because focus simply follows DOM order.

What’s interesting about that is that this also works if you move the popover away from the button. In this demo, you see in the DOM that first we got all the buttons, and then at the very end we have the popover. If I now open the popover and press Tab, you can see that focus still jumps into the popover. When I press Tab again, it jumps to the second button, following the first button. That’s because the popover manages focus. For screen reader users, this works if they use the Tab key, but if they use the virtual cursor, it will still follow the DOM order. So it’s still best to put the popover as close as possible to the button that controls it.

One more thing, remember in my previous example where I used aria-expanded to indicate that the button controls another element? You don’t have to do that with popovers because as soon as you reference a popover, the button gets an implicit aria-expanded attribute. But now, enough with popovers. Let’s talk about SVGs.

When you add an SVG inline to a document, it defaults to an image role, which some screen readers may announce. Screen reader users may get something like “image” without any context. That’s similar to having an img element without an alt attribute, and we don’t want that.

<svg viewBox="0 0 24 24" aria-hidden="true">
  …
</svg>

To fix that, add aria-hidden=”true”, which is similar to adding an empty alt attribute to an img element. It remove the element from the accessibility tree.

<svg viewBox="0 0 24 24" role="img" aria-label="Charging">
  …
</svg>

If you want to describe the image, you can use aria-label and role=image to ensure it has the right role in every browser and screen reader.

<svg viewBox="0 0 24 24" role="img">
  <title>Charging</title>
  …
</svg>

You could also use the title element that you may know from the head of your pages, which generally works fine, but…

<button>
  <svg viewBox="0 0 24 24" role="img">
    <title>Charging</title>
    …
  </svg>
</button>

…there is a bug or “interesting behavior”. When you put an SVG inside a button, the SVG’s accessible name should serve as the button’s accessible name, but that’s not the case with VoiceOver in Safari.

<button>
  <svg viewBox="0 0 24 24" role="img" aria-labelledby="icon_title">
    <title id="icon_title">Charging</title>
    …
  </svg>
</button>

To work around that, add aria-labelledby to the SVG and reference the title element by its ID.

<svg viewBox="0 0 24 24" aria-hidden="true">
  …
</svg>

Remember when I said you could set aria-hidden to true on the SVG? You can also use this attribute on other elements.

<h2>
  Hello Salzburg
</h2>

For example, if you have an H2, a screen reader will announce something like “Hello Salzburg, heading level 2”.

<h2 aria-hidden="true">
  Hello Salzburg
</h2>

If you use the attribute, the element will be removed from the accessibility tree entirely, and no announcement will be made. Now that’s nothing I recommend. In most cases, that’s actually a bad practice. I’m only mentioning it because some people think that instead of aria-hidden you can also use the role attribute by setting it to “presentation” or “none”.

<h2 role="presentation">
  Hello Salzburg
</h2>

When you use role=presentation, the element’s role is removed, but the text is still announced. It doesn’t remove the entire element and its children from the accessibility tree.

<img src="battery.svg" role="presentation">

I think I know where the confusion comes from: applying the same to an img element removes the image from the accessibility tree entirely. That’s not a bug. That’s intended behavior.

In the spec it says “the img element is treated as a single entity regardless of the type of image file. Consequently, using role=”presentation” or role=”none” on an HTML img is equivalent to using aria-hidden=”true”.”

<img src="battery.svg" role="presentation">
<img src="battery.svg" aria-hidden="true">
<img src="battery.svg" alt="">

So that means, on an image, you can use either role=“presentation “, aria-hidden=”true”, or an empty alt attribute.

I prefer the empty alt attribute because it’s clear what it does and it doesn’t involve ARIA.

<svg viewBox="0 0 24 24" role="presentation">
  …
</svg>

One more thing: If you put role=presentation on the SVG, it’s removed from the accessibility tree. That’s fine.

<svg viewBox="0 0 24 24" role="presentation" aria-label="Charging">
  …
</svg>

But if you also label it, for example, by using aria-label, its text will still be announced.

<h2>
  <button>
    Is the service free of charge?
  </button>
</h2>

When you put a button inside an H2, and you focus the button, a screen reader may announce something like “heading level 2, Is the service free of charge?, button”

<button>
  <h2>
    Is the service free of charge?
  </h2>
</button>

When you do it the other way around, so you put the H2 inside the button, you get something like “Is the service free of charge?, button”. You lose the information that there is an H2.

If you take a look at the spec and find the button role, you will see that one of its characteristics is that its children are presentational.

When an element’s children are presentational, the user agent, or in other words, the browser, must not include these elements in the accessibility tree.

<geolocation></geolocation>

Let’s switch to some of the hot new stuff.

Who among you has heard of the geolocation element? It’s pretty new, still experimental, and only available in Chromium-based browsers.

The element shows a button with an icon and a text label that, when clicked, allows the user to share their location. We could already do that with the geolocation API, but now there’s a dedicated element. I’m mentioning this element because there are some interesting styling constraints for security reasons. Certain properties or property values are either limited or disallowed on this element. Basically, anything that would make this button look like anything other than a button. The constraints come in three flavors.

Some properties you just can’t use, like opacity. That’s not new; we also know this from other elements.

What’s interesting is that there are new constraints, too. For example, certain properties now have limits. If you use the word-spacing property, you can set a value between 0 and 16px, but not above 16px. That’s fine, I guess, but here’s where it gets interesting.

The geolocation element’s text color must have a contrast ratio of at least 3:1 against its background. When you use a color combination with a lower ratio and click the button, nothing happens. The button is deactivated, and the user gets no feedback.

The only place where developers get feedback is the Issues panel. Who among you uses the Issues panel in Chromium-based browsers?

Here’s another one. When you use a font size larger than 3.1rem (by default, that’s 48 pixels), the element is deactivated as well. You can see here how it works when I change the value from 2rem to 3rem, but at 4rem I can’t click the button anymore.

I find that, and also some other behavior, very concerning. I’ve raised my concerns in an issue. They’ve fixed some things, but not anything that I’ve told you today.

Let’s talk about text scaling. In this demo, you see two paragraphs. The font size is small: 16 pixels, but I will try to change that in a second. For one paragraph, I’ve defined it in pixels, and for the other one in rem. When I go into my browser’s appearance settings and change the default font size from 16 to 30px, you can see how the paragraph defined in rem scales with the default font size, while the one in pixels stays the same. That’s the beauty of the rem unit.

When I change the font size on my Android device, you can see how it gets larger everywhere on the operating system except inside the browser’s viewport. That’s unfortunate, but…

…there is a proposal, and in some browsers even an implementation, of a new meta tag.

<meta name="text-scale" content="scale">

Using the text-scale meta tag, you can opt in to the same behavior we saw earlier on my desktop browser.

Now you can see how changing the font size on mobile also affects the website. Please don’t just throw this meta tag into your pages, it may break the layout. There’s a good reason why this feature is opt-in. Test it locally first.

Next, let’s talk about tabs. The recommended way to build tabs is to show the corresponding content when you click a tab. With the keyboard, you can focus the active tab by pressing Tab, and pressing Tab again takes you into the content. You can switch between tabs using the arrow keys. There is no tab element in HTML, so you have to build this yourself using HTML, CSS, and JavaScript.

But new developments in HTML will let you reduce some of your custom JavaScript by using an HTML attribute.

<div focusgroup="tablist nomemory" aria-label="Sections" class="tablist">
  <button type="button" aria-selected="true">Ada Lovelace</button>
  <button type="button" aria-selected="false">Jocelyn Bell Burnell</button>
  <button type="button" aria-selected="false">Marie Curie</button>
</div>

Focusgroup lets you create consistent directional and sequential focus navigation without writing any JavaScript.

Now you can see how I’m switching between tabs with the arrow keys without writing any JavaScript. And that’s all it does. There are some options, but if you want to define the selected state for the tabs, or if you actually want to show the content, you still have to write JavaScript.

As with the popover we saw earlier, you also get ARIA roles and states with focusgroup, depending on the value. The container gets a tab list role and each tab a tab role.

When you’re working on custom components, you have to test with the keyboard, and debugging sometimes gets hard. One nice feature I like about Chrome DevTools is called Live Expressions. It lets you log document.activeElement, which always shows the currently focused element while you tab through the page. It even lets you jump from the console to the Elements panel to the corresponding element.

When you visit the page, the focus is on the body by default. When you press Tab once, it will find the first focusable element, then the second, third, and so on.

Now let’s see what happens when we use an anchor link that brings us to a fragment on the page. So, not a link that takes you to another page or an external resource, but to a specific part of the page. Focus starts on the body again, then moves to the first link, then the first button, then another link, and when I press Enter, focus returns to the body. It brings us to the right part of the page, but the focus is on the body. But when I press Tab again, it doesn’t jump to the first link on the page; it jumps to the next focusable element, which is a tab here in this example.

This is all happening because of a mechanism called sequential focus navigation starting point. When you click on an anchor link, it doesn’t move focus to the target. It puts it on the body; we just saw that. But what happens is that the focus starting point shifts from the top of the page to that fragment.

The same thing happens when you click somewhere on the page. You can see here that the focus is on the body. I move my mouse, I click on a paragraph, I press Tab, and focus jumps to the next focusable element after the paragraph. So it doesn’t start at the top of the page; it starts from where I clicked before.

That’s also happening when you delete a focused element. You see focus is here on a button; I press Enter, and it deletes the button. Focus is on the body; I press Tab, and it finds the next focusable element after the previously deleted button.

Let’s talk about focus order. When you use the Tab key, focus always follows the DOM order. You can see I put four buttons here in a two-column grid, and when I press Tab, it goes from the first button to the second and then jumps into the second row to the third and the fourth button.

When I change the order using CSS, focus still follows the DOM order. Changing the visual order often creates a mismatch between the logical and visual order, which can be problematic.

.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  reading-flow: grid-rows;
}

To work around that issue, a new property called reading-flow exists. It allows you to change the logical order without touching the DOM.

Now you can see how focus doesn’t follow the DOM order anymore, but the visual order, or, more precisely, it goes from row to row. This is really cool, but it only works with grid layouts right now and only in Chromium-based browsers. I was hoping that I could combine it with another new feature in CSS: grid-lanes.

Let’s say we have a layout like that: a three-column grid with differently sized elements. You can see how there are many gaps in the layout because we have a mix of tall and short items. That doesn’t look nice.

.grid {
  display: grid;
  display: grid-lanes;
  grid-template-columns: repeat(3, 1fr);
  reading-flow: grid-rows;
}

We can enhance this layout by setting the display property to grid-lanes.

And now we get this tightly packed layout that kind of looks like a grid, but it’s not a grid anymore because we still have columns, but there are no proper rows. The problem is that if you’re using a keyboard, it’s hard to follow where focus will go next because the algorithm tries to pack the layout as tightly as possible.

Here is a visualization of the problem. We want the focus to move from top to bottom and left to right, not sometimes left and sometimes right.

The algorithm always tries to find the shortest column to create a dense layout. The first item goes in the first column, the second in the second, and the third in the third. It always picks the shortest column, right? And the fourth item now goes into the third column because it’s the shortest. That happens even if the shortest column is only one pixel shorter. Reading flow could be a solution, but unfortunately, it only works with grid layouts right now.

.grid {
  display: grid;
  display: grid-lanes;
  grid-template-columns: repeat(3, 1fr);
  flow-tolerance: 20px;
}

Instead, to balance that a bit, there is a property called flow-tolerance, which by default is set 1rem. In this example, I’m using 20 pixels, which means the algorithm must pick the shortest column that’s at least 20 pixels shorter than the next logical column. If it’s only 10 pixels shorter, it will move to the next logical column instead of the shortest.

.grid {
  display: grid;
  display: grid-lanes;
  grid-template-columns: repeat(3, 1fr);
  flow-tolerance: infinite;
}

You can also set this value to infinite so that the algorithm always picks the next column.

Now you can see that the logical order is intact, but of course, this may also create a more visually unbalanced layout.

A common issue that we’ve probably all faced at some point is having a component that includes a heading, which sometimes should be an H2 and sometimes maybe an H3, depending on where it’s used. Here’s an example taken from the City of Vienna’s website (I translated the page into English before taking the screenshot). We see the same card component used three times.

At the very beginning of the page, it follows the H1, so the heading inside the first card should be an H2. But then we have another section that starts with an H2, and here these cards should start with an H3. We solved this by giving editors an option to pick the appropriate heading level in the CMS. That works, but it means more work for them, because they need to keep track of the document outline, and it’s also a potential source of error.

<h1>Museums</h1>

<section>
  <h2>Free entry to the museum</h2>
</section>

<section>
  <h2>All museums and collections</h2>
  <h3>Alphabetical</h3>
  <h3>By district</h3> 
</section>

This is how the outline of this page could look like in HTML. Again, that’s easy to build and maintain if you have control over the page structure. If you don’t, you need context-aware components.

There is a new experimental attribute that offers a solution to this problem. The heading offset content attribute allows us to offset heading levels for descendants.

<h1>Museums</h1>

<section headingoffset="1">
  <h1>Free entry to the museum</h1>
</section>

<section headingoffset="1">
  <h1>All museums and collections</h1>
  <div headingoffset="1">
    <h1>Alphabetical</h1>
    <h1>By district</h1> 
  </section>
</section>

First, we turn all headings into H1s. Then we add the headingoffset attribute with a value of 1 to every parent element where we want to increase the heading level by 1. The heading offset attribute increases the current heading level by the specified number; in this case, by 1 (h1 + 1 = h2). The H1s inside the sections become H2s and the H1s nested in another heading offset container become H3s.

This feature is currently only available behind flags.

All right, that’s it. These are my 20 things you didn’t know about accessibility in HTML and CSS. Although that’s not really true because I said I’m not going to talk about the alt attribute, but I did mention it.

So let’s call it 19.5 things. I hope that you enjoyed this talk. I hope that you learned something. Thank you so much for listening.

Thank you! ❤

accessibility-cookbook.com matuzo.social htmhell.dev matuzo.at manuel@matuzo.at