Theme-aware Tailwind classes and variants

Multiple dashboards and multiple themes - one adaptive component to rule them all

Let’s say we have multiple dashboards something like admin.example.com, client.example.com and business.example.com and they all have different themes.

There are many ways to make components adapt to theme change, for example, theme object as a prop, theme provider or some kind of CSS-in-JS solution.

But I’m personally convinced that theming, or should we call it what it actually is - styling, should live where it belongs - in styles and CSS classes. Thankfully Tailwind gives us all the power we need.

First of all, lets tweak our tailwind.config a bit

/** @type {import('tailwindcss').Config} */
module.exports = {
  mode: "jit",
  content: ["*.html", "./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {}
  },
  plugins: [
    plugin(function ({ addVariant }) {
      addVariant("business", ".dashboard-business &")
      addVariant("client", ".dashboard-client &")
      addVariant("admin", ".dashboard-admin &")
    })
  ]
}

To make it work the only thing we need is to add .dashboard-whatever class at some level. It can be as high as html, or as low as you need. Quite flexible solution.

<!doctype html>
<html class="dashboard-admin" lang="en">
  ...
</html>

Now we can apply styling to our component depending on the dashboard theme just like we do with dark: variation for dark theme.

function Button(props) {
  return (
    <button
      className="admin:bg-indigo-500 client:bg-blue-500 text-white"
      {...props}
    >
      {props.children}
    </button>
  )
}

This is very handy for some small tweaks, a couple of properties here and there.

But you probably already noticed the upcoming problem - amount of classes we need on an element is a multiplication of properties we want to tweak and amount of themes we need to account for. That can get out of control very quickly, especially if at some point in the future we need to add more themes.

So, what if we could have one class for background, for example, that will automatically adapt to the theme applied to some parent element. Once again - Tailwind gives us the power to do so.

Let’s update our tailwind.config one more time.

/** @type {import('tailwindcss').Config} */
module.exports = {
  mode: "jit",
  content: ["*.html", "./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {
      colors: {
        adaptive: {
          100: "rgb(var(--adaptive-100) / <alpha-value>)",
          200: "rgb(var(--adaptive-200) / <alpha-value>)",
          300: "rgb(var(--adaptive-300) / <alpha-value>)",
          400: "rgb(var(--adaptive-400) / <alpha-value>)",
          500: "rgb(var(--adaptive-500) / <alpha-value>)",
          600: "rgb(var(--adaptive-600) / <alpha-value>)",
          700: "rgb(var(--adaptive-700) / <alpha-value>)",
          800: "rgb(var(--adaptive-800) / <alpha-value>)",
          900: "rgb(var(--adaptive-900) / <alpha-value>)"
        }
      }
    }
  },
  variants: {
    extend: {}
  },
  plugins: [
    plugin(function ({ addVariant }) {
      addVariant("business", ".dashboard-business &")
      addVariant("client", ".dashboard-client &")
      addVariant("admin", ".dashboard-admin &")
    })
  ]
}

Now we can keep our styles as simple as this:

function Button(props) {
  return (
    <button
      className="bg-adaptive-500 text-adaptive-100 border-adaptive-900/50"
      {...props}
    >
      {props.children}
    </button>
  )
}

All color switching will happen thanks to the power of CSS Custom Properties (a.k.a CSS Variables).

The only caveat is that we need to store our colors in r g b format as a simple triad of numbers like 255 248 221 and not as rgb(255 248 221) like we mostly used to. This approach will allow us to apply opacity using a shorthand like bg-adaptive-500/60

@layer base {
  :root {
    --adaptive-100: 255 248 221;
    --adaptive-200: 255 241 187;
    --adaptive-300: 255 235 153;
    --adaptive-400: 255 228 119;
    --adaptive-500: 255 221 85;
    --adaptive-600: 255 207 17;
    --adaptive-700: 204 163 0;
    --adaptive-800: 136 109 0;
    --adaptive-900: 68 54 0;
  }

  .dashboard-admin {
    --adaptive-100: 219 230 251;
    --adaptive-200: 183 205 247;
    --adaptive-300: 147 179 243;
    --adaptive-400: 111 154 239;
    --adaptive-500: 75 129 235;
    --adaptive-600: 24 88 227;
    --adaptive-700: 18 66 170;
    --adaptive-800: 12 44 113;
    --adaptive-900: 6 22 57;
  }
}

That’s it. Now we have a component that adapts to the theme without any props, provides or any other unnecessary complexities.

The last thing left to do is to add some kind of code generator that will spit out CSS from above based on a config file. Single source of truth, automation and all that good stuff.