Tying Tailwind styling to ARIA attributes

Recently he posted a question about Tailwind CSS, and relatively complex CSS selectors:

The idea here is that we deny the component visual styling and functionality unless the developer has remembered to set important accessibility related attributes such as aria-expanded, role, and tabindex. This is a nifty idea that has been around for a while and is commonly touted as a way to force developers to remember their ARIA attributes and to consider things like semantic markup and focus order/states. I am completely on board with this approach and have previously used it in my own work for exactly the same reasons.

Here's an similar idea from Sara Soueidan that references how she uses aria-expanded as a styling hook, so if someone forgets it (or misspells it!) then the component just doesn't work until they fix their mistake:

We can presume that someone like Sara might use something like this:

.some-component__button[aria-expanded="false"] + .some-component__child {
  display: none;
}

Let's take a look at how we'd do that with Tailwind, and then later we can take a look at using some more complex selectors that are close to (but not quite the same as) Adrian's examples.

Before we get started, a quick disclaimer… Tailwind doesn't work for everyone, and it doesn't work for all projects. It makes me cringe everytime I see Tailwind users being too defensive or pushy on Twitter, and I cringe too when I see people who dislike the idea of utility classes reacting equally as vitriolically.

I tend to stay out of such discussions, but I thought I could make a thoughtful contribution to Adrian's question. I know from his writing that he's very thorough and knowledgeable, and I hoped that I might be able to show him something new about Tailwind that might have not been obvious from all the heated Twitter discourse of the past. Some of the Twitter discourse seems to come from people who don't have much Tailwind experience, or who don't grasp how it can be used as part of a 'balanced diet' alongside other CSS techniques. I also feel that Tailwind gets a bit of a bad reputation because it's sometimes used by people new to front-end development (maybe via a React bootcamp or similar) or self-proclaimed 'full-stack' developers who don't know enough about accessibility because of a lack of training, knowledge, time, or desire - but from my experience it's perfectly possible to use utility classes to create quality pieces of web development craft that are entirely accessible.

A second disclaimer is that it's absolutely fine to use code snippets like the one below in regular CSS files (or Vue <style> blocks, etc) alongside Tailwind:

[role="region"][aria-labelledby][tabindex]:focus {
  outline: .1em solid rgba(0,0,0,.1);
}

Tailwind is utility-first not utility-only, and anyone who disagrees with that, in my opinion, is being unnecessarily obstructive. I have been using Tailwind on large, long-running projects since 2018 and we use plenty of regular CSS, wherever regular CSS is the best tool for the job. It's definitely not something you need to shy away from doing, and this ability to add custom CSS is often something that gets lost (or perhaps ignored for the sake of trying to make a point) in tweets like the one below:

Really, this whole blog post could just be 'It's okay to add your own hand-written custom CSS alongside the utility classes generated by Tailwind'.

I think that it's best to think of Tailwind as a handy tool for generating a bunch of utility classes for you (rather than generating them all yourself with a load of Sass loops), but you don't have to use only these classes to get your job done; if you need to use things like :nth-child(), :first-of-type, or complex selectors then a hybrid approach of utilities and hand-written CSS is often sensible.

With all that said, let's go back to Sara's example of working with Tailwind to tie the display CSS property to an aria-expanded value.

Adding a aria-expanded custom component style that hides things, using the Tailwind API

It turns out that Tailwind expects us to want to add styles like this. It gives us tools deliberately intended for us to add custom CSS, such as the @layer directive which allows custom CSS to be inserted at the appropriate point in the generated Tailwind CSS file, depending on if it's a base style, a custom utility, or a component specific style. You can also, of course, just have a completely separate CSS file for your non-Tailwind classes – that's what I do at work at the moment. The separate file is managed by Sass and we use it to add custom styles and any Sass dependencies that we need.

Here's how we'd add a new component style in the CSS file that is processed by Postcss to add all the Tailwind utilities:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer component {
  .some-component__button[aria-expanded="false"] + .some-component__child {
    display: none;
  }
}

If we were confident enough about applying our rules globally, we could add them as base styles like so:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  /* In reality, such a general selector is probably
     a bad idea! But this is just an example… */
  button[aria-expanded="false"] + * {
    display: none;
  }

  /* This is a safer selector for things like a div 
     wrapper around a scrollable/responsive table. */
  [role="region"][aria-labelledby][tabindex]:focus {
    outline: .1em solid rgba(0,0,0,.1);
  }
}

Now, let's try something a bit more complex. This next approach will rely on specific classes being added to the component which moves away a bit from Adrian's and Sara's idea of global rules to enforce accessibility attributes, but it still follows the principle of denying styling or functionality until the correct ARIA attribute(s) are added.

Creating a custom Tailwind variant that uses aria-expanded to hide a sibling

In Tailwind terminology, a 'variant' is something that prefixes a CSS selector to have it only apply in certain situations. For example, a hover, focus, or active state, or where an element is the first-child of its parent. The focus:ring-blue-600 class would correspond to this CSS:

.focus\:ring-blue-600:focus {
  --tw-ring-opacity: 1;
  --tw-ring-color: rgba(37,99,235,var(--tw-ring-opacity));
}

There are a bunch of other variants too, for example a motion-safe:hover:scale-110 class corresponds to this CSS:

