CSS-only Widgets Are Inaccessible

Usually.

The text “inaccessible” with “CSS-only” jammed into the middle to spell “inacCSS-onlyible”.
I originally titled this InacCSS-onlyible. I even made this typographically, er, distinct image. Then I realized it was silly and will instead use the neologism in a talk so I can hear the groans IRL.

Interactive widgets powered with only CSS are relatively common as people are playing with all the ways CSS can respond to, or create, interactions. CodePen contests are a great venue to see these experiments (which hopefully are never moved to a live human-facing project).

The problem with many of these examples is they need to convey their state (such as expanded or not), properties (think of relationships), or values, and (sometimes) dynamic names. While CSS is ideal to show these visually, they need to be passed to accessibility APIs so they can be conveyed to users of assistive technologies.

Not everything that relies on CSS for interaction is inaccessible. There are always going to be exceptions, but those are (and should be) rare.

Examples

I know folks do better with examples, so I have grabbed common ones.

Disclosures

It is very common to see a checkbox used as a trigger to hide or show content. Its use in “hamburger” navigation is sadly common, but I often see it with arbitrary expando patterns, such as this fork of an image gallery I made more accessible than its source:

See the Pen Forked: A more accessible NOT CSS-only expanding gallery by Adrian Roselli (@aardrian) on CodePen.

Originally this was coded with a checkbox. A screen reader user would have no idea why there is a checkbox. Since the images are displayed even in the collapsed state (as black boxes), a sighted screen reader user could be confused why their alternative text is announced. A keyboard only user may wonder why it does not fire on Enter.

By changing the trigger to a <button> with aria-expanded, a screen reader user will know the trigger is for a disclosure and it fires on Enter. By adding aria-hidden="true" when collapsed, those images will not be exposed to that same user. This particular design means simply toggling a hidden HTML attribute or the CSS-only display: none will not do the job.

In short, script is needed to manage the expanded or collapsed state as well as the programmatic exposure of the images when reduced to tiny icons. It is a simple disclosure widget with an extra requirement.

Please do not use this example for a real project. It has other issues; I created it simply to demonstrate how easily a CSS-only widget can be tweaked for a baseline of WCAG conformance and a modicum of accessibility affordances.

Update 31 March 2023: A popular tweet demonstrating CSS trigonometry functions uses a non-ideal expando widget. I forked it and made it into an accessible disclosure widget (notes in the code). Scott O’Hara went further and rebuilt it as a popover using the Chrome-only OpenUI proposal. A missed opportunity for OP to demonstrate a pattern which OP has championed.

Toggles

This example is a control to toggle dark or light mode (from a site offering copy-pasta-ready code) that is also quite inaccessible:

See the Pen Inaccessible toggle by Adrian Roselli (@aardrian) on CodePen.

This fake toggle uses a hidden checkbox, so it cannot be activated with the keyboard. This also means a sighted screen reader user cannot activate it without grabbing a mouse. Bear in mind that the code in this sample is scraped from the demo, not from the downloadable package(s).

It is fine to use a checkbox as a toggle, incidentally. Though maybe save it for when you plan to progressively enhance the control and/or flipping the toggle will only take effect when the user submits it. In other words, plan to add a submit button when using a checkbox.

The site also offers a button version of the control, but that button does not convey its state. For that to happen, it would need to use aria-pressed and alternate its value between true and false. Then you hang styles off it, visually reinforcing its programmatic state.

I tend to prefer a button as a toggle in this scenario if you can count on JavaScript being available and flipping the toggle has an immediate effect. For most theme toggles on sites, this is the preferred approach.

As an aside, as of this writing my site uses buttons for the theme toggle. If JavaScript is not available, it uses links with a query string which spares the user having to also press a submit button.

Update 18 May 2024: While not a toggle, using :has() with select menus brings the same risk as the checkbox hack. While articles like Combining CSS :has() And HTML <select> For Greater Conditional Styling are nice demonstrations of CSS, they should not be used in production without caution. I left a comment saying as much.

Sorting

I have seen a few efforts using CSS to sort content. They generally follow variations on re-ordering the visual layout using the order property or repositioning items in a grid track.

The following video captures an experiment from 2018 that visually re-orders rows in a table. It demonstrates it as experienced in JAWS using table navigation.

Visually the table rows are sorted, but their row position announcement and navigation order never changes.

From an interface standpoint, an accessible sortable table would use aria-sort on a column header to convey its current sort state. The header cell would also include a control that can be operated by keyboard (a <button>, for those keeping score).

Programmatically, the sorting operation would restructure the DOM so that the new first row would become the first row in the DOM, the new second row moves into the second row spot, and so on. Otherwise you have both meaningful sequence and focus order WCAG violations.

Marquee

Added 2 December 2023. It is relatively simple to use CSS to create all sorts of animations. A marquee, recreating the classic and terrible <marquee> element, is probably the first place most people start as they teach themselves the CSS syntax. While you can use a prefers-reduced-motion feature query to prevent any animation for users with that system-level preference, it is useless for those who still want animations but want to pause or stop this specific case on a page.

It is also definitely not a carousel (or slider or whatever the kids call it). And adding aria-hidden will not make it accessible, despite what the lead design engineer at Vercel (and Twitter blue-check) casually asserts:

Video description

A 5 second video showing a row of boxes, each made up of an illustrated head, a bold role name (designer or developer) under it, below that the text “Hire pre-vetted developers directly from your dashboard,” and below that is a large bold dollar value alongside a smaller “/hr”. These cards slowly slide left, revealing one more on the right as the one on the left disappears.

