DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our Tailwind config is a v3 file that v4 reads through one @config line, and I measured which of its 80 lines reach the browser

Our stylesheet starts like this:

@import "tailwindcss";
@config "../tailwind.config.ts";
Enter fullscreen mode Exit fullscreen mode

Line one is Tailwind v4. Line two reaches back and loads an 80-line v3-shaped JavaScript config: content globs, a prefix, darkMode, a theme.extend, two keyframes and a require()d plugin. It works, because @config exists precisely so that a project can move to v4 without rewriting its theme the same afternoon. The question I had not asked is which parts of that file still decide anything.

The useful thing about this question is that it is answerable from outside. The compiled stylesheet is a public URL, and every claim below is a grep over it.

$ curl -s https://pub-trivia.app/ | grep -o '/_next/static/chunks/[a-z0-9]*\.css'
/_next/static/chunks/3lcp5uer5uji0.css
$ curl -s https://pub-trivia.app/_next/static/chunks/3lcp5uer5uji0.css | wc -c
86718
Enter fullscreen mode Exit fullscreen mode

Roughly 1,100 rules in 86 KB raw, 15,506 bytes over the wire with brotli.

The colours and the radii are doing the work

The theme.extend is the reason the config is still there. Twelve semantic colour names, each pointing at a CSS variable, and three radii derived from one more:

colors: {
  border: "hsl(var(--border))",
  background: "hsl(var(--background))",
  muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
  ...
},
borderRadius: {
  lg: "var(--radius)",
  md: "calc(var(--radius) - 2px)",
  sm: "calc(var(--radius) - 4px)",
},
Enter fullscreen mode Exit fullscreen mode

Both survive the trip:

$ grep -o '\.bg-muted{[^}]*}'    chunks.css
.bg-muted{background-color:hsl(var(--muted))}
$ grep -o '\.rounded-lg{[^}]*}'  chunks.css
.rounded-lg{border-radius:var(--radius)}
$ grep -o '\.rounded-md{[^}]*}'  chunks.css
.rounded-md{border-radius:calc(var(--radius) - 2px)}
Enter fullscreen mode Exit fullscreen mode

Those twelve names are used unevenly, by class-prefix count across the app: muted 449 times, card 52, destructive 47, accent 29, primary 28, ring 14, secondary 8, and popover and input 6 each. Nothing is dead, but the top one accounts for more than half of everything.

The radius family is the one place where the two Tailwind versions are now visibly mixed in a single utility group:

.rounded-sm{border-radius:calc(var(--radius) - 4px)}   <- our v3 config
.rounded-md{border-radius:calc(var(--radius) - 2px)}   <- our v3 config
.rounded-lg{border-radius:var(--radius)}               <- our v3 config
.rounded-2xl{border-radius:var(--radius-2xl)}          <- v4's own theme
.rounded-full{border-radius:3.40282e38px}              <- v4's own theme
Enter fullscreen mode Exit fullscreen mode

The two systems meet on one element, and you can watch it happen. Scroll the homepage and the header detaches into a floating bar. The class list it switches to is max-w-[1320px] rounded-2xl border border-border/60, so the corner radius comes from v4's own scale and the border colour comes from our v3 config, on the same <header>:

unscrolled   border-radius: 0px      max-width: 100%
scrolled     border-radius: 16px     max-width: 1320px
             border-color: oklab(0.30078 0.0000136644 0.00000602007 / 0.6)
Enter fullscreen mode Exit fullscreen mode

Nothing is broken by that, and nobody reading the class list would guess the two values come from different theme systems. The 3.40282e38px on rounded-full is v4 writing out the largest single-precision float where v3 used 9999px, which I enjoyed more than is reasonable.

The container is emitted and then overruled

The config configures the container utility:

container: { center: true, padding: "2rem", screens: { "2xl": "1400px" } },
Enter fullscreen mode Exit fullscreen mode

All of it compiles. The stylesheet carries nine .container declarations, including the two the config asked for:

.container{width:100%}
.container{max-width:40rem}   ... and four more breakpoint steps
.container{margin-inline:auto;padding-inline:2rem}
.container{max-width:none}
.container{max-width:1400px}
Enter fullscreen mode Exit fullscreen mode

Then the three call sites on the homepage look like this:

<div className="container px-4 md:px-8 max-w-4xl mx-auto ...">
Enter fullscreen mode Exit fullscreen mode

Every one of them re-states the centring, sets its own max width, and sets its own padding. The padding utilities appear later in the stylesheet than the container rule, at byte offsets 44,185 and 73,056 against the container's 20,039, so they win. Measured in the browser on the live homepage:

