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.
Sandro Maglione
Contact me889 words
・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.
The design has two variants:
color
: yellow (default) and bluesize
: 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>
</>
);
}
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 👇):
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.
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/andtailwind-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 becomesdata-[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.
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).
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>;
}
Resources
- Designing a Component Library (Adam Wathan, creator of tailwindcss)
- Tailwind CSS v4.0 Beta