Using Aria States To Toggle Tailwind Classes
Ben Read
Posted on March 4, 2024
I maintain an internal UI library for a number of large sites. It's got a number of JavaScript interactions for menus, search buttons and similar.
In my first iteration of the project I used event handlers to add and remove classes directly in the code base, like this:
desktopSearchButton?.addEventListener("click", event => {
// Show the search box
desktopWrapper?.classList.toggle("hidden");
// Add active state to button
let button = event.target.closest("button");
button?.classList.toggle("bg-blue-800");
button?.classList.toggle("rounded-tr-none");
button?.classList.toggle("rounded-br-none");
if (!desktopWrapper?.classList.contains("hidden")) {
desktopWrapper?.querySelector("input[type=search]")?.focus();
}
});
Apart from being verbose this is quite fragile code. I'm looking for a style class and initializing an interactive state that is fixed inside the JS.
This means if the style of the button changed, I would also have to change the JS. Also it could break the JavaScript if you weren't very careful.
Second iteration
I started to refactor the code to instead use data attributes:
desktopSearchButton?.addEventListener("click", event => {
// Show the search box
if(desktopWrapper) {
desktopWrapper.dataset.open = desktopWrapper.dataset.open === "true" ? "false" : "true";
}
// Add active state to button
let button = event.target.closest("button");
if(button) {
button.dataset.open = button.dataset.open === "true" ? "false" : "true";
}
if (desktopWrapper?.dataset.open === "true") {
desktopWrapper.querySelector("input[type=search]")?.focus();
}
});
This is a lot more robust and easily re-useable. Now I can use Tailwind arbitrary selectors in the template to toggle the states:
<button data-open="false" class="hidden data-[open]:block" ...>
Third iteration
With this one, I really wanted to make sure that accessibility was intrinsic to the project, and not an optional extra. To that end, instead of data attributes wherever possible I used aria states to toggle the visuals.
desktopSearchButton?.addEventListener("click", event => {
// Show the search box
if(desktopWrapper) {
desktopWrapper.ariaExpanded = desktopWrapper.ariaExpanded === "true" ? "false" : "true";
}
// Add active state to button
let button = event.target.closest("button");
if(button) {
button.ariaPressed = button.ariaPressed === "true" ? "false" : "true";
}
if (desktopWrapper?.ariaExpanded === "false") {
desktopWrapper.querySelector("input[type=search]")?.focus();
}
});
<button aria-pressed="false" class="hidden aria-pressed:block" ...>
The only disadvantage here is just an inconvenience: they have to add the aria state to the DOM. And to be honest, that is a positive friction to make them more aware of how aria states work.
Also my compiler complains when I try to make left-hand assignments with optional chaining:
button?.ariapressed = "true"
// The left-hand side of an assignment expression may not be an optional property access.ts(2779)
I could escape this rule but instead opted for an if
statement rather than risk making my entire codebase less type safe.
This way, any developers using my library would need to use aria states too, making it at least a bit better for all of our users.
Posted on March 4, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.