Viewport padding-left max-width
1512px 32px 896px, 672px, 1280px
390px 16px 896px, 672px, 1280px

At desktop width the padding is 32px, which is md:px-8 landing on the same number the config asked for by coincidence. At 390px it is 16px from px-4, so the configured 2rem loses. The max widths are max-w-4xl, max-w-2xl and max-w-7xl at every size, so the container's own ladder, including the 1400px step that the 2xl screen override exists for, never applies anywhere on the page. 2xl: appears zero times in our source, so that screen setting has exactly one potential consumer and that consumer is overridden.

The configured container is not wrong, it is just unused. The only call sites where its padding decides anything are inside the dashboard, behind a login, where the markup does not add its own.

Two keyframes for a component we do not have

keyframes: {
  "accordion-down": { from: { height: "0" }, to: { height: "var(--radix-accordion-content-height)" } },
  "accordion-up":   { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" } },
},
animation: {
  "accordion-down": "accordion-down 0.2s ease-out",
  "accordion-up": "accordion-up 0.2s ease-out",
},
Enter fullscreen mode Exit fullscreen mode

This is the block every shadcn/ui project starts with, and we never installed an accordion.

$ grep -rni accordion app components lib hooks | wc -l
0
$ grep -c accordion chunks.css
0
Enter fullscreen mode Exit fullscreen mode

Zero in the source, zero in the stylesheet. Tailwind only emits a keyframe when its animation utility is used, so an unused theme.extend.keyframes entry costs nothing at runtime, which is exactly why it has survived for months. The whole 86 KB sheet contains four keyframes:

$ grep -o '@keyframes [a-z-]*' chunks.css
@keyframes enter
@keyframes exit
@keyframes spin
@keyframes pulse
Enter fullscreen mode Exit fullscreen mode

enter and exit come from tailwindcss-animate, the one plugin the config require()s, and that plugin is genuinely load bearing: the dialog, the dropdown menu and the toasts all use its animate-in, fade-in-0, zoom-in-95 and slide-in-from-* utilities. spin and pulse are v4's own. So of the config's four animation-related lines, four are dead and the plugin they sit beside is not.

The thing I did not expect: 76 @supports blocks

Declaring colours as hsl(var(--border)) has a consequence I had not thought through. The value is an unresolved variable, so when a utility asks for partial opacity, Tailwind cannot compute the colour at build time. It emits this instead:

.border-border,.border-border\/40{border-color:hsl(var(--border))}
@supports (color:color-mix(in lab, red, red)){
  .border-border\/40{border-color:color-mix(in oklab, hsl(var(--border)) 40%, transparent)}
}
Enter fullscreen mode Exit fullscreen mode

Thirty-seven distinct slash-opacity utilities across the site, 76 @supports blocks to support them, and in every case the no-color-mix fallback is the fully opaque colour. A browser without color-mix does not get a subtler line, it gets the solid one, which is the right way round but worth knowing if you are relying on an opacity modifier to carry a visual distinction. You can see the pattern in the live stylesheet; the fallback is always the first selector in the pair.

A palette written as whole colours rather than raw HSL channels, which is what v4's @theme wants, would resolve the alpha at build time and none of those blocks would exist.

The scorecard

Config block Verdict
theme.extend.colors, 12 names Load bearing, all 12 used
theme.extend.borderRadius, 3 Load bearing, mixed with v4's own scale
plugins: [tailwindcss-animate] Load bearing, 2 keyframes and 4 utility families in use
darkMode: "class" Load bearing, the public site is forced dark
theme.container Compiles, overruled at every public call site
content globs, 4 Two name ./pages and ./src, neither of which exists
prefix: "" Decides nothing
keyframes + animation, 4 lines Dead, provably: zero occurrences in the stylesheet

Six lines I would delete today and two decisions I would rather make properly: whether container is a utility this project uses, and whether the palette moves into @theme as real colours. Neither is urgent, which is the whole reason a v3 config is still sitting in a v4 project. @config is a good compatibility door, and the risk it carries is not that it stops working, it is that nobody re-reads what walked through it.

If you want to poke at the result, the stylesheet URL is in the HTML of any page: the homepage is where the three container call sites are, pricing is where most of the slash-opacity colours are, and scrolling the homepage is the fastest way to see v4's radius scale and our v3 colour config on the same element. Any guide page ships eight rounded-lg and no rounded-2xl at all, which is how lopsided the usage really is.

Top comments (0)