Patterns for composable tailwindcss styles

tailwindcss has everything that you need to compose styles, especially since v4. These are some patterns that I often use to keep my styles centered around tailwindcss.

Author Sandro Maglione

Sandro Maglione

Contact me

I have been using nothing but tailwindcss for years for styling on the web. I built anything from simple forms to complete component libraries.

This article is a list of patterns that I picked up by making (costly) mistakes that caused huge refactoring effort later on. This is to spare you (and my future self) some time when styling with tailwind (v4).


Variants styling with props

We have the simplest of components: Chip. It's a simple colored string tag.

DefaultNewEmptyUnknownBig

The design has two variants:

  • color: yellow (default) and blue
  • size: medium (default) and small

We can start from the base React component:

export default function Chip({
  children,
}: { children: string }) {
  return <span>{children}</span>;
}

The first option to add variants is simply to do it manually:

export default function Chip({
  children,
  color,
  size,
}: {
  // 👇 Manual definition of all styles and variants 
  color?: "yellow" | "blue";
  size?: "small" | "medium";
  children: string;
}) {
  return (
    <span
      className={`rounded-full ${
        color === "yellow"
          ? "bg-yellow-500 text-slate-800"
          : color === "blue"
          ? "bg-blue-500 text-white"
          : ""
      } ${
        size === "small"
          ? "bg-yellow-500 text-slate-800"
          : size === "medium"
          ? "bg-blue-500 text-white"
          : ""
      }`}
    >
      {children}
    </span>
  );
}

That's inconvenient, hard to read, and unsafe (it's easy to miss a variant when using the ternary operator ?:).

The second option is use a library like clsx to apply styles:

import clsx from "clsx";

export default function Chip({
  children,
  color,
  size,
}: {
  color?: "yellow" | "blue";
  size?: "small" | "medium";
  children: string;
}) {
  return (
    <span
      className={clsx(
        "rounded-full",
        color === "yellow" && "bg-yellow-500 text-slate-800",
        color === "blue" && "bg-blue-500 text-white",
        size === "small" && "text-sm py-1 px-3",
        size === "medium" && "text-base py-2 px-5"
      )}
    >
      {children}
    </span>
  );
}

Easier to read, but still limited and mostly unsafe.

A better option instead is to use class-variance-authority, which is specifically designed to collect, apply, and infer variants:

import { cva, type VariantProps } from "class-variance-authority";

const chip = cva(
  "rounded-full", // Base styles
  {
    // List of variants
    variants: {
      // 👇 `color` variant: "yellow" or "blue"
      color: {
        yellow: "bg-yellow-500 text-slate-800",
        blue: "bg-blue-500 text-white",
      },

      // 👇 `size` variant: "small" or "medium"
      size: {
        small: "text-sm py-1 px-3",
        medium: "text-base py-2 px-5",
      },
    },

    // Default variant values
    defaultVariants: {
      color: "yellow",
      size: "medium",
    },
  }
);

Add the following configuration to your VsCode settings to have auto-completion inside cva:

"tailwindCSS.experimental.classRegex": [
  ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
  ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]

We can then apply this to the component as props:

import { cva, type VariantProps } from "class-variance-authority";

const chip = cva(
  "rounded-full",
  {
    variants: {
      color: {
        yellow: "bg-yellow-500 text-slate-800",
        blue: "bg-blue-500 text-white",
      },
      size: {
        small: "text-sm py-1 px-3",
        medium: "text-base py-2 px-5",
      },
    },
    defaultVariants: {
      color: "yellow",
      size: "medium",
    },
  }
);

export default function ChipProps({
  children,
  color,
  size,
}: { children: string } & VariantProps<typeof chip>) {
  return <span className={chip({ color, size })}>{children}</span>;
}

By using VariantProps variants are inferred from cva, so we don't need manual typing, making each style safe and always defined.

When using the Chip component you can specify color and size:

export default function App() {
  return (
    <>
      <Chip>Default</Chip>
      <Chip color="yellow" size="small">
        New
      </Chip>
      <Chip color="blue" size="small">
        Empty
      </Chip>
      <Chip color="yellow" size="medium">
        Unknown
      </Chip>
      <Chip color="blue" size="medium">
        Big
      </Chip>
    </>
  );
}
DefaultNewEmptyUnknownBig

Notice how we did not add a className prop to the component. You often want the styles of your components to be consistent across the app.

Not adding className avoids accidental one-off styles. I prefer to update the component variants instead if necessary.

You can also consider a similar library specific for tailwind called tailwind-variants.

Inline CSS variables

With tailwindcss all generated classes are static.

Imagine we need to update a style based on a mutable state. As the parent container scrolls we update the opacity of a sticky label (try scrolling the container below 👇):

0

We cannot apply a dynamic style with tailwind:

import { useState } from "react";