He does not provide a CodePen for his example, but he did link someone else’s 2017 demo that makes no accessibility claims (and so should not be shamed). However, the video he made clearly shows structured data, no ability to pause or stop animation, and (if those avatars are or ever become links) a problematic experience for screen reader users. While my expectations for Vercel code quality are incredibly low, I was disappointed to see CodePen boost this.

Sadly, it is not uncommon for accessibility claims to be wrong. I just wish they weren’t promoted when they are so easily disproven.

Looper

Added 3 May 2024. Google Chrome has a video about radio button groups that its author is calling The Looper Mini Web Machine. A core feature of groups of radio buttons is that they are operable with arrow keys by default. Another core feature is that they are form controls, meant to gather information from users and then submit to a process. This video is promoting them strictly as a UI widget.

See the Pen Radiento – Bento Radio Group Carousel thing by Adrian Roselli (@aardrian) on CodePen.

That is only one of the examples, but it demonstrates the risk of taking the video’s advice at face value. When I forked it, I removed the script that handles the view transitions since I am focusing on the CSS and HTML (the script only animates the change via view transitions).

The hit area not matching the visual box is a usability problem. The missing group label (the absent <legend>) is a 1.3.1 Info and Relationships violation and the shifting position of the currently selected radio button can be argued as a 3.2.2 On Input failure (depending on broader context of use). That sudden shift can be a problem for magnifier users as well. Sadly, this author has a history of WCAG-violating advice on behalf of Google, and Google continues to provide a platform, so I expect more problematic content in this “web machine” series by Google.

In short, if you want arrow key support for a widget then use script. See ARIA grids, tab lists, listboxes for scripting examples, even if the roles aren’t a fit. Taking a form control and using CSS to turn it into a contextually different widget only risks WCAG violations and confused users.

Tabs

Added 19 June 2024. This example is from 2020, but it came my way today when someone asked about this “accessible” pure CSS set of tabs. It uses radio buttons to hide and show content. Two immediate clues this may not satisfy its “accessible” claim:

I am ignoring the unnecessary tabindex on the heading and paragraph as well as its value of 1.

See the Pen Pure CSS Accessible Tabs by Adrian Roselli (@aardrian) on CodePen.

Wrap-up

Any person, post, article, tweet, expert, fortune cookie, company, etc., that claims its CSS-only interactive widget is accessible is probably wrong.

While it may not be intentional, it is incumbent on you to confirm the accessibility because the person making the claim often has not.

Talk Reference (Added 7 July 2024)

This post was mentioned at CSS Day 2023.

YouTube: That’s not how I wrote CSS 3 years ago | Manuel Matuzović | CSS Day 2023.

4 Comments

Reply

How accessible is this pattern of opening/closing UI components?

main[hidden]:target{display:revert}
main:not([hidden]):has(~ main:target){display:none}

App
<a href="#settings" rel="nofollow ugc">Go to Settings</a>

Settings
<a href="#app" rel="nofollow ugc">Back to App</a>

In response to Rua. Reply

Rua, I am not your QA. But without a lot more context, that does not appear to be very accessible. You may want a disclosure widget or, more likely, a dialog.

Reply

I’m curious on what your opinion about “enhancing” the toggle hack is via messages that are pushed in and out of visibility (and thus announced) by way of live regions and display conditional upon the toggle state.

I did this a while ago and the below might have some errors as I’m going off the top of my head here. I can dig up the exact code at a later time / tomorrow if curious.

Specifically, in pseudocode, if you have

Input for toggle hack
Subsequent message container with live region / assertiveness
Message for checked state
Message for unchecked state
Indeterminate state if needed

CSS pseudocode:

input:checked ~ message container > message:not(specific index) display none
– display initial for message to announce

Invert the above for the non-checked state

Then add a sr-only class of course to visually hide them.

This seems to work fluidly for things like menus, dropdowns, etc.

If we had a dropdown we could do something like:

Input for hamburger menu
– label: toggle dropdown menu
Messages:
– dropdown menu has been opened
– dropdown menu has been closed
When checked:
– live region announces message #1
When unchecked:
– live region announces message #2

Which seems acceptable with proper display / CSS on the dropdown menu per state of course

The catch is the implicit role of the input itself but we can kind of hack that with role=button.

So not 100% fixed but still much better and bordering on acceptable usage at least for no JavaScript cases

In response to Nick. Reply

Nick, I would need to see this in action to have a sense how it works. But I can call out some specific statements you make that are indeed problems (and likely WCAG violations):

Indeterminate state if needed

While a native checkbox can have an indeterminate state, I have no idea how you would represent that in your show/hide pattern. It might be good to disallow that.

Messages:
– dropdown menu has been opened
– dropdown menu has been closed

A disclosure-based navigation is not a menu, nor does dropdown mean the same thing to all users. So the messages themselves are mis-managing expectations, which can impact expected interaction.

When checked:
– live region announces message #1
When unchecked:
– live region announces message #2

Here the user only knows the state of the control when it gets activated. And the live region can interrupt their ongoing task. When live regions are supported, that is, never mind those on Braille displays.

Regardless, there is no programmatic state (such as aria-expanded), which means a user may not know if the widget is open or closed as they navigate through the page.

The catch is the implicit role of the input itself but we can kind of hack that with role=button.

That is indeed a hack since a screen reader user may wonder why it does not fire on Enter (certain roles give instructions to users on how to operate them).

So not 100% fixed but still much better and bordering on acceptable usage at least for no JavaScript cases

The thing about accessibility (and especially WCAG) is that less than 100% is still a barrier (or a failure in WCAG). That is like a lovely ramp to a store with a single step at the very top. I disagree that this is bordering on acceptable usage.

Leave a Comment or Response

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>