Building flexible, themeable interfaces doesn’t have to be complex.
By combining Tailwind’s data attribute selectors with environment variables, you can create a clean system that switches themes dynamically without JavaScript or complex state management. Shout out to Matt Evans for teaching me this trick in a recent collaboration.
I’m using Astro below, but you can adapt this approach to any framework, heck you don’t even need a framework at all.
Start by exposing your theme as a client-side environment variable in your Astro config:
// astro.config.mjs
export default defineConfig({
env: {
schema: {
THEME: envField.string({ context: "client", access: "public" }),
},
},
});
This allows you to control themes at the environment level—perfect for different deployments or runtime switching.
In your layout, pass the theme to a data attribute on the body:
---
import { THEME } from "astro:env/client";
---
<body class="group/body" data-theme={THEME}>
<slot />
</body>
The group/body
class is key—it creates a Tailwind group that child components can reference.
Now components can style themselves based on the active theme using Tailwind’s group-data-[]
modifier:
<div class={`
border-gray-300
group-data-[theme=brutalist]/body:border-primary
group-data-[theme=round]/body:rounded-full
`}>
<input class="group-data-[theme=round]/body:px-4" />
</div>
It’s pretty simple:
THEME=brutalist
or THEME=round
at deploy timeWhile we have a fixed theme set via environment variables, you can easily switch themes dynamically using JavaScript that updates the data-theme
attribute on the body element.
For more complex theming, combine this approach with CSS custom properties:
[data-theme="brutalist"] {
--primary: #000;
--border-radius: 0;
}
[data-theme="round"] {
--primary: #3b82f6;
--border-radius: 9999px;
}
Then reference these in your Tailwind config or components as needed.
This pattern gives you the flexibility of environment-controlled theming with the power of Tailwind’s utility classes.