export default function ParentScroll() {
  const [scroll, setScroll] = useState(0);

  const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
    setScroll(event.currentTarget.scrollTop);
  };

  return (
    <div
      className="max-h-[100px] relative overflow-y-auto border border-black"
      onScroll={handleScroll}
      ref={(ref) => {
        if (ref !== null) {
          setScroll(ref.scrollTop);
        }
      }}
    >
      <span
        // 👇 tailwindcss classes are statically generated, this `opacity` doesn't work
        className={`sticky top-0 right-0 opacity-${scroll / 1000}`}
      >
        {scroll}
      </span>
      <div className="h-[1000px]"></div>
    </div>
  );
}

An option is to fallback to using styles:

import { useState } from "react";

export default function ParentScroll() {
  const [scroll, setScroll] = useState(0);

  const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
    setScroll(event.currentTarget.scrollTop);
  };

  return (
    <div
      className="max-h-[100px] relative overflow-y-auto border border-black"
      onScroll={handleScroll}
      ref={(ref) => {
        if (ref !== null) {
          setScroll(ref.scrollTop);
        }
      }}
    >
      <span
        style={{ opacity: scroll / 1000 }}
        className="sticky top-0 right-0"
      >
        {scroll}
      </span>
      <div className="h-[1000px]"></div>
    </div>
  );
}

But we can keep all the styles inside tailwind by defining an inline CSS variables inside styles:

import { useState, type CSSProperties } from "react";

export default function ParentScroll() {
  const [scroll, setScroll] = useState(0);

  const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
    setScroll(event.currentTarget.scrollTop);
  };

  return (
    <div
      className="max-h-[100px] relative overflow-y-auto border border-black"
      onScroll={handleScroll}
      ref={(ref) => {
        if (ref !== null) {
          setScroll(ref.scrollTop);
        }
      }}
    >
      <span
        style={
          {
            "--scroll": scroll,
          } as CSSProperties
        }
        className="sticky top-0 right-0 opacity-[calc(var(--scroll)/1000)]"
      >
        {scroll}
      </span>
      <div className="h-[1000px]"></div>
    </div>
  );
}

as CSSProperties is needed to fix the unexpected property in TypeScript.

0

The CSS variable --scroll updates with the component state. Tailwind references var(-scroll) in a static selector (opacity-[calc(var(--scroll)/1000)]).

What's even better is that the inline variable is available in all children:

import { useState, type CSSProperties } from "react";

export default function ParentScroll() {
  const [scroll, setScroll] = useState(0);

  const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
    setScroll(event.currentTarget.scrollTop);
  };

  return (
    <div
      className="max-h-[100px] relative overflow-y-auto border border-[#000]"
      onScroll={handleScroll}
      // 👇 Works also when defined inside the parent
      style={{ "--scroll": scroll } as CSSProperties}
      ref={(ref) => {
        if (ref !== null) {
          setScroll(ref.scrollTop);
        }
      }}
    >
      <span className="sticky top-0 right-0 opacity-[calc(var(--scroll)/1000)]">
        {scroll}
      </span>
      <div className="h-[1000px]"></div>
    </div>
  );
}

An inline variable has higher priority compared to global variables. Therefore, you can even use it to overwrite tailwindcss styles locally:

import type { CSSProperties } from "react";

export default function Overwrite() {
  return (
    <div style={{ "--color-red-500": "#ffff00" } as CSSProperties}>
      <p className="text-red-500">What color is this?</p> {/* 👈 Yellow (#ffff00) */}
    </div>
  );
}

States with custom data attributes

It's common to assign one (or multiple) states to a component or input. Say for example a Card that can be selected or unselected.

The standard solution involves a mix of JavaScript conditionals and className:

Usually you would use something like clsx or/and tailwind-merge.

export default function CardProps({
  toggleSelected,
  isSelect = false,
}: {
  isSelect?: boolean;
  toggleSelected: () => void;
}) {
  return (
    <div
      className={isSelect ? "bg-emerald-600 text-white" : "bg-white text-slate-800"}
    >
      <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit.</p>
      <button onClick={toggleSelected}>Select</button>
    </div>
  );
}

But this strategy tends to be verbose and confusing, especially when adding multiple conditionals. There is another solution: Custom Data Attributes.

You can attach a data- prop to any HTML element. You can then reference the value inside tailwindcss just by adding the data- prefix to the class (since v4):

export default function Card({
  toggleSelected,
  isSelect = false,
}: {
  isSelect?: boolean;
  toggleSelected: () => void;
}) {
  return (
    <div
      // 👇 This allows to have `data-selected` (with an empty value) instead of `true`/`false`
      data-selected={isSelect ? "" : undefined}
      className="bg-white text-slate-800 data-selected:bg-emerald-600 data-selected:text-white"
    >
      <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit.</p>
      <button onClick={toggleSelected}>Select</button>
    </div>
  );
}

You need to provide an empty string to target data-selected: with tailwindcss.

If you use isSelected the selector becomes data-[selected=true]:

