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.