Frontend styling technology has been replaced quite frequently. There was an era when Bootstrap was widely used, then CSS Modules and CSS-in-JS spread and styled-components and Emotion were used, and now Utility First CSS such as Tailwind CSS is the norm. Recently, options like Panda CSS, which emphasizes type safety and build-time CSS generation, have also appeared.
I don't want to talk about which one is the right answer. I think you should choose whatever is productive at the time and fits the project.
Given that, I make a point of not using a specific CSS framework directly from application code. I stopped writing Tailwind CSS classNames in Features and Pages, and instead put my own UI components in between, using the CSS framework only inside them. The dependency looks like this.
Application
↓
Own typed UI Component
↓
CSS Framework / UI Library
From the application's point of view, the CSS framework becomes an implementation detail. In this article, I'll organize, with code, why I use this structure and what changes when you actually swap the CSS framework.
The mainstream of styling won't necessarily be the same in 10 years
Since Bootstrap became widely used in the early 2010s, options have kept coming one after another: Foundation, Semantic UI, Bulma, Material UI, CSS Modules, styled-components, Emotion, Chakra UI, Tailwind CSS, and Panda CSS.
These are not 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 looking at any of them, there was no guarantee that whatever was mainstream when it appeared would still be mainstream 10 years later.
Neither Bootstrap nor Emotion was bad technology. Tailwind CSS is also a very good option today. What we should ask is not what to adopt, but how far the dependency on that technology spreads through the application.
If Features write Tailwind directly, Tailwind's API becomes your Design System
Suppose you implement a button with Tailwind CSS. Here is very typical code.
OrderPage.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 enough. You get Tailwind's benefits as is, and implementation is fast.
But as the product grows, another screen has this,
<button className="rounded bg-blue-500 px-3 py-2 text-white">
Save
</button>
and yet another screen ends up like this.
<button className="rounded-lg bg-sky-600 px-4 py-2 text-sm text-white">
Register
</button>
All of them are technically correct, and Tailwind works fine. But rules such as whether to use blue-500 or blue-600, rounded or rounded-md, px-3 or px-4, text-sm or text-base get scattered across the entire codebase. In this state, Tailwind's API itself is the application's Design System.
With a UI component as the boundary, Features only need to know meaning
So instead of using the CSS framework directly from Features, prepare a Button like the following.
Button.tsx
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
}
The Feature side becomes this.
export function OrderPage() {
return (
<Button variant="primary">
Confirm order
</Button>
)
}
The Feature side doesn't know bg-blue-600, px-4, rounded-md, or hover:bg-blue-700. All it knows is the meaning, variant="primary".
TypeScript types become the rules of the Design System
A big advantage of this structure is that you can constrain 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 can't be written.
If you use Tailwind directly, bg-blue-500, bg-sky-500, bg-indigo-500, and bg-[#4287f5] are all technically correct code. But the Design System may want to allow only one color. By enclosing that in the variant="primary" interface, you can separate "what can be written as CSS" from "what may be written as the product."
Use Tailwind only inside UI components
Inside Button, you would implement it like this, for example.
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>
)
}
There is no meaning in using Tailwind itself. What matters is that the places where Tailwind is used are limited to here. Don't put Tailwind classNames on the Feature side.
Don't make className part of the public API
Another thing I want to uphold is not allowing usage like<Button className="bg-purple-500 px-12">.
Even if you build UI components, letting className be passed freely breaks the boundary. Code like <Button className="bg-green-500">, <Button className="rounded-none">, and <Button className="px-[37px]"> will spread across the Feature side again.
So expose only the necessary changes as explicit Props such as <Button size="sm" /> or <Button variant="danger" />.
This is an approach taken in real products too. After migrating from Chakra UI / Emotion to Tailwind CSS + Radix UI, KEPPLE adopted a policy of exposing only limited Props such as size, rather than passing className through as is, so that styling doesn't become chaotic (migration article).
The same idea applies to layout
The same goes for frequently appearing layouts. Instead of writing<div className="flex flex-col gap-4"> everywhere, 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>
)
}
The Feature side just writes this.
<Stack gap="md">
<Heading>Shipping information</Heading>
<Text>Please check the delivery address</Text>
<Button>Confirm</Button>
</Stack>
This prevents values like gap-3, gap-4, and gap-[14px] from multiplying throughout the application.
Don't build your own Tailwind
A caution is needed here.
<Box
display="flex"
padding="4"
marginTop="2"
background="blue500"
borderRadius="md"
/>
If you turn CSS properties into Props wholesale like this, you end up with a giant homemade CSS framework that merely hides Tailwind. That defeats the point of the abstraction.
What you want to build is an API that is meaningful as your product's Design System. Like<Button variant="primary" />, <Text tone="muted" />, <Alert severity="warning" />, and <Stack gap="md" />, what you expose is not how to write CSS but the meaning as UI.
Style tests also gather in one place
With this design, style implementation concentrates in generic UI components, so style tests can also be placed in one spot. For Button you can write:
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")
})
})
However, this test itself depends on Tailwind. If you change from Tailwind to another implementation, you'll have to rewrite the test as well.
So one level up, use Visual Regression Testing. Prepare Stories in Storybook for Button's primary, secondary, danger, disabled, small, and large, and compare screenshots with Playwright or Chromatic, and you can confirm the appearance stays the same even when the internal implementation changes.
Ideally, the contract is split into three layers.
TypeScript → Contract of the UI API
Unit Test → Contract of behavior
Visual Regression Test → Contract of appearance
Even if you switch from Tailwind to Panda CSS, the Feature code doesn't change
The value of this design becomes clear when you actually swap the CSS framework.
The Feature-side code is as follows, and we don't change it at all.
<Button
variant="primary"
size="md"
>
Save
</Button>
Only the inside of Button changes. When migrating to Panda CSS, conceptually the implementation looks 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 internal implementation changes a lot, and bg-blue-600, px-4, and rounded-md are gone. Even so, the Feature side remains <Button variant="primary">Save</Button>.
Before: Feature → Own UI Component → Tailwind CSS
After: Feature → Own UI Component → Panda CSS
Only the bottom layer changed. Don't let a change of CSS framework propagate up to Features. That is the goal of this design.
Cases where styling foundations were actually migrated
This isn't just hypothetical. There are cases, in Japan and abroad, where the styling foundation was swapped midway.
Levtech: from Tailwind to Panda CSS
Levtech decided to migrate from Tailwind CSS to Panda CSS in a business application built with Next.js + TypeScript. About a year and a half after the project started, when the codebase exceeded 200 files, ambiguous style rules and inconsistency between screens became issues, and they reviewed their styling foundation (article).
This isn't a story of "Tailwind was bad." When a project's scale and requirements change, the best option changes too. That's why thinking about how far to let things depend is worth as much as choosing which CSS framework to use.
Airbnb: the abstraction layer paid off later
Particularly interesting abroad is Airbnb. In the process of migrating its Design System to React, Airbnb adopted CSS-in-JS and used Aphrodite. But it doesn't depend directly on Aphrodite. CSS-in-JS was still a new area at the time, so they built an abstraction layer calledreact-with-styles so they could switch to another implementation in the future.
Component
↓
Own Styling Interface (react-with-styles)
↓
Aphrodite
And years later, Airbnb actually migrated to Linaria (Airbnb's Trip to Linaria). It's an example where an abstraction built with the thought "it might change someday" really did help later. The fact that they detected visual regressions from style changes with screenshot tests also overlaps with the structure of this article.
Atlassian: from Emotion to Compiled CSS-in-JS
Atlassian has also changed the styling foundation of its Design System. In Atlaskit, which includes the Atlassian Design System, components that used Emotion are being migrated to Compiled CSS-in-JS. The reasons cited are runtime performance, JavaScript bundle size, and support for React 18's concurrent rendering and streaming SSR (RFC-73).
Once chosen, a styling approach doesn't stay fixed even in large organizations.
Thanks to AI, the cost of creating a boundary has dropped
Before, the objection "why abstract this far when you're just making one Button?" was quite persuasive. Creating the component, designing Props, defining types and variants, and even preparing tests and Storybook adds up to a fair amount of work. For small services, I think there were plenty of cases where writing Tailwind directly was clearly faster.
With AI-assisted coding now commonplace, this cost structure has shifted a little. Much of the routine work, such as component scaffolding, adding variants, type definitions, tests, Stories, and refactoring, can be left to AI. The implementation cost of building a boundary in the Design System has become much smaller than before.
If you have AI write the code, constraints matter even more
AI can generate code in large amounts, but when the degrees of freedom are high, it also generates subtle differences in large amounts.gap-3 on one screen, gap-4 on another, gap-[14px] on yet another. Or text-gray-500, text-slate-500, and text-zinc-500 get mixed. None of them is wrong on its own, so they tend to slip past review.
Rather than asking AI to "write some nice Tailwind," having it use only the limited interfaces <Stack gap="md">, <Text tone="muted">, and <Button variant="primary"> makes consistency easier to maintain. A Design System is a set of rules for humans, and at the same time it now also works as a constraint on AI.
Don't eliminate the dependency; limit where it lives
Just to be clear, this isn't saying "you shouldn't use Tailwind." I use Tailwind, and if Panda CSS fits, I use Panda CSS. If a UI library is needed, I use one. The dependency on a CSS framework remains; I'm just confining it to the UI layer.
What I consider most important in this design is not letting the CSS framework decide the Design System's public API. Don't makebg-blue-600 the public API; make <Button variant="primary"> the public API. What color primary is, how much padding, how large the border-radius is, and whether the inside is Tailwind or Panda CSS are the responsibility of the inside of the UI component. That way, we can own the Design System itself.
CSS-related technology will surely keep changing. Just as things moved from the Bootstrap era to CSS-in-JS and Utility First CSS, some other approach may be mainstream in a few years. There's no need to avoid new technology; you should actively use its benefits. But don't make that technology the public API of the whole application. Features know only our own typed UI API, the CSS framework is treated as an implementation detail behind it, and we swap it out when necessary.
This design, once somewhat heavy, has become quite realistic now that AI has lowered the cost of routine work. Rather than predicting which CSS framework will still be around in 10 years, build a boundary so that you're not in trouble even if the CSS framework changes. For a product operated over the long term, I think it's an option worth considering.
References
Japan
- KEPPLE "We migrated from Chakra UI to Tailwind CSS along with the Next.js update"
- A case of migrating from Chakra UI / Emotion to Tailwind CSS + Radix UI. More than 60 components and 17 pages were migrated in about 2 to 3 months. Also instructive is that after the migration they don't pass
classNamefreely and control styles through limited Props.
- A case of migrating from Chakra UI / Emotion to Tailwind CSS + Radix UI. More than 60 components and 17 pages were migrated in about 2 to 3 months. Also instructive is that after the migration they don't pass
- Levtech "On facing the frontend debt of a business app and deciding to migrate from Tailwind CSS to Panda CSS"
- A case in a Next.js + TypeScript business app that had adopted Tailwind CSS, deciding to migrate to Panda CSS after the project grew.
Overseas
- Airbnb Engineering "Airbnb's Trip to Linaria"
- A case where they prepared an abstraction layer called
react-with-stylesinstead of depending directly on Aphrodite, and later actually migrated to Linaria. Quite close to the design philosophy of this article.
- A case where they prepared an abstraction layer called
- Atlassian "RFC-73: Migrating our components to Compiled CSS-in-JS"
- A case of migrating Atlaskit components, including the Atlassian Design System, from Emotion to Compiled CSS-in-JS.