The whole class handling layer of our app is five lines:
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
That is lib/utils.ts, the file every shadcn/ui install drops in, used at 54 call sites across 18 files here. Two dependencies, one function, zero configuration. I wanted to know what it actually produces in production, so I fetched all 72 URLs from pub-trivia.app/sitemap.xml and counted.
What 72 pages of Tailwind weigh
HTML, all 72 pages 5,081,245 bytes
class attributes 13,379
bytes inside them 674,045 (13.3% of the HTML)
distinct class strings 324
Thirteen per cent of every byte we serve is the contents of class. That number sounds like an indictment and is not, because of the third line: 13,379 attributes are built from 324 unique strings. The homepage is 150,475 bytes raw and 16,824 over the wire with brotli, a ratio of about nine to one, and repeated identical strings are exactly what a compressor is for. The cost of utility classes in the HTML is real and it is mostly paid by the compressor, not by the user.
The repetition is heavier than I expected:
1,380 text-sm text-muted-foreground hover:text-foreground transition-colors
727 text-muted-foreground leading-relaxed
471 min-w-0
462 flex gap-4 py-4
The top one is a footer link. It appears about 19 times per page, because that is how many links the footer carries: the nav columns, the three legal pages, Sign in, Sign up, Dashboard. One styling decision, 1,380 copies, 95 KB of the 674.
The missing class
Here is the longest class attribute on the site, 379 characters, from the team name generator page:
inline-flex items-center justify-center whitespace-nowrap text-sm font-medium
ring-offset-background transition-colors focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50 border border-input
bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2
mt-4 rounded-full
Now here is the start of the base string in components/ui/button.tsx, which every one of those elements is built from:
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ..."
)
rounded-md is the fourth class in the base. It is not in the rendered output. The call site asked for rounded-full, and twMerge worked out that the two belong to the same group and dropped the loser before React ever saw it.
That is the only visible evidence tailwind-merge does anything, and you have to look for an absence to find it. So I counted the absences. Of the 13,379 class attributes, 222 are built from the button base, in 10 distinct strings:
occurrences pages rounded-md survived?
69 69 no (ghost, h-9, rounded-full)
69 69 no (default, h-9, rounded-full)
67 67 no (default, h-11, rounded-full)
6 1 yes (outline, h-9, rounded-md)
4 2 no
2 2 no
2 1 no
1 1 no
1 1 no
1 1 no
Nine of the ten strings had a class removed. 216 of the 222 rendered buttons on this site are the shadcn base with its own border radius deleted, because somebody decided a while ago that our buttons are pills. Without twMerge, every one of those elements would carry both rounded-md and rounded-full, and which one won would depend on the order Tailwind happened to emit them in the stylesheet, not on the order you wrote them in the JSX. That is the actual argument for the dependency: not tidiness, but that CSS specificity does not care about your intent and twMerge does.
The variant inventory
Four files use class-variance-authority: the button, the badge, the toast and the label. The button declares the most:
variants default, destructive, outline, secondary, ghost, link
sizes default, sm, lg, icon
Across 75 <Button> call sites, variant="outline" appears 38 times, ghost 8, secondary 3, destructive 2, and link never. Not once. The link variant exists in the file because it exists in the shadcn registry, and this app renders text links as links.
My favourite of the four is the label:
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
No second argument. No variants, no sizes, no defaults. cva() here is a function call whose only job is to return the string it was given, wrapped so that VariantProps<typeof labelVariants> can be spread into the component's prop type, where it contributes no props. It is the shape of the pattern with the pattern removed, kept because deleting it would make this file different from the upstream one.
One more thing I found in the config
components.json, which shadcn's CLI reads, records the choices made when the components were installed:
"tailwind": { "baseColor": "slate", "cssVariables": true }
Our dark palette in app/globals.css is written in HSL and every colour in it has a hue of zero:
.dark { --background: 0 0% 9%; --foreground: 0 0% 98%; --border: 0 0% 18%; }
Pure neutral grey. Slate is a blue grey, hue 222. The config says one thing and the stylesheet says another, and the stylesheet wins, because baseColor is only read when the CLI generates a new component. It is a record of a decision that was later reversed, in a file nobody reads again after the first install.
Check it yourself
curl -s https://pub-trivia.app/tools/team-name-generator \
| grep -o 'class="inline-flex[^"]*"' | head -1
Look for rounded-md in the output. It is not there. Then look at the shadcn button source, where it is.
And if you want to see the footer links that account for 1,380 of those class attributes, they are at the bottom of every page, including pub-trivia.app/features and pub-trivia.app/pricing.
The lesson I took from the measurement: the two dependencies behind cn() do nothing you can see, one of them earns its place 216 times on the page you are looking at, and the other one is mostly there to let you write cn(a, b && c) without a join. Both are load bearing. Neither is visible. That is a slightly uncomfortable thing to say about 13.3 per cent of your bytes.
Top comments (0)