With this there are no JavaScript conditionals, everything comes back to plain HTML and CSS (tailwind).

It becomes also easier to target multiple states:

export default function Card({
  toggleSelected,
  isSelect = false,
  isDisabled = false,
}: {
  isSelect?: boolean;
  isDisabled?: boolean;
  toggleSelected: () => void;
}) {
  return (
    <div
      data-selected={isSelect ? "" : undefined}
      data-disabled={isDisabled ? "" : undefined}
      // 👇 Apply `selected` only when not `disabled`
      className="bg-white text-slate-800 [[data-selected]:not([data-disabled])]:bg-emerald-600 [[data-selected]:not([data-disabled])]:text-white"
    >
      <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit.</p>
      <button onClick={toggleSelected}>Select</button>
    </div>
  );
}

This pattern is the same used in many popular component libraries to provide states to headless components (e.g. react-aria-components).

But there is more! In tailwindcss v4 you can create custom variants using @variant:

@import "tailwindcss";

@variant selected-not-disabled (&[data-selected]:not([data-disabled]));

This assigns the defined selector to every selected-not-disabled:, which allows to simplify the class even further:

export default function Card({
  toggleSelected,
  isSelect = false,
  isDisabled = false,
}: {
  isSelect?: boolean;
  isDisabled?: boolean;
  toggleSelected: () => void;
}) {
  return (
    <div
      data-selected={isSelect ? "" : undefined}
      data-disabled={isDisabled ? "" : undefined}
      // 👇 Apply `selected` only when not `disabled` with custom variant
      className="bg-white text-slate-800 selected-not-disabled:bg-emerald-600 selected-not-disabled:text-white"
    >
      <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit.</p>
      <button onClick={toggleSelected}>Select</button>
    </div>
  );
}

Slot components with data attributes

Custom data attributes can also come handy to style a child component from the parent. This pattern allows applying styles based on the parent context.

An example is a Text description element. When Text is placed inside an InputGroup we want to apply a top margin.

We add a data-slot attribute to Text with the value of "description". Then inside InputGroup we apply the margin using a data selector:

const Text = ({ children }: { children: string }) => {
  return <p data-slot="description">{children}</p>;
};

const Input = (props: React.InputHTMLAttributes<HTMLInputElement>) => {
  return <input data-slot="control" {...props} />;
};

const InputGroup = ({ children }: { children: React.ReactNode }) => {
  return <div className="[&>[data-slot=description]]:mt-2">{children}</div>;
};

export default function App() {
  return (
    <div>
    {/* 👇 This has no margin */}
      <Text>Isolated text</Text>

      <InputGroup>
        <Input />
        {/* 👇 This has margin top applied (by `InputGroup`) */}
        <Text>Input description</Text>
      </InputGroup>
    </div>
  );
}

With this the style becomes context dependent, applied by the parent. The final App has all the correct styles applied for Text both outside and inside InputGroup.

Adding data-slot gives more control by creating a "tag" for each component, instead of targeting each generic <p> element ("Branded CSS components").

Required styles from custom design system

Using @import "tailwindcss"; makes all the styles defined in tailwindcss available, included spacing and colors.

@import "tailwindcss";

@theme {
  --color-primary: #3490dc;
  --color-secondary: #ffed4a;
  /* ...as well as all the colors from tailwindcss theme */
}

When working on a custom design you often don't want to allow using styles outside your design system. @import "tailwindcss"; can cause accidental uses of tailwind "native" styles.

Auto-complete shows all the styles and colors from the full tailwindcss theme, whereas you usually want to see and use your custom colors.
Auto-complete shows all the styles and colors from the full tailwindcss theme, whereas you usually want to see and use your custom colors.

It's safer to only import specific layers (base and utilities), and define your custom styles as CSS variables (v4):

@layer theme, base, components, utilities;
@import "tailwindcss/preflight" layer(base);
@import "tailwindcss/utilities" layer(utilities);

@theme {
  --color-primary: #3490dc;
  --color-secondary: #ffed4a;
}

By not importing theme, accidentally using a style outside your design will not apply any rule (no auto-completion as well).

By not importing all the theme you get all your custom styles quickly available and easy to spot.
By not importing all the theme you get all your custom styles quickly available and easy to spot.

Styles like flex, grid, items-center, font-bold still work, as well as all default preflight!

You can view the styles and preflight from each @layer inside the tailwindcss repository.

Theme values using CSS variables

Since v4 all theme styles are defined as global CSS variables.

In practice, it means that you can avoid duplicated values and instead reference everything as a CSS variable.

This makes using computed values just a calc away:

export default function AsVariable() {
  return <div className="pl-[calc(var(--spacing)*2)]"></div>;
}

All values are available as CSS variables, you can reference them to build even complex styles, all from the same theme.
All values are available as CSS variables, you can reference them to build even complex styles, all from the same theme.


Resources