Disclaimer: This post is just a record of my tests, investigations and speculations. Things might evolve and change as technology changes. Remember to verify your assumptions and test your code.
Some time ago, I learned about an interesting HTML pattern by wandering through the spec:
<label for="btn">Hello World</label>
<button type="button" id="btn"></button>
What I didn't know until then is that button
is a labelable element and thus it can be referenced with the for
attribute of a label
.
Since this looked like an exotic pattern to me, I did some research and found that the pattern is actually well supported by browsers and most screen readers but not so well supported by voice control software (even if I couldn't verify it myself).
My initial guess was that, depending on the requirements, there are probably more established patterns out there, like using id
and aria-labelledby
. Nonetheless, this pattern stuck in my mind.
And then came The Switch
Now, the example above doesn't make much sense, and I didn't encounter it in the wild until recently when I found an implementation of it looking at the examples of the Switch
component in the shadcn documentation (I cleaned it up to make it more readable):
<button
type="button"
role="switch"
aria-checked="false"
value="on"
id="airplane-mode"
>
</button>
<label for="airplane-mode">Airplane Mode</label>
The markup is very close to my first snippet, with the relevant addition of the switch
role and the related aria-checked
attribute. These additions tell assistive technologies that the control is a switch.
Around the web, you might find the control also called toggle or toggle switch, but this is how it would usually look like:
Since shadcn is based on Radix UI, a pretty established React library with a focus on accessibility, I decided to embark on a journey to see what other popular libraries were doing and how widespread the labeled button pattern was.
Introduction to switches
Before starting the journey, let's try to understand what a switch is, because the control itself is also pretty interesting from a User Experience point of view.
It clearly is a skeuomorphism of physical toggle switches and it was probably popularized by the iOS interface.
In many cases, the expected behavior for this control is to immediately have an effect on the system. For example, we expect a switch for airplane mode to immediately turn it on or off without having to press a save button.
This is where the application of this UI pattern to the web changes: a switch can both have immediate effect or be part of a submittable form (thus behaving almost like a checkbox). Let's keep this in mind for later.
Web switches
When it comes to the web, there's extensive literature about accessible switches and toggle buttons (for example: Updated Switch script & more, Toggle Buttons
, An accessible toggle).
Even if their semantics are slightly different, the names are used interchangeably in design systems.
There are two ongoing initiatives for native switch controls: OpenUI toggle and switch attribute, the latter one being already shipped in Safari 17.4. But for now, the most effective patterns are:
<!-- 1) HTML base is a checkbox -->
<label for="switch">Airplane mode</label>
<input type="checkbox" id="switch" role="switch" aria-checked="false" />
<!-- 2) HTML base is a button -->
<label id="switch-label">Airplane mode</label>
<button aria-labelledby="switch-label" role="switch" aria-checked="false"></button>
Screen readers
For both patterns, NVDA and VoiceOver (macOS) and other screen readers would sound like (you can check it on CodePen):
Airplane mode off switch
Not all screen readers/browser pairs support these patterns though. For example, Narrator in every browser except Edge identifies the switch as button, off
(probably because Narrator is mostly tailored for Edge users): as always, build with your target audience in mind.
System architecture
From a system architecture perspective, the first pattern is probably preferable in cases where the switch's state change is part of a submittable form. This is because the checkbox's value is submitted with all other fields (which is a good practice for progressive enhancement). This approach becomes even more relevant with concepts like React Server Functions.
The second pattern is instead more suitable when we want the state change to have an immediate effect since that's an expected behavior of the underlying button
element.
The "labeled control side effect"
Another big difference, at least from the user agent point of view, is that in the first example the label
has an associated labeled control (the checkbox), while in the second it's just a caption for the button.
It might seem a semantic subtlety, but there is a rather interesting side effect: interacting with the label of a labeled control triggers events on the control itself.
For example, in most browsers, clicking the label of a checkbox will change the checkbox's state, as if we directly clicked on the control. This is not the case when we associate a label and a control using aria-labelledby
.
So, if we change the second snippet to:
<!-- 2) HTML base is a button -->
<label for="switch-btn">Airplane mode</label>
<button id="switch-btn" role="switch" aria-checked="false"></button>
now clicking on the label triggers the button click
event (you can test the behavior in this CodePen).
In the context of a form with many inputs, this behavior might contribute to the consistency of the user experience by making the switch feel more native.
Anyway, as I previously mentioned, some accessibility software might not fully support this pattern. We'll see later how component libraries are trying to solve the problem.
Switches in the wild
Now that we've covered the basics of switches and their implementation patterns, I'd like to see what other popular component libraries are doing for the same component.
I will focus on React, Vue, and Angular since they seem to have the highest market share in the JavaScript ecosystem. My choice of library is a mix of surveys and other sources.
React Libraries
Library | Pattern |
---|---|
MUI | label > input[type=checkbox] |
shadcn/ui + Radix Primitives | label[for] + button[role=switch] |
Headless UI | label[for] + button[role=switch][aria-labelledby] |
Chakra UI | label > input[type=checkbox] |
React Aria |
label > input[type=checkbox][role=switch] (no aria-checked) |
Vue Libraries
Library | Pattern |
---|---|
Vuetify | label[for] + input[type=checkbox] |
Element Plus | label[for] + input[type=checkbox][role=switch] |
Radix Vue | label[for] + button[role=switch][aria-label] |
Angular Libraries
Library | Pattern |
---|---|
Angular Material | label[for] + button[role=switch][aria-labelledby] |
Nebular | label > input[type=checkbox][role=switch] |
Multi-framework Libraries
Library | Pattern |
---|---|
Bootstrap | label[for] + input[type=checkbox][role=switch] |
Prime(NG,React,Vue) | label[for] + input[type=checkbox][role=switch] |
Ark UI | label[for] + input[type=checkbox][aria-labelledby] |
Results
After investigating these libraries, a clear result emerges: as I suspected, most of the library authors chose a more conservative pattern using a label
and a checkbox with (or without) the switch
role.
However, even if it might seem that the label + button
pattern isn't used a lot, we need to remember that React is the most popular JavaScript library, shadcn and Radix have large adoption, and Headless UI is used in Tailwind's own premium UI library.
So, from a numerical perspective, there may be a significant number of web applications using the labeled button pattern.
Accessibility testing results
In my quick tests using VoiceOver (macOS), NVDA, and Narrator, all these patterns worked as expected. The screen readers correctly reported switches and checkboxes along with their states, with one exception: NVDA on Firefox reports Element Plus as blank
. I didn't investigate this issue in depth, but it might be related to how the library hides the native input element (see a similar topic for PrimeFaces).
React Aria Vuetify Element Plus Radix Vue Angular Material NebularTests output log
I created some rough demo environments, grouping most of the libraries I tested. You can check them out at the following links:
I also noticed some interesting details:
- MUI, Vuetify, and Chakra UI (as well as Ark UI, which is developed under the same org) implementations of the switch are basically just visual: the underlying controls are simply labeled checkboxes. While this makes the accessibility information less consistent with its on-screen appearance, as MUI documentation states, the choice of not using the switch role is conservative, to widen the range of supported devices.
- React Aria doesn't set the
aria-checked
attribute that should be required; nevertheless, in all my tests, screen readers were able to pick the state from the underlying checkbox. - Headless UI and Angular Material use the same implementation as shadcn/ui (the labeled button) but with a twist: they introduce a redundancy where the label is referenced in the button via the
aria-labelledby
attribute.
Redundant labeling
The redundant labeling pattern from the previous section deserves a closer look, as it represents an interesting approach to usability and accessibility.
Here is a simplified snippet as reference:
<label for="switch" id="switch-label">Airplane mode</label>
<button id="switch" role="switch" aria-labelledby="switch-label" aria-checked="false"></button>
Earlier we said that some accessibility software might not properly compute the name of buttons associated with labels with the for
attribute, so, since aria-*
attributes have precedence in the computation of accessible names, we should have solved those issues.
The for
attribute is then only used to leverage the labeled control trigger side effect (as reported in this PR).
Anyway, I couldn't test the accessibility of this pattern, so I can't 100% vouch for it.
Takeaways
After this long journey through switch implementations, I can summarize my findings in four key insights:
- The labeled button pattern, while fascinating to discover in the HTML spec, remains somehow exotic in practice. However, its usage with a redundant labeling could make it a fair alternative to more traditional patterns.
- Because UI libraries abstract away the HTML implementation, you end up living in a world (that of
<Switches />
,<Tabs />
and<Menubar />
) disconnected from the real medium you're writing for: In all the test scenarios you are writing a variation on<Switch />
but the real markup is hidden away. - When we delegate our design choices to someone else, we don't know the impact on the user experience. Small changes in the markup can fundamentally change how users perceive and interact with an interface.
- The libraries I explored emphasize their accessibility and indeed provide a good baseline, but as we've seen, there are often many possible ways to make a control "accessible" — each with its own assumptions and caveats. Don't rely blindly on any library; do some research based on your specific needs.
Originally posted at: https://marco.solazzi.me/blog/journey-through-switches/
Top comments (0)