Scale, Maintenance and Versions in Tailwind: Component Patterns, Class Sorting, tailwind-merge and Migrating from v3 to v4
Of two conflicting utilities, the one later in the CSS wins, not the one written later in the class attribute; so components should expose variants through a class map with full names and merge outside classes with tailwind-merge. We build this into bookstore-web's button, show tailwind-merge's custom token pitfall and Prettier sorting, measure with Playwright what npx @tailwindcss/upgrade converts and misses on a v3 page, and lay out the v4.0–v4.3 release map.
Version note: The examples in this post were run with Tailwind CSS 4.3.5 and 3.4.19, @tailwindcss/upgrade 4.3.5, tailwind-merge 3.7, Prettier 3.10 and prettier-plugin-tailwindcss 0.8.1, Angular 22.2 (TypeScript 6.0), Node.js 24 and Playwright 1.65 (Chromium 158).
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, draws the "Remove" button on the cart page with the shared button component. The component's own classes are bg-brand-600 px-6 py-2. The cart page wants the button red and a bit narrower, and the caller adds bg-red-600 px-4. The element ends up like this:
<button id="a" class="bg-brand-600 px-6 py-2 bg-red-600 px-4">Remove</button>
The caller's classes come last. Is the button red or blue? Is its horizontal padding 16px (px-4) or 24px (px-6)?
This is the maintenance post of the series: building component APIs with variants in a growing project, merging conflicting classes, locking down class order with a tool, migrating from v3 to v4 and keeping up with releases. Once the Institutional Orders screens, the admin panel and the catalog start sharing the same buttons, cards and badges, these stop being theoretical problems.
The Winner Is the CSS Order, Not the Class Order
We measured the button from the guess and a second button with the same classes in reverse order:
node scripts/06-conflict.mjs
class="bg-brand-600 px-6 py-2 bg-red-600 px-4" → bg oklch(0.577 0.245 27.325), padding-inline 24px, display inline-block
class="bg-red-600 px-4 bg-brand-600 px-6 py-2" → bg oklch(0.577 0.245 27.325), padding-inline 24px, display inline-block
class="grid flex" → bg rgba(0, 0, 0, 0), padding-inline 0px, display grid
Both buttons are red and both have 24px of horizontal padding. The caller won on color and the component won on padding; the order in the class attribute changed nothing. In the cascade, of two rules with the same specificity the one that comes later in the stylesheet wins, and Tailwind decides that order:
.flex {
.grid {
.bg-brand-600 {
.bg-red-600 {
.px-4 {
.px-6 {
bg-red-600 wins because it comes after bg-brand-600; px-6 comes after px-4. The Tailwind docs state the same rule and show it with the class="grid flex" example. This order is an internal detail that can change from page to page, even when a new class is added; you don't write overrides that rely on it. The fix has two parts: the component should expose meaningful options rather than classes, and if it does accept classes from outside, it should merge the conflicting ones.
A Component API with Variants: A Small Class Map
The bookstore-web button takes two options: intent (primary, secondary, danger) and size (sm, md). The classes for each option live in a Record, written in full:
import { Component, computed, input } from '@angular/core';
import { extendTailwindMerge } from 'tailwind-merge';
export type ButtonIntent = 'primary' | 'secondary' | 'danger';
export type ButtonSize = 'sm' | 'md';
// Bookstore's custom font sizes, so that tailwind-merge does not read them as colors
const merge = extendTailwindMerge({ extend: { theme: { text: ['hero'] } } });
const BASE =
'inline-flex items-center justify-center rounded-md font-medium focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
// one entry per variant, every class written in full
const INTENT: Record<ButtonIntent, string> = {
primary: 'bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600',
secondary:
'border border-slate-300 bg-white text-ink-900 hover:bg-slate-50 focus-visible:outline-slate-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600',
};
const SIZE: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
};
export function buttonClasses(
intent: ButtonIntent = 'primary',
size: ButtonSize = 'md',
extra = '',
): string {
return merge(BASE, INTENT[intent], SIZE[size], extra);
}
@Component({
selector: 'button[appButton]',
template: '<ng-content />',
host: { '[class]': 'classes()' },
})
export class Button {
readonly intent = input<ButtonIntent>('primary');
readonly size = input<ButtonSize>('md');
// classes the caller adds; they win over the variant's classes
readonly extra = input('', { alias: 'class' });
protected readonly classes = computed(() =>
buttonClasses(this.intent(), this.size(), this.extra()),
);
}
Since the selector is button[appButton], the component sits on a real <button> element; keyboard, form and disabled behavior come from the browser. The card uses it as <button appButton size="sm" [intent]="soldOut() ? 'secondary' : 'primary'" [disabled]="soldOut()">. This structure has two benefits. Record<ButtonIntent, string> catches a missing entry at compile time when a new intent is added; and since every class is written in full in the source, the scanner sees all of them. A library such as class-variance-authority (0.7.1) does the same job; for a two-option button in Bookstore, a Record was enough.
buttonClasses is a pure function, so it's easy to test:
npx ng test --watch=false
Test Files 2 passed (2)
Tests 4 passed (4)
tailwind-merge: Merging Conflicting Classes
The "Clear" button on the catalog page carries intent="secondary" size="sm" class="ml-auto px-2". The sm size gives px-3, the caller wants px-2. tailwind-merge reads the class list from the end and drops earlier classes that write to the same property group:
node scripts/06-merge.mjs
twMerge("bg-brand-600 px-6 py-2", "bg-red-600 px-4") → py-2 bg-red-600 px-4
twMerge("grid", "flex") → flex
twMerge("p-4", "px-2") → p-4 px-2
twMerge("px-2", "p-4") → p-4
twMerge("text-lg text-ink-900", "text-hero") → text-lg text-hero
twMerge("hover:bg-brand-700", "hover:bg-red-700") → hover:bg-red-700
extended: ("text-lg text-ink-900", "text-hero") → text-ink-900 text-hero
The first line solves the problem from the guess: bg-brand-600 and px-6 are gone and the caller's classes stay. In p-4 px-2 both stay, because px-2 only overrides the horizontal padding and px-* comes after p-* in the CSS; in the reverse order px-2 is dropped, because p-4 covers both horizontal and vertical padding. Variants are kept separate: two colors with the hover: prefix override each other but don't touch a color without hover:.
The fifth line is the real pitfall. text-hero is a font size Bookstore defines in @theme (--text-hero). tailwind-merge doesn't read Tailwind's CSS; it has its own rules, and it takes an unknown value after text- for a color. As a result it dropped the text-ink-900 color and kept the text-lg font size: exactly backwards. After adding hero to the text theme group with extendTailwindMerge, it gave the right result. When you add values to your own @theme namespaces (font size, shadow, radius), you also have to add them to the tailwind-merge config; otherwise the bug is silent.
We measured it in the browser:
node scripts/06-button.mjs
Clear (intent=secondary size=sm class="ml-auto px-2") → has px-3: false, has px-2: true, padding-left 8px, margin-left auto
Add to cart disabled=false bg oklch(0.54 0.178 264) opacity 1 cursor default
Add to cart disabled=false bg oklch(0.54 0.178 264) opacity 1 cursor default
Notify me disabled=true bg rgb(255, 255, 255) opacity 0.5 cursor not-allowed
Add to cart disabled=false bg oklch(0.54 0.178 264) opacity 1 cursor default
There is a cost too: when we replaced merge with a plain join(' ') and built, the main bundle went from 281.83 kB down to 253.18 kB; tailwind-merge brings about 28.6 kB raw and 7.6 kB transferred. The Tailwind docs recommend adding meaningful options to the component instead of accepting arbitrary classes from outside. In Bookstore we use the class override only for layout classes (ml-auto, w-full); a change of color or size comes as a new intent or size.
Sorting Classes with Prettier
Writing the same classes in a different order in every file makes diffs unreadable. prettier-plugin-tailwindcss orders classes by Tailwind's order in the CSS. In v4 the plugin needs tailwindStylesheet to know the theme and custom utilities:
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./conflict.css",
"printWidth": 140
}
npx prettier 06/unsorted.html
<button
class="custom-legacy rounded-md bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 focus-visible:outline-2 disabled:opacity-50 md:px-6"
>
Add to cart
</button>
The order is: classes Tailwind doesn't know (custom-legacy) first, then layout and box model, typography, color, and the classes with variants last. In the first attempt --color-brand-700 wasn't in the stylesheet; the plugin didn't recognize hover:bg-brand-700 and put it at the front too, next to custom-legacy. If a class unexpectedly jumps to the front, it is usually either a typo or a token that isn't in the stylesheet.
In bookstore-web we added the same config to .prettierrc and ran npx prettier --write "src/app/**/*.{html,ts}". Angular's inline templates were processed too: the classes in the format filter had been written with aria-selected:* first and focus-visible:* after; the plugin moved the focus-visible: group to the front. The INTENT and SIZE strings in TypeScript were only wrapped for line length, not sorted. For sorting strings passed to a function, the plugin has a tailwindFunctions option (its README shows ["clsx"]); we did not try it. Which rule the sorting follows is a team decision; the rule itself is the topic of the Coding Conventions and Team Standards series.
From v3 to v4: npx @tailwindcss/upgrade
One of Bookstore's old back-office pages is still on Tailwind 3.4.19: a tailwind.config.js, three @tailwind lines, a button written with @apply inside @layer components, and v3 class names:
module.exports = {
content: ["./index.html"],
theme: {
extend: {
colors: {
brand: { 600: "#3766d4", 700: "#2a53b3" },
},
fontFamily: {
display: ["Georgia", "Cambria", "serif"],
},
},
},
};
<div id="panel" class="mt-4 rounded border p-4 shadow-sm">
<input id="search" class="rounded-md border px-2 py-1" placeholder="Search by ISBN">
<button id="save" class="btn-primary">Save</button>
<span id="badge" class="rounded-full bg-black bg-opacity-50 px-2 text-white">Draft</span>
<img id="cover" class="flex-shrink-0 shadow" src="" alt="" width="40" height="60">
<p id="total" class="!font-bold">Total: 1240.00 TRY</p>
</div>
The official upgrade tool requires Node.js 20 or later and writes its changes straight into the files, so we ran it in a clean git working tree:
npx @tailwindcss/upgrade@4.3.5
≈ tailwindcss v4.3.5
│ ↳ Upgrading from Tailwind CSS `v3.4.19`
│ Searching for CSS files in the current directory and its subdirectories…
│ ↳ Linked `./tailwind.config.js` to `./src/input.css`
│ Migrating JavaScript configuration files…
│ ↳ Migrated configuration file: `./tailwind.config.js`
│ Migrating stylesheets…
│ ↳ Migrated stylesheet: `./src/input.css`
│ Updating dependencies…
│ ↳ Updated package: `tailwindcss`
│ Migrating templates…
│ ↳ Migrated `./index.html`
│ ↳ Migrated templates for configuration file: `./tailwind.config.js` (1 file changed)
│ ↳ No PostCSS config found, skipping migration.
│ Verify the changes and commit them to your repository.
The changes in the template:
- <div id="panel" class="mt-4 rounded border p-4 shadow-sm">
+ <div id="panel" class="mt-4 rounded-sm border p-4 shadow-xs">
- <img id="cover" class="flex-shrink-0 shadow" src="" alt="" width="40" height="60">
- <p id="total" class="!font-bold">Total: 1240.00 TRY</p>
+ <img id="cover" class="shrink-0 shadow-sm" src="" alt="" width="40" height="60">
+ <p id="total" class="font-bold!">Total: 1240.00 TRY</p>
The tool translated the renamed utilities correctly: v3's rounded is rounded-sm in v4, shadow-sm → shadow-xs, shadow → shadow-sm, flex-shrink-0 → shrink-0, and the leading ! moved to the end. It deleted tailwind.config.js and moved its contents into @theme; it turned the button in @layer components into @utility btn-primary (focus:outline-none → focus:outline-hidden, focus:ring → focus:ring-3). For the default border color changing from gray-200 to currentColor, it also added a commented compatibility block to the CSS.
Then we compared the two versions with Playwright; the tool had not caught everything:
| Element | v3.4.19 | v4.3.5 after the upgrade |
|---|---|---|
| --- | --- | --- |
| panel border color | rgb(229, 231, 235) | oklch(0.928 0.006 264.531) (compatibility block) |
#save cursor | pointer | default |
#save:focus ring | rgba(59, 130, 246, 0.5) 0px 0px 0px 3px | rgb(255, 255, 255) 0px 0px 0px 3px |
#badge background | rgba(0, 0, 0, 0.5) | rgb(0, 0, 0) |
There are three differences. bg-opacity-50 was removed in v4 and the tool didn't turn it into bg-black/50; the badge became solid black. focus:ring-3 is now 3px, but its color is not v3's semi-transparent blue but currentColor, which is the button's white text color. And v4's Preflight doesn't give buttons cursor: pointer. We fixed all three by hand with their counterparts from the upgrade guide: bg-black/50, focus:ring-3 focus:ring-blue-500/50, and the cursor: pointer block from the guide. In the final measurement all three match v3 again. The build also broke: the tailwindcss -i ... script in package.json lives in the @tailwindcss/cli package in v4; the tool had only updated tailwindcss, and in an isolated copy npm run build said "tailwindcss: command not found". npm i -D @tailwindcss/cli fixed it.
The lesson of this experiment: the upgrade tool does most of the work, but "the tool finished, merge it" is not enough. Before migrating, capture the computed styles or screenshots of critical screens, then compare.
The Browser Baseline and Staying on v3.4
v4 relies on modern CSS features (cascade layers, @property, color-mix()) and targets Safari 16.4, Chrome 111 and Firefox 128. Imagine one of Bookstore's institutional customers working with a locked-down, old browser: moving those screens to v4 means a broken UI. The upgrade guide says to stay on v3.4 in that case, and npm supports it explicitly:
npm view tailwindcss dist-tags --json
{
"next": "4.0.0",
"v3-lts": "3.4.19",
"latest": "4.3.5",
"insiders": "0.0.0-insiders.a3cb19c"
}
The v3-lts tag points to 3.4.19, released on 10 December 2025. Pinning the app that needs old browser support with tailwindcss@v3-lts while writing new apps on v4 is possible even in the same monorepo, as long as the two apps have separate package.json files.
Release Map
According to npm publish dates and Tailwind's blog announcements, the v4 series:
| Version | npm date | Highlights |
|---|---|---|
| --- | --- | --- |
| 4.0 | 21 January 2025 | CSS-first configuration, @theme, automatic content detection, Vite plugin, built-in container queries, starting:, not-* |
| 4.1 | 1 April 2025 | text-shadow-*, mask-*, @source not, @source inline(), colored drop-shadow-*, pointer-* variants, wrap-break-word |
| 4.2 | 18 February 2026 | @tailwindcss/webpack, mauve/olive/mist/taupe palettes, block-direction logical utilities, font-features-* |
| 4.3 | 8 May 2026 | scrollbar-*, @container-size, zoom-*, tab-*, stacked and compound @variant, --default() |
| 4.3.5 | 6 November 2026 | The patch release this series uses |
The v4.2 announcement also brought a deprecation: start-* and end-* were deprecated in favor of inset-s-* and inset-e-*. 4.3.5 still generates both, but the canonicalize command suggests the new one:
npx tailwindcss canonicalize start-0
inset-s-0
Two sources are enough to follow releases: the GitHub releases page and the blog. Minor releases bring new utilities and don't break existing classes; still, doing the upgrade in a separate pull request and measuring critical screens is a good habit.
In Production
In a large application you don't have to migrate from v3 to v4 in one go. In Bookstore the back office is a separate build target; you can move it first and leave the catalog and checkout for the next release. What matters is not loading the CSS of two Tailwind versions on the same page: classes with the same name but different values in v3 and v4, such as .shadow-sm, overlap, and once again the stylesheet order decides which one wins.
If you use tailwind-merge, think about its version together with Tailwind's. tailwind-merge 3 knows Tailwind v4's class names; don't assume that new utilities such as v4.2's inset-s-* or v4.3's scrollbar-* are recognized by tailwind-merge without testing. That's exactly why we wrote unit tests for buttonClasses.
Run the Prettier plugin in CI with prettier --check. Locally the editor sorts on save, but if someone on the team hasn't installed the plugin, unsorted classes make it into the repo.
Common Pitfalls
The first is the pitfall from the guess: assuming that of two conflicting utilities, the one later in the class attribute wins. The winner is the one later in the CSS.
The second is not teaching tailwind-merge your custom tokens. Mistaking text-hero for a color and deleting text-ink-900 produced no warning at all; the button just showed up in the wrong color.
The third is v3 habits: writing shadow-sm and expecting v3's thin shadow (in v4 that is v3's shadow), writing ring and expecting a 3px blue ring (in v4 it's 1px and currentColor), using outline-none to hide the focus ring (in v4 it really is outline-style: none; the accessible version is outline-hidden), and using v3's right-to-left order in chained variants (v4 is left to right; *:first:pt-0 instead of first:*:pt-0).
The fourth is writing start-* and end-* in new code. They work today but are deprecated; write inset-s-* and inset-e-*.
A Small Experiment
★☆☆ Add two elements to 06/conflict.html, one with class="p-4 px-2" and one with class="px-2 p-4". Guess the result first, then measure the computed padding with 06-conflict.mjs and compare it with what tailwind-merge says about the same two lists.
★★☆ Add a fourth intent named ghost to the bookstore-web button (bg-transparent, bg-slate-100 on hover). See how the Record type catches the missing entry, then write a unit test for buttonClasses.
★★★ Bring the 06-v3 folder back to its v3 state from this post (take tailwind.config.js and the old classes from the code blocks above and install tailwindcss@3.4.19), add a list that uses ring-offset-2, divide-y and space-y-4 to the template, run the upgrade tool again, and extend 06-legacy.mjs to measure what the tool changed for these three.
In this part of the series we have set up Tailwind itself: the approach, configuration, tokens, variants, Angular and ASP.NET Core integration, and maintenance. The next two posts move on to the utility families we write every day; in the first, we read the layout, flex, grid, spacing, sizing and position utilities side by side with the CSS they produce, through Bookstore's catalog grid, book card and cart page.