Styling Radix UI components using Tailwind CSS

How to create a tailwind plugin that helps style Radix UI components using data attribute CSS selectors.

Styling Radix UI components using Tailwind CSS
⚠️
6 months after writing this post, tailwind v3.2 added support for targeting data attributes directly. No need for modifying your config!

I like using Radix UI as the basis of some of our more complex UI components, but there is an expectation that you will style the components using data attributes and tailwind doesn't directly support styling based on arbitrary html attributes. (The attribute selectors that tailwind does support are dir="ltr" and open).

Here's what the Radix UI docs say about styling their components:

When components are stateful, their state will be exposed in a data-state attribute. For example, when an Accordion Item is opened, it includes a data-state="open" attribute. This is the html that the Accordian might produce:

<div data-state="open" class="faq-item">
    <button data-state="open" ...>Is it accessible?<button>
    
    <div data-state="open">
        Yes it is!

Here is how you could style that using css:

.faq-item[data-state='open'] {
  border-color: green;
}

And you could certainly do that with tailwind (see Extracting classes with @apply) but then you have to maintain your styles in two places.

Customising Tailwind to support data-state attributes

An alternative is to create a Tailwind plugin to help target tailwind styles at these data-state attributes.

let plugin = require('tailwindcss/plugin')

module.exports = {
  // ...
  plugins: [
    plugin(function ({ addVariant, e }) {
      addVariant('data-state-open', ({ modifySelectors, separator }) => {
          modifySelectors({ className }) => {
              return `.${e(`data-state-open${separator}${className}`)}[data-state='open']`
          }
      })
    })
  ]
}

With this tailwind variant the following tailwind class className="data-state-open:border-green" will generate css that targets the data-state css attribute:

.data-state-open\:border-green[data-state='open'] {
    --tw-border-opacity: 1;
    border-color: rgb(136 157 50 / var(--tw-border-opacity));
}

Now you can style your Radix UI based components inline based on the data-state attributes.

<AccordionPrimitive.Item
  {...itemProps}
  className='data-state-open:border-green'
/>

Refactor and address all the other Radix UI data-state attributes

Now that we understand the basis of plugin, let's extend it to allow styling based on the sibling and parent element states and also address all the other attributes like data-state='closed', data-state='on', data-state='checked' etc.

const plugin = require('tailwindcss/plugin')

module.exports = {
  // ...
  plugins: [
    plugin(function (helpers) {
      // variants that help styling Radix-UI components
      dataStateVariant('open', helpers)
      dataStateVariant('closed', helpers)
      dataStateVariant('on', helpers)
      dataStateVariant('checked', helpers)
      dataStateVariant('unchecked', helpers)
    }),
  ],
}

function dataStateVariant(state, {
    addVariant, // for registering custom variants
    e           // for manually escaping strings meant to be used in class names
  }) {

  addVariant(`data-state-${state}`, ({ modifySelectors, separator }) => {
    modifySelectors(({ className }) => {
      return `.${e(`data-state-${state}${separator}${className}`)}[data-state='${state}']`
    })
  })

  addVariant(`group-data-state-${state}`, ({ modifySelectors, separator }) => {
    modifySelectors(({ className }) => {
      return `.group[data-state='${state}'] .${e(
        `group-data-state-${state}${separator}${className}`,
      )}`
    })
  })

  addVariant(`peer-data-state-${state}`, ({ modifySelectors, separator }) => {
    modifySelectors(({ className }) => {
      return `.peer[data-state='${state}'] ~ .${e(
        `peer-data-state-${state}${separator}${className}`,
      )}`
    })
  })
}

That's it. Drop this in your tailwind.config.js and have fun with Radix UI.