Styling Radix UI components using Tailwind CSS
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 adata-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.