className Considered Harmful
Most component libraries accept className everywhere. I get it. A team needs a special case, or thinks it does, and className lets them ship without waiting for a new variant or asking anyone. Marketing wants a pill-shaped button. Logs wants a shorter select in a dense toolbar. Settings wants green switches. Each of those is an override: a class passed to a system component to change how it looks. Other teams copy those versions, and after a couple of years the components look like this:
This also makes change management hard, because callers end up relying, often without knowing it, on implementation details that should have been private. That is Hyrum’s Law:
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.
One team used *:size-4 to make the switch’s knob bigger, and that class only works as long as the switch’s markup stays the same. Change the markup and their switch breaks. You might even say the markup is load-bearing . With className, every class and element inside a component becomes part of its API.
Design tokens help. They limit which colors and sizes a team can pick, but teams can still combine them however they like. Here is a Badge with a soft-green success variant that three teams changed using only approved tokens:
All four badges are on brand: every color and size comes from an approved token. They are still four different badges for the same status.
How some libraries limit it
Libraries deal with this in different ways. Here is how a few of them do it:
In Polaris and Braid, a component’s look only changes through its props, so if you need something the props don’t cover, you wait for the library to ship it. With UNSAFE_className, a team can still override anything, but the name makes it obvious in code review and easy to grep for. In React Aria and shadcn/ui, you write the styles, so keeping them consistent is up to you.
No design system team can know every case the teams using it will run into, so some flexibility has to live somewhere. Polaris and Braid keep it in the library. className leaves it with the team using the component. I think picking between those is mostly a decision about people: how quickly the design system team can ship a new variant, and how long the teams using it can wait. Whatever your library does, you still need to see how it’s being used.
Trust, but verify
Start with an inventory: every className passed to a component from your design system package, plus style props and CSS that targets its markup, grouped by component and class. Before you change a component, this tells you what might break. It also tells you when a new variant is taking shape that nobody has raised with the design system team yet. I don’t think that kind of drift is bad. A system that people use will always get asked for things it doesn’t have yet, and the overrides are often where those needs show up first.
A scary prop name is the cheapest way to get this list. UNSAFE_className only exists on system components, so a text search finds every use. React did the same thing to its own internals, which shipped under this name until React 19:
The classes that repeat across teams matter most. If five teams add rounded-full to Button, that might be a variant worth adding. If five teams add -mt-px to Checkbox to line up its label, that might be a bug nobody reported.
A lint rule keeps new overrides from getting in. Atlassian’s rule errors on any className, with this message: “Avoid className because it invites the use of unsafe global styles and is impossible to determine via local tooling.” shadcn/lint’s no-restyle rule reports classes outside the categories you allow, such as layout.
Think of it as CI for your design system: compare the override inventory with main on every pull request and post anything new to the team that manages the design system. Blocking the pull request is the strictest option, and depending on how many overrides already exist, it can stall a lot of teams at once.
padding="none"size propgap in the parentWhere I’ve landed
Kumo, the design system I help maintain at Cloudflare, accepts className on most of its components. Text is the one that doesn’t: it only takes DANGEROUS_className and DANGEROUS_style. I’d like to rename it on every component. Then a grep reveals the entire inventory:
Text has worked this way for a while, and we have already learned something from it. font-bold showed up often enough that Text got a bold prop and our design guidelines got a rule against the override.
I haven’t opened that pull request yet.
className to DANGEROUS_className on every component #4821main from rename-classnameIf you’ve made this change on a library that was already in use, I’d really like to hear how it went.
