I don't let application code use any particular CSS framework directly. No Tailwind CSS classNames in Features or Pages. Instead, a layer of my own typed UI components sits in between, and the CSS framework is confined to the inside of those components.
Dependencies run in a single line. They flow from the Application to my own UI components, and from there to the CSS framework or UI library. From the application's point of view, the CSS framework is nothing more than an implementation detail.
The reason is that frontend styling technology has been replaced with remarkable frequency. There was an era when Bootstrap ruled the roost; then CSS Modules and CSS-in-JS spread, and styled-components and Emotion took over. Today Utility First CSS such as Tailwind CSS is the norm, and more recently options like Panda CSS, which prizes type safety and build-time CSS generation, have appeared too. Trends, it seems, come and go like waves.
I have no interest in arguing which one is right. Pick whatever is productive at the time and fits the project. What I'd rather pass on is having the whole application flooded every time a wave rolls in. What follows is why I build a breakwater out of my own UI components, and what actually changes when you swap the CSS framework, with code along the way.
The Mainstream in Ten Years
Since Bootstrap became widely used in the early 2010s, the options have kept arriving one after another. Foundation, Semantic UI, Bulma, Material UI, CSS Modules, styled-components, Emotion, Chakra UI, Tailwind CSS, Panda CSS. A veritable age of warring states.
Of course, these aren't all the same category of technology. Some are CSS frameworks, some are CSS-in-JS, some are Utility First CSS, and some are UI libraries for React. But for every one of them, there was never any guarantee that what was mainstream at its debut would still be mainstream ten years later.
Neither Bootstrap nor Emotion was bad technology. Tailwind CSS is a very good option today, too. The question to ask is not so much what to adopt as how far the dependency on that technology spreads through the application.
When Tailwind's API Becomes the Design System
Say you implement a button with Tailwind CSS. Here is entirely typical code you might write inOrderPage.tsx.
export function OrderPage() {
return (
<button
className="
rounded-md
bg-blue-600
px-4
py-2
text-sm
font-medium
text-white
hover:bg-blue-700
disabled:opacity-50
"
>
Confirm order
</button>
)
}
For a small application, this is plenty. You get Tailwind's benefits as is, and implementation is fast.
As the product grows, however, another screen has this,
<button className="rounded bg-blue-500 px-3 py-2 text-white">
Save
</button>
and yet another ends up like this.
<button className="rounded-lg bg-sky-600 px-4 py-2 text-sm text-white">
Register
</button>
All of it is technically correct, and Tailwind works without a hitch. But should it be blue-500 or blue-600? rounded or rounded-md, px-3 or px-4, text-sm or text-base? Rules like these slowly scatter across the entire codebase. Before long, Tailwind's API starts calling itself your application's Design System.
Features That Know Only Meaning
So instead of using the CSS framework directly from Features, prepare a Button like this.
type ButtonVariant =
| "primary"
| "secondary"
| "danger"
type ButtonSize =
| "sm"
| "md"
| "lg"
type ButtonProps = {
children: React.ReactNode
variant?: ButtonVariant
size?: ButtonSize
disabled?: boolean
onClick?: () => void
}
export function Button({
children,
variant = "primary",
size = "md",
disabled,
onClick,
}: ButtonProps) {
// Implementation with Tailwind
}
Then the Feature side looks like this.
export function OrderPage() {
return (
<Button variant="primary">
Confirm order
</Button>
)
}
The Feature side knows nothing of bg-blue-600, px-4, rounded-md, or hover:bg-blue-700. All it knows is the meaning: variant="primary".
If you've ever used Material UI or Chakra UI, this should look familiar. There too, you pass a meaning like <Button variant="contained"> and never worry about which styles are applied underneath. In short, we're building the same mechanism ourselves. The only difference is that the inside is Tailwind.
The beauty of this structure is that you can bind UI rules with TypeScript. <Button variant="primary" /> passes, but <Button variant="purple" /> is a compile-time error. Expressions that don't exist in the Design System simply cannot be written.
Use Tailwind directly, by contrast, and bg-blue-500, bg-sky-500, bg-indigo-500, and bg-[#4287f5] are all technically correct code. Yet the Design System may want to allow just one of those colors. Seal that inside the variant="primary" interface. That lets you separate "what can be written as CSS" from "what may be written as the product."
Where Tailwind Lives
Inside Button, the implementation might look like this.
const variantClasses = {
primary:
"bg-blue-600 text-white hover:bg-blue-700",
secondary:
"bg-gray-100 text-gray-900 hover:bg-gray-200",
danger:
"bg-red-600 text-white hover:bg-red-700",
}
const sizeClasses = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
}
export function Button({
children,
variant = "primary",
size = "md",
disabled,
onClick,
}: ButtonProps) {
return (
<button
disabled={disabled}
onClick={onClick}
className={`
inline-flex
items-center
justify-center
rounded-md
font-medium
transition-colors
disabled:pointer-events-none
disabled:opacity-50
${variantClasses[variant]}
${sizeClasses[size]}
`}
>
{children}
</button>
)
}
Using Tailwind means nothing in itself. What matters is that Tailwind is used here and nowhere else. Not a single Tailwind className goes on the Feature side.
The other thing I want to uphold is refusing usage like<Button className="bg-purple-500 px-12">. You can build all the UI components you like, but the moment you let className be passed freely, the boundary collapses. Code like <Button className="bg-green-500">, <Button className="rounded-none">, and <Button className="px-[37px]"> starts creeping across the Feature side all over again. It's like cutting a back door into your breakwater.
So expose only the changes you actually need, as explicit Props. Think <Button size="sm" /> or <Button variant="danger" />.
This is an approach real products take, too. After migrating from Chakra UI / Emotion to Tailwind CSS + Radix UI, KEPPLE set a policy so that styling wouldn't descend into chaos. Rather than passing className straight through, they expose only limited Props such as size (migration article).
Layout, and the Homemade-Tailwind Trap
The same thinking applies to layouts that come up again and again. Instead of writing<div className="flex flex-col gap-4"> all over the place, provide <Stack gap="md">.
type StackGap =
| "sm"
| "md"
| "lg"
type StackProps = {
children: React.ReactNode
gap?: StackGap
}
export function Stack({
children,
gap = "md",
}: StackProps) {
const gaps = {
sm: "gap-2",
md: "gap-4",
lg: "gap-8",
}
return (
<div className={`flex flex-col ${gaps[gap]}`}>
{children}
</div>
)
}
All the Feature side has to write is this.
<Stack gap="md">
<Heading>Order details</Heading>
<Text>Please review your order</Text>
<Button>Confirm</Button>
</Stack>
That keeps values like gap-3, gap-4, and gap-[14px] from breeding throughout the application. There is a trap here, though. Get carried away and turn every CSS property into a Prop, and you get a contraption like this.
<Box
display="flex"
padding="4"
marginTop="2"
background="blue500"
borderRadius="md"
/>
That is merely a giant homemade CSS framework with Tailwind hidden underneath. It all but defeats the point of abstracting. What you want is an API that means something as your product's Design System.
<Button variant="primary" />, <Text tone="muted" />, <Alert severity="warning" />, and <Stack gap="md" /> are the archetypes. What you expose is not how to write CSS, but meaning as UI.
Tests Gather in One Place Too
With this design, the style implementation concentrates in generic UI components. So the style tests can live in one place as well. For Button, you can write this.
describe("Button", () => {
it("primary variant", () => {
render(
<Button variant="primary">
Save
</Button>
)
const button = screen.getByRole("button")
expect(button).toHaveClass("bg-blue-600")
expect(button).toHaveClass("text-white")
})
})
This test itself, however, depends on Tailwind. Swap Tailwind for another implementation and you're stuck rewriting the test too.
So one level up, use Visual Regression Testing. Prepare Stories for Button in Storybook, covering primary, secondary, danger, disabled, small, and large. Compare screenshots with Playwright or Chromatic, and you can confirm the appearance stays the same even when the internals change.
Ideally, TypeScript holds the contract of the UI API, unit tests hold the contract of behavior, and visual regression tests hold the contract of appearance. That's the three-layer split.
Swapping In Panda CSS
The value of this design really sinks in when you actually swap the CSS framework. The Feature-side code is as follows, and it isn't touched at all.
<Button
variant="primary"
size="md"
>
Save
</Button>
Only Button's internals change. Migrating to Panda CSS, the implementation would conceptually look like this.
import { css } from "../../styled-system/css"
const variantStyles = {
primary: css({
backgroundColor: "blue.600",
color: "white",
_hover: {
backgroundColor: "blue.700",
},
}),
secondary: css({
backgroundColor: "gray.100",
color: "gray.900",
_hover: {
backgroundColor: "gray.200",
},
}),
danger: css({
backgroundColor: "red.600",
color: "white",
_hover: {
backgroundColor: "red.700",
},
}),
}
const sizeStyles = {
sm: css({
height: "8",
paddingX: "3",
fontSize: "sm",
}),
md: css({
height: "10",
paddingX: "4",
fontSize: "sm",
}),
lg: css({
height: "12",
paddingX: "6",
fontSize: "md",
}),
}
export function Button({
children,
variant = "primary",
size = "md",
disabled,
onClick,
}: ButtonProps) {
return (
<button
disabled={disabled}
onClick={onClick}
className={`
${css({
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "md",
fontWeight: "medium",
})}
${variantStyles[variant]}
${sizeStyles[size]}
`}
>
{children}
</button>
)
}
The internals have changed completely; bg-blue-600, px-4, and rounded-md have vanished. Even so, the Feature side remains <Button variant="primary">Save</Button>. Of the three tiers — Feature, our own UI components, Tailwind CSS — only the bottom one changed. Tailwind CSS became Panda CSS, and that's all.
And it isn't just Panda CSS. With CSS Modules, with styled-components, even with Material UI, the only thing that gets swapped is the inside of Button.