The “Other” C in CSS
I think it’s worth listening to anything Sara Soueidan has to say. That’s especially true if she’s speaking at an event for the first time in four years, which was the case when she took the stage at CSS Day 2024 in Amsterdam. What I enjoy most about Sara is how she not only explains the why behind everything she presents but offers it in a way that makes me go “a-ha!” instead of “oh crap, I’m doing everything wrong.”
Sara’s presentation, “The Other ‘C’ in CSS”, was published on YouTube just last week. It’s roughly 55 minutes of must-see points on the various ways CSS can, and does, impact accessibility. I began watching the presentation casually but quickly fired up a place where I could take thorough notes once I found myself ooo-ing and ahhh-ing along.
So, these are the things I took away from Sara’s presentation. Let me know if you’ve also taken notes so we can compare! Here we go, there’s a lot to take in.
Here’s the video
Yes, CSS affects accessibility
CSS changes more than the visual appearance of elements, whether we like it or not. More than that, its effects cascade down to HTML and the accessibility tree (accTree). And when we’re talking about the accTree, we’re referring to a list of objects that describes and defines accessible information about elements.
There are typically four main bits of info about an accTree object:
- Role: what kind of thing is this? Most HTML elements map to ARIA roles, but not all of them.
- Name: identifies the element in the user interface.
- Description: how do we further describe the thing?
- State: what is its current state? Announce it!
The browser provides interactive features — like checking a checkbox that updates and exposes the element’s information — so the user knows what happens following an interaction.
Accessibility tree objects may also contain properties and relationships, such as whether it is part of a group or labeled by another element.
Example: List semantics
CSS can affect an object’s accessible role, name, description, or even whether it is exposed in the accTree at all. As such, it can directly impact the screen reader announcement. We shared a while back how removing list-style
affects list semantics, particularly in the case of Safari, and Sara explains its nuances.
/* Removes list role semantics in Safari */
/* Need to add aria-role=list */
ul {
list-style: none;
}
/* Does not remove role semantics in Safari */
nav ul {
list-style: none:
}
/* Removed unless specifically re-added in the markup */
ul:where([role="list"]) {
list-style: none;
}
/* Preserves list semantics */
ul {
list-style: "";
}
display: contents
CSS can completely remove the presence of an element from the accessibility tree. I took a screenshot from one of Sara’s slides but it’s just so darn helpful that I figured putting the info in a table would be more useful:
Exposed to a11y APIs? | Keyboard accessible? | Visually accessible (rendered)? | Children exposed to a11y APIs? | |
---|---|---|---|---|
display: none |
❌ | ❌ | ❌ | ❌ |
visibility: hidden |
❌ | ❌ | ❌ | ❌ |
opactity: 0 and filter: opacity(0) |
✅ | ✅ | ❌ | ✅ |
clip-path: inset(100%) |
✅ | ✅ | ❌ | ✅ |
position(off-canvas) |
✅ | ✅ | ❌ | ✅ |
.visually-hidden |
✅ | ✅ | ❌ | ✅ |
display: contents |
✅ | ✅ | ❌ | ✅ |
The display: contents
method does more than it’s supposed to. In short, we know that display
controls the type of box an element generates. A value of none
, for example, generates no box.
The contents
value is sort of like none
in that not box is generated. The difference is that it has no impact on the element’s children. In other words, declaring contents
does not remove the element or its child elements from the accTree. More than that, there’s a current bug report saying that declaring contents
in Firefox breaks the anchoring effect of an ID attribute attached to an element.
Eric Bailey says that using display: contents
is considered harmful. If using it, the recommendation is to set it on a generic <div>
instead of a semantically meaningful element. If we were to use it on a meaningful interactive element, it would be removed from the accTree, and its children would be bumped up to the next level in the DOM.
Visually hiding stuff
Many, many of us use some sort of .visibility-hidden
class as a utility for hiding elements while allowing screenreaders to pick them up and announce the contents. TPGi has a great breakdown of the technique.
.visually-hidden:not(:focus):not(:active) {
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0); /* for IE only */
clip-path: inset(50%);
position: absolute;
white-space: nowrap;
}
This is super close to what I personally use in my work, but the two :not()
statements were new to me and threw me for a loop. What they do is make sure that the selector only applies when the element is neither focused nor activated.
It’s easy to slap this class on things we want to hide and call it a day. But we have to be careful and use it intentionally when the situation allows for us to hide but still announce an element. For example, we would not want to use this on interactive elements because those should be displayed at all times. If you’re interacting with something, we have to be able to see it. But for generic text stuff, all good. Skip to content links, too.
There’s an exception! We may want an animated checkbox and have to hide the native control’s appearance
so that it remains hidden, even though CSS is styling it in a way that it is visible. We still have to account for the form control’s different states and how it is announced to assistive tech. For example, if we hide the native checkbox for a custom one by positioning it way off the screen, the assistive tech will not announce it on focus or activation. Better to absolutely position the checkbox over the custom one to get the interactive accessibility benefits.
Bottom line: Ask yourself whether an interactive element will become visible when it receives focus when deciding whether or not to use a .visually-hidden
utility.
CSS and accessible names
The browser follows a specific process when it determines an element’s accessible name (accName):
- First, it checks for
aria-labelledby
. If present, and if the ID in the attribute is a valid reference to an element on the page, it uses the reference’s element’s computed text as the element’s accessible name. - Otherwise, it checks for
aria-label
. - Otherwise, unless the element is marked with
role="presentation"
orrole="none"
(i.e., the element does not accept an accName anymore), the browser checks if the element can get its own name, which could happen in a few ways, including:- from an HTML elemnenty, such as
alt
ortitle
(which is best on an<iframe>
; otherwise, avoid), - from another element, like
<label>
or<legend>
, or - from its contents.
- from an HTML elemnenty, such as
At this point, Sara went into a brief (but wonderful) tangent on <button>
semantics. Buttons are labels of an element and can get their accName by using an aria-label
attribute, an aria-labelledby
attribute, its contents, or even a <label>
element. I didn’t know this, but careful, because virtual cursors will announce labels and buttons resulting in two announcements, which violates WCAG SC 2.5.3.
Aria takes precedence over HTML which is why we want to avoid it only where we have to. We can see the priorities and overrides for accessible names in DevTools under the Accessibility tab when inspecting elements.
But note: the order of priority defined in the accName computation algorithm does not define the order of priority that you should follow when providing an accName to elements. The steps should like be reversed if anything. Prioritize native HTML!
CSS generated content
Avoid using CSS to create meaningful content. Here’s why:
<a href="#" class="info">CSS generated content</a>
.info::before {
content: "ⓘ" / "Info: ";
/* or */
content: url('path-to-icon.svg') / "Info: ";
}
/* Contents: : Info: CSS generated content. */
But it’s more nuanced than that. For one, we’re unable to translate content generated by CSS into different languages, at least via automated tools. Another one: that content is gone if CSS is unavailable for whatever reason. I didn’t think this would ever be too big a concern until Sara reminded me that some contexts completely strip out CSS, like Safari’s Reader Mode (something I rely on practically every day, but wish I didn’t have to).
There are also edge cases where CSS generated content might be inaccessible, including in Forced Colors environments (read: color conflicts), or if a broken image is passed to the url()
function (read: alt
text of the image is not shown in place of the broken image, at least in most browsers, yet it still contributes to the accName, violating SC 2.5.3 Label in Name). Adrian Roselli’s article on the topic includes comprehensive test results of the new feature, showing different results.
Inline SVG is probably better! But we can also do this to help with icons that are meant to be decorative to not repeat redundant information. But it is inconsistent as far as browser implementation (but Sara says Safari gets it right).
/* like: <img src="icon.svg" alt=""> */
.icon {
content: url('path/to/icon.svg') / "";
}
So, what can we do to help prevent awkward and inaccessible situations that use CSS generated content?
- Avoid using CSS pseudo-elements for meaningful content — use HTML!
- Hide decorative and redundant CSS content by giving it an empty
alt
text (when support is there and behavior is consistent).
CSS can completely strip an element of its accName…
…if the source of the name is hidden in a way that removes it from the accessibility tree.
For example, an <input>
can get its accName from a <label>
, but that label is hidden by CSS in a way that doesn’t exose it to a11y APIs. In other words, the <label>
is no longer rendered and neither are its contents, so the input winds up with no accName.
BUT! Per spec:
By default assistive technologies do not relay hidden information, but an author can explicitly override that and include hidden text as part of the accessible name or accessible description by using
aria-labelledby
oraria-describedby
.
So, in this case, we can reuse the label even if it is hidden by tacking on aria-labelledby
. We could use the .visually-hidden
utility, but the label is still accessible and will continue to be announced.
CSS does not affect the state of an element in the accTree
If we use a <button>
to show/hide another element, for example, the <button>
element state needs to expose that state. Content on hover or focus violates SC 1.4.13 which requires a way to dismiss the content. And users must be able to move their cursor away from the text and have it persist.
CSS-only modals using the checkbox hack are terrible because they require additional keyboard action to function, but they also trap focus and fail to update background content to inert
.
Popovers created with the Popover API are always non-modal. If you want to create a modal popover, a <dialog>
is the right way to go. I’m enamored with Jhey Tompkins’s demo using the popover
for a flyout navigation component, so much so that I used it in another article. But, using popover for modal-type stuff — including for something like a flyout nav — we still need to update the accessible states.
There’s much more to consider, from focus traps to inert content. But we can also consider removing the popover’s ::backdrop
for fewer restrictions, like making background content inert or trapping focus. Then again, keyboard focus is passed in unexpected ways, including to elements positioned off of the page which violates SC 2.4.11.
So… close the popover
when focus leaves it. Sara mentioned an article that Amit Sheen wrote here on CSS-Tricks where it’d be wise to pay close attention to how a change is communicated to the user when a <select>
menu <option>
is selected to update colors on the page. That poses issues about SC 3.2.2 where something changes on input. When the user interacts with it, the user should know what’s going to happen.
Final thoughts
Yeah, let all that sink in. It feels good, right? Again, what I love most about Sara’s presentation (or any of them, for that matter) is that she isn’t pointing any condemning fingers at anyone. I care about oodles accessible experiences but know just how much I don’t know, and it’s practical stuff like this where I see clear connections to my work that can make me better.
I took one more note from Sara’s talk and didn’t quite know where to put it, but I think the conclusion makes sense because it’s a solid reminder that HTML, CSS, and, yes JavaScript, all have seats at the table and can each contibute positively to accessible experience:
- Hacking around JavaScript with CSS can introduce accessible barriers. JavasScript is still useful and required for these things. Use the right tool for the job.
The “Other” C in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.