@media (prefers-reduced-motion: no-preference) {
  .motion-safe\:hover\:scale-110:hover {
    --tw-scale-x: 1.1;
    --tw-scale-y: 1.1;
    transform: var(--tw-transform);
  }
}

Let's make a new predecessor-not-expanded variant that targets the sibling of a button element that has the aria-expanded attribute value of false. We could do this with good-old regular CSS using the @layer directive that I mentioned earlier, or sticking this CSS in a separate CSS file, but for the sake of completeness let's look at how we could do this using the Tailwind API.

What we are going to do is produce a custom variant by creating a Tailwind plugin in our tailwind.config.js file, like this example that targets the required HTML attribute for input elements.

We can write some JavaScript inside tailwind.config.js that uses the Tailwind API to create our new predecessor-not-expanded variant:

const plugin = require('tailwindcss/plugin')

module.exports = {
  mode: 'jit',
  plugins: [
    plugin(function ({ addVariant, e }) {
      addVariant('predecessor-not-expanded', ({ modifySelectors, separator }) => {
        modifySelectors(({ className }) => {
          return `[aria-expanded='false'] + .${e(
            `predecessor-not-expanded${separator}${className}`
          )}`
        })
      })
    }),
  ],
}

When Tailwind detects a class like predecessor-not-expanded:hidden it will generate CSS that looks like this:

[aria-expanded='false'] + .predecessor-not-expanded\:hidden {
  display: none;
}

We can now use our new variant in our HTML markup, like so:

<!-- https://www.w3.org/WAI/GL/wiki/Using_the_WAI-ARIA_aria-expanded_state_to_mark_expandable_and_collapsible_regions#Examples -->
<button
  id="button1"
  class="bg-purple-100 px-5 py-3 rounded"
  aria-controls="topic-1"
  aria-expanded="false"
>
  <span>Show</span> Topic 1
</button>
<div
  id="topic-1"
  role="region"
  tabindex="-1"
  class="predecessor-not-expanded:hidden pt-2"
>
  Topic 1 is all about being Topic 1 and may or may not have anything to do with
  other topics.
</div>

In the example above, the 'Topic 1 is all about…' text will be hidden, because the button element that precedes it has an aria-expanded value of false.

Let's take it a step further and create a group-expanded variant so we can switch between 'show' and 'hide' text in our component. This will be similar to the group-hover option in Tailwind already.

We can do this by creating two more custom variants:

plugin(function ({ addVariant, e }) {
  addVariant('group-expanded', ({ modifySelectors, separator }) => {
    modifySelectors(({ className }) => {
      return `.group[aria-expanded='true'] .${e(`group-expanded${separator}${className}`)}`
    })
  })
}),
plugin(function ({ addVariant, e }) {
  addVariant('group-not-expanded', ({ modifySelectors, separator }) => {
    modifySelectors(({ className }) => {
      return `.group[aria-expanded='false'] .${e(`group-not-expanded${separator}${className}`)}`
    })
  })
}),

…and then adding a group class to our button element, and updating the content of the button to read:

<span class="group-not-expanded:hidden">Hide</span><span class="group-expanded:hidden">Show</span> Topic 1

These examples aren't quite the same as the ones mentioned in Adrian's tweets, but they show how a similar thing can be done.

Generally, I'd just do the sort of this Adrian is talking about in custom CSS. That said, one slight advantage of creating a custom variant rather than writing custom CSS is if you are going to be using the variant to do a lot of different things. Once we've made the variant, we can use it for anything else, just by adding new classes to our component template, and without having to go back into our CSS file and add new property and value pairs. Here's a jazzier example where we are tying a whole bunch of various element's CSS properties to the aria-expanded attribute's value, without writing any new CSS ourselves - the design is odd but it shows how the variant can be used to do whatever you want it to.

Hopefully this blog shows that a) Tailwind is not an all-or-nothing approach and that it's fine to add your own CSS alongside it, and b) that if you want to add custom, relatively complex selectors in front of your Tailwind utilities then that is absolutely possible too.

Postscript (added a day after publishing)

I like this approach of tying functionality and styling to ARIA attributes to enforce good HTML practices, but it's not one I always use personally (at the moment). On projects where I am reviewing pull requests (so I can keep an eye on the markup), and where we are already using JavaScript for a component, I would use a declarative and reactive/data-driven approach like the one shown in the Codepen below. Here, the styling and the aria-expanded attribute value are both controlled by JavaScript, based on the value of the open property within the component's data object. If we change the value of open then the class bindings and the ARIA attribute values automatically adjust - we don't need to remember to toggle them manually.

This approach does mean you lose the failsafe of the component stopping to function or be styled correctly if the ARIA attribute is accidentally removed. The benefit is that it makes it much easier to make changes to the markup (for example adding a new element after the button but before the expandable element) without running into issues where our CSS is completely dependent on our HTML structure never changing.

This introduces a bit of a moral quandary… We have switched to an approach that is more developer-friendly (e.g. less tight coupling of markup and styles, easier to make changes without updating the CSS, less chance of regression bugs) at the expense of an approach that is more user-friendly (e.g. making it less likely for ARIA bugs to make it to production). In this case we could make sure that the ARIA attributes are always present and have the correct values on click by writing automated tests, which would mitigate my concern about favouring developer experience over user experience (which is normally a big no-no).

88