DEV Community

Cover image for A different approach to writing component variants with Tailwind CSS
Liam Hall
Liam Hall

Posted on

A different approach to writing component variants with Tailwind CSS

The problem

Traditionally when writing component variants with Tailwind CSS I've reached for simple class maps that map a prop value to component slots:

type TTheme = "DEFAULT" | "SECONDARY";
interface IComponentSlot {
  root: string;
  accent: string;
}

const THEME_MAP: Record<TTheme, IComponentSlot> = {
  DEFAULT: {
    root: "bg-red hover:background-pink",
    accent: "text-blue hover:text-green",
  },
  SECONDARY: {
    root: "bg-green hover:background-black",
    accent: "text-pink hover:text-white",
  }
}

<div :class="THEME_MAP['DEFAULT'].root">
  <div :class="THEME_MAP['DEFAULT'].accent">/**/</div>
</div>
Enter fullscreen mode Exit fullscreen mode

The problem with this approach is keeping the required classes in line with one another, ensuring each variant has all the required classes, particularly in more complicated components. In components where we want to share a style, say text color, across different component slots it requires us to update each slot individually.

The limitations of Tailwind

Tailwind generates utility classes by scanning your codebase and matching strings, this means while Tailwind can create classes from arbitrary values, we can't initiate them dynamically without creating a safe list. So this will not work:

// .ts
type TTheme = "DEFAULT" | "SECONDARY";

const colors: Record<TTheme, string> = {
  DEFAULT: "red",
  SECONDARY: "blue",
}

// .html
<div :class="`text-[${colors[DEFAULT]}]`">
Enter fullscreen mode Exit fullscreen mode

However, we can imitate the desired behavior by taking advantage of CSS variables, something Tailwind uses under the hood for lots of its classes. We can set a variable via a class in Tailwind using the following syntax: [--my-variable-key:--my-variable-value]
So how could we update the above code example to use dynamic values?

// .ts
type TTheme = "DEFAULT" | "SECONDARY";

const colors: Record<TTheme, string> = {
  DEFAULT: "[--text-color:red]",
  SECONDARY: "[--text-color:blue]",
}

// .html
<div
  :class="[
    colors[DEFAULT],
    'text-[--text-color]'
  ]">
Enter fullscreen mode Exit fullscreen mode

Tackling the initial problem

Now we understand the limitations of Tailwind, we need to look into ways to solve our initial problem caused by our class maps approach. We can start by simplifying our class maps:

type TTheme = "DEFAULT" | "SECONDARY";
interface IComponentSlot {
  root: string;
  accent: string;
}

const THEME_MAP: Record<TTheme, string> = {
  DEFAULT: "[--backgound:red] [--hover__background:pink] [--text:blue] [--hover__text:green]",
  SECONDARY: "[--backgound:green] [--hover__background:black] [--text:pink] [--hover__text:white]",
}

<div class="bg-[--background] hover:bg-[--hover__background]">
  <div class="text-[--text] hover:text-[--hover__text">/**/</div>
</div>
Enter fullscreen mode Exit fullscreen mode

Unfortunately, this alone doesn't solve our problem, we still can't ensure we've set all of the classes we need to display each variant correctly. So how can we take this a step further? Well, we could begin writing an interface to force us to set specified values:

interface IComponentThemeVariables {
  backgound: string;
  hover__backgound: string;
  text: string;
  hover__text: string;
}

const THEME_MAP: Record<TTheme, IComponentThemeVariables> = {
  DEFAULT: {
    backgound: "[--backgound:red]",
    text: "[--hover__background:pink]",
    hover__background: "[--text:blue]",
    hover__text:"[--hover__text:green]",
  },
  SECONDARY: {
    backgound: "[--backgound:green]",
    text: "[--hover__background:black]",
    hover__background: "[--text:pink]",
    hover__text:"[--hover__text:white]",
  },
}
Enter fullscreen mode Exit fullscreen mode

So this would work, but, there's still a problem, nothing is stopping us from mixing up our string values. For example, we could accidentally set key background to [--text:blue].

So maybe we should type our values as well. We can't type entire classes, that would be a maintenance nightmare, so what if we typed our colors and wrote a helper method to generate our CSS variables:

type TColor = "red" | "pink" | "blue" | "green" | "black" | "white";

interface IComponentThemeVariables {
  backgound: TColor;
  hover__backgound: TColor;
  text: TColor;
  hover__text: TColor;
}

// Example variableMap method at the end of the article

const THEME_MAP: Record<TTheme, string> = {
  DEFAULT: variableMap({
    backgound: "red",
    text: "pink",
    hover__background: "blue",
    hover__text:"green",
  }),
  SECONDARY: variableMap({
    backgound: "green",
    text: "black",
    hover__background: "pink",
    hover__text:"white",
  }),
}
Enter fullscreen mode Exit fullscreen mode

Okay so this is great, we can ensure we are always setting the correct variables for every variant of our component. But wait, we've just run into the initial issue we found with Tailwind, we can't just generate classes, Tailwind won't pick them up. So how are we going to tackle this?

What about CSS in JS?

CSS in JS seemed like the obvious answer here, just generate a class that creates a custom class with correct variables. But there's a snag, the Javascript runs on the client, and this causes a "Flash", where the component initially loads without the variables set before updating to display correctly.

How do CSS in JS libraries deal with this?

Libraries like Emotion deal with this by inserting inline style tags about the components:

<body>
  <div>
    <style data-emotion-css="21cs4">.css-21cs4 { font-size: 12 }</style>
    <div class="css-21cs4">Text</div>
  </div>
</body>
Enter fullscreen mode Exit fullscreen mode

This didn't feel like the right approach to me.

So how do we solve this?

I was working with Vue, this led me down the path of v-bind in CSS, a feature in Vue to bind Javascript as CSS values. I'd only used this feature sparingly in the past and never taken a deep dive into what it's doing. v-bind in CSS simply sets an inline style on the relevant element.

This jogged my memory about a Tweet I saw from the creator of Tailwind CSS, Adam Wathan a couple of months previously:

So how does this help us? Well, while we can't dynamically generate Tailwind classes, we can dynamically generate inline styles and consume those inline styles from our Tailwind classes. So what would that look like?

type TColor = "red" | "pink" | "blue" | "green" | "black" | "white";

interface IComponentThemeVariables {
  backgound: TColor;
  hover__backgound: TColor;
  text: TColor;
  hover__text: TColor;
}

// Example variableMap method at the end of the article

const THEME_MAP: Record<TTheme, string> = {
  DEFAULT: variableMap({
    backgound: "red",
    text: "pink",
    hover__background: "blue",
    hover__text: "green",
  }),
  SECONDARY: variableMap({
    backgound: "green",
    text: "black",
    hover__background: "pink",
    hover__text: "white",
  }),
}

<div
  class="bg-[--background] hover:bg-[--hover__background]"
  :style="THEME_MAP['DEFAULT']"
>
  <div class="text-[--text] hover:text-[--hover__text">/**/</div>
</div>

/*
OUTPUT:
<div
  class="bg-[--background] hover:bg-[--hover__background]"
  style="--background: red; --text: pink; --hover__background: blue; --hover__text: green;"
>
  <div class="text-[--text] hover:text-[--hover__text">...</div>
</div>
*/
Enter fullscreen mode Exit fullscreen mode

Conclusion

By combining the powers of Typescript, CSS variables, and inline styles we were able to ensure that while using Tailwind CSS, each variant of our component would have every option set and with the correct type.

This is an experimental approach on which I'm sure there will be some strong opinions. Am I convinced this is the best approach? At this stage, I'm not sure, but I think it has legs.

If you've found this article interesting or useful, please follow me on Bluesky (I'm most active here), Medium, Dev and/ or Twitter.

Example: variableMap

// variableMap example
export const variableMap = <T extends Record<string, string>>(
  map: T
): string => {
  const styles: string[] = [];
  Object.entries(map).forEach(([key, value]) => {
    const wrappedValue = value.startsWith("--") ? `var(${value})` : value;
    const variableClass = `--${key}: ${wrappedValue};`;
    styles.push(variableClass);
  });
  return styles.join(" ");
};
Enter fullscreen mode Exit fullscreen mode

Top comments (0)