Fix Tailwind Class Conflicts

Merge conflicting Tailwind classes correctly and benchmark the merge speed

Introduction

30 Second Summary

You build a button, pass it a padding class from the parent, and nothing moves. The class is right there in the markup, and the browser ignores it.

In this project, you will reproduce that exact bug in a Tailwind component and fix it with a class merging engine. You will also benchmark how fast that merge runs.

What You'll Build

A page of buttons where your padding override finally wins, with the merge timings printed right next to them.

By the end of this project, you'll have:

  • A button that respects the className you pass it, so a padding class from the parent visibly changes the button instead of being silently swallowed.
  • A before and after you can see in the browser. You reload once and watch your override get ignored, then reload again and watch it take effect.
  • A row of size and colour variants where every button resolves to exactly one padding and one background, no leftovers fighting each other.
  • A benchmark whose numbers land on the page. You merge the same class list many times, read the two timings in your terminal, then show them next to the buttons.
  • Secret Mission: An optional challenge to push your skills further.

Are there any prerequisites?

You need Node on your machine and nothing else. There is no account to create, no API key, and no card, since both packages are open source and everything runs locally.

Before We Start

Pass a padding class into a Tailwind component that already sets its own padding, and one of them silently wins. Usually not the one you wanted.

Before you can reproduce that bug, you need somewhere to render it. In this step you'll set up a React app with Tailwind CSS compiling on every save.

In this step, get ready to:
  • Check that Node.js and npm are installed.
  • Scaffold a Vite React project and run it in your browser.
  • Install Tailwind CSS and plug it into the build.
Check Node.js is installed

Vite runs on Node.js, and npm is the package installer that ships alongside it. Both need to be on your machine before anything else.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check which version of Node.js you have by running this command:
node --version

✔️ I see version 20.19.0 or higher

Node.js is ready, and npm came along with it.

One catch. If your version number starts with 22, Vite needs it to be 22.12.0 or higher.

ⓧ I see a version lower than 20.19.0

Vite needs Node.js 20.19.0 or higher. The official installer replaces your current version in place.

  • Open the Node.js download page.
  • Download the LTS installer for your operating system.
  • Run the installer and accept the default options.
  • Close your terminal window.
  • Open a new terminal window with Cmd+Space (macOS) or the Windows key (Windows).
  • Confirm the upgrade landed by running this command:
node --version

You should now see 20.19.0 or higher.

ⓧ Command not found

Node.js isn't on your machine yet. Installing it also gives you npm.

  • Open the Node.js download page.
  • Download the LTS installer for your operating system.
  • Run the installer and accept the default options.
  • Close your terminal window.
  • Open a new terminal window with Cmd+Space (macOS) or the Windows key (Windows).
  • Confirm the install worked by running this command:
node --version

You should now see a version number of 20.19.0 or higher.

Still no version number after installing?

A terminal window only picks up a new install when it starts, so an old window keeps reporting the old result. Close every terminal window and open a fresh one.

On Windows, the installer needs to finish completely before the command resolves. Why does my terminal still say node is not recognised?

Scaffold and run a Vite React app

Vite hands you a React project plus a dev server that reloads the page every time you save. That reload loop is what makes the class conflict visible later on.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Press Enter if npm asks permission to install the create-vite package.
  • Create the project by running this command:
npm create vite@latest cn-buttons -- --template react

What does this command do?

  • npm create vite@latest downloads the latest project generator and runs it.
  • cn-buttons is the folder it creates on your Desktop.
  • --template react skips the framework menu and scaffolds React straight away.
  • Confirm the folder landed by running this command:
ls

You should see cn-buttons listed among the items on your Desktop.

  • Move into the project and install its packages by running these commands:
cd cn-buttons
npm install

The terminal prints a line telling you how many packages were added.

Install stopped with an error?

Check the terminal prompt shows you are inside cn-buttons. Running the install from the Desktop instead of the project folder is the usual cause.

A dropped network connection mid-install also leaves the packages half written, and re-running the command fixes it. Help me read this npm install error

  • Start the dev server by running this command:
npm run dev

This terminal is now busy

The dev server keeps running and prints a local URL, usually http://localhost:5173/.

You won't get the prompt back while it runs. Press Ctrl+C whenever you need to stop it.

  • Open the local URL from the terminal in your browser.

A dark page appears with the Vite and React logos, a heading, and a counter button underneath.

Add Tailwind CSS to your app

Tailwind CSS is a build step, not a stylesheet you drop in. Vite has to run it over your files before a single utility class exists.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the cn-buttons folder on your Desktop.
  • Click Open.
  • Click Yes, I trust the authors if VS Code asks whether you trust the folder.

The Explorer sidebar now lists your project files, including src, vite.config.js and package.json.

  • Switch back to the terminal window running your dev server.
  • Press Ctrl+C to stop the dev server and get the prompt back.
  • Install Tailwind CSS and its Vite plugin by running this command:
npm install tailwindcss @tailwindcss/vite

What are these two packages?

  • tailwindcss is the engine that turns utility class names into real CSS.
  • @tailwindcss/vite is the plugin that lets Vite run that engine every time you save.
  • Start the dev server again by running this command:
npm run dev

The starter page is back in your browser, still styled exactly as before. The plugin is installed but nothing has told Vite to use it yet.

  • In the Explorer sidebar, click vite.config.js to open it.
  • Find these two import lines at the top of the file:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
  • Add the Tailwind plugin import below them, so the imports look like this:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
  • Find the plugins line further down the file:
  plugins: [react()],
  • Add the Tailwind plugin to the array, so the line looks like this:
  • Save vite.config.js.
  plugins: [react(), tailwindcss()],

What did registering the plugin do?

Vite now runs the Tailwind engine over your project on every save, right alongside the React plugin.

The engine still needs one instruction from your CSS before it produces anything.

  • In the Explorer sidebar, expand the src folder.
  • Click index.css to open it.
  • Select everything in the file with Cmd+A (macOS) or Ctrl+A (Windows).
  • Press Delete to clear the file.
  • Type this single line in its place:
@import "tailwindcss";

What does this one line do?

It pulls in Tailwind's base reset and makes every utility class available to your components.

You just deleted the styling the Vite template shipped with, so this line is the only CSS your app has left.

Before you save, what do you think the starter page will look like once Tailwind's reset is the only CSS on it?

  • Save index.css.
  • Switch back to the browser tab running your app.

The page turns white, the heading shrinks, and the counter button loses its rounded dark styling. Tailwind's reset has replaced the template's own CSS, which proves the engine is compiling into your app.

Page looks exactly the same?

Check that vite.config.js is saved with tailwindcss() inside the plugins array. Without it, your CSS import does nothing.

Also check the terminal still has the dev server running, since a stopped server serves nothing new. Why isn't my Tailwind import taking effect?

Your app is running and Tailwind recompiles on every save. Next up, you'll build the button whose base classes set its padding and background.

Build a Base Button Component

Your Vite and React project is already live in the browser, with Tailwind CSS wired up. Now you need a component to put it to work.

Almost every component library starts the same way. A button hard-codes the classes it wants, and every caller gets that exact look.

That works right up until someone needs different padding. Before you can fix that bug, you need the button that causes it.

In this step, get ready to:
  • Create a Button component in its own file.
  • Render your button on the page.
  • Give the button base Tailwind classes for padding and background.
Create your button component file

Components live in their own files so any part of the app can import them. Your project has no home for them yet.

  • In your editor's Explorer sidebar, right-click the src folder.
  • Click New Folder.
  • Type components and press Enter.
  • Right-click your new components folder.
  • Click New File.
  • Type Button.tsx and press Enter.
  • Confirm Button.tsx now sits inside src/components in the sidebar.
  • Give your component something to render by copying this code into the empty Button.tsx:
// Button renders one <button> with the base look every caller gets.
function Button() {
  return <button>Click me</button>;
}

export default Button;

What does this code do?

  • function Button() defines a React component. Any file that imports it gets this exact markup.
  • The returned <button> is plain JSX, so it carries no styling of its own yet.
  • export default Button is what lets other files import it by name.
  • Save Button.tsx.
  • Confirm your editor shows no red underlines anywhere in the file.

Seeing a red underline in Button.tsx?

  • Check the file extension is .tsx and not .ts. JSX only compiles in a .tsx file.
  • Check the capital B in the file name. Component files are case-sensitive when you import them.
  • Still stuck? Ask why my component file is showing an error.
Render your button on the page

A component only exists once something renders it. App.tsx is the file Vite already renders at your preview URL.

  • Open src/App.tsx from the Explorer sidebar.
  • Select everything in the file and delete it.
  • Put your button on the page by copying this code into the empty file:
import Button from "./components/Button";

// App is the component Vite renders at your preview URL.
function App() {
  return (
    <div>
      <Button />
    </div>
  );
}

export default App;

What does this code do?

  • The import points at ./components/Button, the file you just created.
  • <Button /> renders your component with no props at all. That is the caller in its simplest form.
  • Deleting the old contents also drops the starter App.css import, so nothing competes with your Tailwind classes.
  • Save App.tsx.
  • Switch back to the browser tab showing your preview URL.
  • Confirm you see a plain, unstyled button labelled Click me in the top left of the page.

Blank page instead of a button?

  • Check the terminal running your dev server. A red error there names the exact file and line that failed to compile.
  • Check the import path reads ./components/Button with no file extension on the end.
  • Still blank? Ask why my React component is not rendering.
Style the button with base Tailwind classes

Your button carries no classes of its own right now. Base classes are what make a component look like itself before any caller gets involved.

  • Switch back to Button.tsx in your editor.
  • Find this line:
  return <button>Click me</button>;
  • Replace that single line with these lines:
  return (
    <button className="px-4 py-2 bg-blue-600 text-white rounded">
      Click me
    </button>
  );

What do these classes do?

  • px-4 and py-2 set the horizontal and vertical padding.
  • bg-blue-600 sets the background colour, and text-white keeps the label readable on top of it.
  • rounded softens the corners. Every one of these is baked in, so every caller gets the same button.

Before you save, what do you expect the button to look like when the page reloads?

  • Save Button.tsx.
  • Switch back to your browser tab.

The page reloads on its own and your button turns blue, with rounded corners and comfortable padding around Click me.

Still seeing a plain grey button?

  • Check the attribute reads className and not class. React ignores class silently.
  • Stop your dev server with Ctrl+C and start it again, so Tailwind picks up your brand new components folder.
  • Still grey? Ask why my Tailwind classes are not applying.

One last check tells you exactly which classes reached the browser. That list matters more than it looks right now.

  • Right-click your blue button in the browser.
  • Click Inspect to open your browser's developer tools.
  • Read the class attribute on the highlighted <button> element in the Elements panel.
  • Confirm it reads exactly px-4 py-2 bg-blue-600 text-white rounded.

One horizontal padding class, one vertical padding class, one background class. Keep that in mind for the next step.

✔️ Awesome, I've got everything!

Great, double check you've saved both files before you move on.

ⓧ I'd like to double check the full code

Here is src/components/Button.tsx in full.

// Button renders one <button> with the base look every caller gets.
function Button() {
  return (
    <button className="px-4 py-2 bg-blue-600 text-white rounded">
      Click me
    </button>
  );
}

export default Button;

And here is src/App.tsx in full.

import Button from "./components/Button";

// App is the component Vite renders at your preview URL.
function App() {
  return (
    <div>
      <Button />
    </div>
  );
}

export default App;

Your button looks exactly the way its base classes say it should. Next up, you'll pass a different padding class in from the parent and watch the button ignore it completely.

Reproduce the Class Conflict

Your button renders exactly the padding and background you hard-coded into it. That works right up until someone wants a roomier version of the same button.

A component nobody can adjust is a component nobody reuses. So you'll open the door with a className prop, then watch Tailwind CSS quietly throw your override away.

In this step, get ready to:
  • Accept a className prop in your Button component.
  • Pass a conflicting padding class in from src/App.tsx.
  • Watch your override lose in the browser.
Wire a className prop through your button

Right now every style lives inside the component, so the parent has no say at all. The smallest way to change that is a prop the parent can fill with extra classes.

  • Open up your Button component by replacing everything in src/components/Button.tsx with this code:
type ButtonProps = {
  className?: string;
};

export default function Button({ className = "" }: ButtonProps) {
  // The styles this button always applies
  const base = "px-4 py-2 bg-blue-600 text-white rounded";

  // Glue the base styles and the caller's classes into one string
  return <button className={`${base} ${className}`}>Click me</button>;
}

What does this code do?

  • ButtonProps lets a caller hand extra classes in through a className prop.
  • base holds the styles the button always applies, including its padding.
  • The template literal joins the two strings with a space between them. Nothing in there checks whether the two sets of classes fight each other.
  • In src/App.tsx, find the line that renders your button:
<Button />
  • Prove the new prop works by passing a class in, so the line now looks like this:
<Button className="uppercase" />
  • Save src/components/Button.tsx and src/App.tsx.
  • Switch back to your browser.

The label now reads CLICK ME in capitals. Your class travelled from the parent all the way onto the <button> element.

Label still in lower case?

  • Check you saved both files. An unsaved src/App.tsx means the prop never gets passed.
  • Look at the terminal running your dev server for a red error message about Button.tsx.
  • My button ignores the className prop I passed in. What should I check?
Swap in a conflicting padding class

Text transforms are easy, because nothing in your base classes competes with them. Padding is a different story.

p-8 sets padding on all four sides, and your base string already sets px-4 py-2. Before you save, which padding do you think the button ends up with?

  • In src/App.tsx, find the line you just edited:
<Button className="uppercase" />
  • Swap uppercase for p-8, so the line now looks like this:
<Button className="p-8" />
  • Save src/App.tsx.
  • Switch back to your browser.

The label drops back to Click me, so your new class definitely reached the button. The padding has not moved a pixel.

That is the bug, reproduced on purpose.

Why the class you passed in loses

p-8, px-4 and py-2 are all plain classes with identical specificity. When two rules of equal specificity set the same property, the browser applies whichever one sits later in the stylesheet.

Tailwind writes that stylesheet in its own fixed order, and the all-sides padding utility lands before the horizontal and vertical ones. Your base classes win every time, no matter which order you concatenate the strings in.

String concatenation cannot help here, because both classes survive into the final attribute. Only something that removes the losing class before render can change the outcome.

Button got bigger?

  • Check your base string in src/components/Button.tsx still reads px-4 py-2 and not a single all-sides padding class.
  • Confirm src/App.tsx reads <Button className="p-8" /> with the old class gone.
  • Why does my padding override sometimes win and sometimes lose?

✔️ Awesome, I've got everything!

Great, double check both files are saved before you move on.

ⓧ I'd like to double check the full code

Your src/components/Button.tsx should look like this:

type ButtonProps = {
  className?: string;
};

export default function Button({ className = "" }: ButtonProps) {
  // The styles this button always applies
  const base = "px-4 py-2 bg-blue-600 text-white rounded";

  // Glue the base styles and the caller's classes into one string
  return <button className={`${base} ${className}`}>Click me</button>;
}

In src/App.tsx, the single line that renders your button should read:

<Button className="p-8" />
Confirm the conflict in DevTools

Your button now carries both padding classes in one attribute. Before you look, which of them do you think the browser is actually honouring?

  • Press Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows) to open DevTools in Chrome.
  • Click the Elements panel.
  • Click your <button> element in the DOM tree.
  • Read the Styles panel on the right.

The class attribute holds px-4, py-2 and p-8 together. In the Styles panel, the padding line inside the .p-8 rule is crossed out.

The class you passed is present, matched and then discarded. Fixing this means never letting both classes reach the browser in the first place.

You have a bug you can reproduce on demand. Next up, you'll install cn and make that override finally win.

Merge Classes with cn

Your button is ignoring you. You passed p-8 in from src/App.tsx and the browser still renders the original px-4 py-2 padding.

Both classes land in the same class attribute, so the winner is decided by the order Tailwind CSS wrote them into its stylesheet. The order you passed them in counts for nothing.

A merge function settles the conflict before the classes ever reach the DOM. In this step you'll build one, then watch your override finally land.

In this step, get ready to:
  • Install clsx and tailwind-merge into your project.
  • Create a cn helper at src/lib/cn.ts.
  • Swap your button's string concatenation for cn and see the override win.
Install the two packages that power cn

The cn helper is two jobs stacked. clsx turns whatever you pass it into one class string, and tailwind-merge knows which utility classes fight each other.

  • Your dev server is holding its terminal, so open a second terminal tab in your code editor.
  • Add both packages to your project by running this command:
npm install clsx tailwind-merge
  • Open package.json in your code editor.
  • Confirm clsx and tailwind-merge are both listed under dependencies.

Install did not finish?

  • Check the terminal tab is sitting in your project folder, the one holding package.json.
  • A registry or network hiccup usually clears if you run the same command again.

Why did my install of clsx and tailwind-merge fail?

Create your cn helper

Every component that needs merging will import the same function, so it gets its own file.

  • In your code editor's file explorer, create a new folder called lib inside src.
  • Create a new file inside src/lib called cn.ts.
  • Confirm the file exists by running this command in your second terminal tab:
ls src/lib

You should see cn.ts in the output.

  • Build the merge function by pasting this into cn.ts:
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

// clsx joins the arguments into one string, twMerge drops the losing side of any conflict
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

What does this code do?

  • clsx(inputs) flattens everything you hand it, including arrays and conditional values, into a single class string.
  • twMerge(...) scans that string for conflicting utility classes and keeps only the last one.
  • The ...inputs rest parameter lets you call cn with as many class strings as you like.
  • Save cn.ts.

Editor flagging the imports?

  • Check the file sits at src/lib/cn.ts, so lib is inside src rather than beside it.
  • Check the package names are spelled clsx and tailwind-merge exactly, since the install writes those names into node_modules.

Why can my editor not resolve the clsx and tailwind-merge imports?

Merge the classes in your button

Argument order is the whole game here. The last conflicting class wins, so the class coming in from the parent has to be the last thing you pass.

  • Switch back to src/components/Button.tsx in your code editor.
  • Reach your new helper by adding this import at the top of the file:
import { cn } from "../lib/cn";
  • Find the line where you join your base classes and the className prop with a template literal. It looks like this:
className={`${baseClasses} ${className}`}
  • Replace that template literal with a cn call, so the line now looks like this:
className={cn(baseClasses, className)}

Why the argument order matters

Your base classes go first because they are the defaults. The className prop goes last because a caller passing a class is asking to override those defaults.

Flip the two arguments and the merge resolves the other way, quietly discarding whatever the parent asked for.

  • Save Button.tsx.

Before you reload, make a call. Will the button hold onto its px-4 py-2 padding, or take the p-8 you passed in from src/App.tsx?

  • Return to your browser and reload the page.

Your button is visibly chunkier now, sitting at the p-8 padding the parent asked for. Its background and text colour are untouched, because only the padding classes were in conflict.

Padding still stuck at px-4 py-2?

  • Check className is the last argument in your cn call, since the last conflicting class is the one that survives.
  • Check you saved both cn.ts and Button.tsx.
  • Check your dev server terminal for a compile error that stopped the update reaching the browser.

Why is my cn merge not overriding the base padding?

✔️ Awesome, I've got everything!

Great. Double check both files are saved before you move on.

ⓧ I'd like to double check the full code

Your src/lib/cn.ts should read exactly like this:

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

// clsx joins the arguments into one string, twMerge drops the losing side of any conflict
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Scroll up and confirm both edits to src/components/Button.tsx are in place:

  • The import { cn } from "../lib/cn"; line at the top of the file.
  • The class string built with cn(baseClasses, className) instead of a template literal.

One override, one merge, one win. Next up, you'll add size and colour variants and confirm each one still resolves to exactly one padding and one background.

Add Size and Colour Variants

Your button now respects a padding class passed in from the parent. One button with one look is not a component library though.

Real components ship variants. A size prop picks a padding class, a color prop picks a background class, and both collide with the Tailwind CSS classes already baked into the button.

That is three sources of truth arguing over the same two properties. cn settles the argument by position, so this step is where you prove it holds for every combination.

In this step, get ready to:
  • Add a size prop that maps small, medium, and large to their own padding class.
  • Add a colour prop that maps primary and secondary to their own background class.
  • Render every combination on one page and confirm each button keeps exactly one padding class and one background class.
Add a size prop to your button

A size variant is a lookup. The prop name goes in, exactly one padding class comes out, and that one-to-one mapping is what keeps the merge predictable.

  • Give your button three sizes by replacing everything in src/components/Button.tsx with this:
import { cn } from "../lib/cn";

const sizeClasses = {
  sm: "p-2 text-sm",
  md: "p-4",
  lg: "p-6 text-lg",
};

type ButtonProps = {
  className?: string;
  size?: "sm" | "md" | "lg";
  children: React.ReactNode;
};

export function Button({ className, size = "md", children }: ButtonProps) {
  const baseClasses = "p-4 bg-blue-600 text-white rounded font-medium";
  // Base first, then the size class, then the caller's className
  return (
    <button className={cn(baseClasses, sizeClasses[size], className)}>
      {children}
    </button>
  );
}

What does this code do?

  • sizeClasses gives each size name a single padding class, so there is never more than one candidate per size.
  • baseClasses sets one padding class, p-4, so there is one clear thing for a size to beat.
  • size = "md" sets the default, so a button that passes no size keeps the padding it already had.
  • cn(baseClasses, sizeClasses[size], className) puts the size after the base classes and the caller's className last, so the caller can still override the size from outside.
  • Save src/components/Button.tsx.
  • Show off both ends of the scale by replacing everything in src/App.tsx with this:
import { Button } from "./components/Button";

export default function App() {
  // Two buttons so the size difference is obvious side by side
  return (
    <div className="flex items-center gap-4 p-10">
      <Button size="sm">Small</Button>
      <Button size="lg">Large</Button>
    </div>
  );
}
  • Save src/App.tsx.
  • Look at your browser, which reloads on save.

You should see two blue buttons side by side, with the Large one carrying visibly more padding. The p-8 override is gone from the page, so the size prop is the only thing setting padding now.

Both buttons the same size?

  • Check that each value in sizeClasses is a complete class string. Tailwind only generates classes it can find written out in full in your files.
  • Check that the keys are sm, md, and lg, matching the values you pass in the JSX.
  • Make sure the dev server is still running in the terminal from earlier.
  • My Tailwind size classes are not applying to my React button, what should I check?
Add a colour prop for the background

Colour works the same way as size, with one difference. Your base classes already set bg-blue-600, so every colour you add is arguing with a background that is already on the button.

  • Give your button two colours by replacing everything in src/components/Button.tsx with this:
import { cn } from "../lib/cn";

const sizeClasses = {
  sm: "p-2 text-sm",
  md: "p-4",
  lg: "p-6 text-lg",
};

const colorClasses = {
  primary: "bg-blue-600 text-white",
  secondary: "bg-slate-200 text-slate-900",
};

type ButtonProps = {
  className?: string;
  size?: "sm" | "md" | "lg";
  color?: "primary" | "secondary";
  children: React.ReactNode;
};

export function Button({ className, size = "md", color = "primary", children }: ButtonProps) {
  const baseClasses = "p-4 bg-blue-600 text-white rounded font-medium";
  // Base, then size, then colour, then className: later arguments win
  return (
    <button className={cn(baseClasses, sizeClasses[size], colorClasses[color], className)}>
      {children}
    </button>
  );
}

What changed in this version?

  • colorClasses gives each colour name one background class plus the text colour that goes with it.
  • The cn call now runs base classes first, then size, then colour, then whatever the caller passed.
  • Later arguments win, so the colour class beats the base bg-blue-600 every time.
  • Save src/components/Button.tsx.
  • In src/App.tsx, find this line:
      <Button size="lg">Large</Button>
  • Switch that button to the secondary colour so the line reads like this:
      <Button size="lg" color="secondary">Large</Button>
  • Save src/App.tsx.
  • Look at your browser.

The large button turns light grey with dark text. The small one stays blue.

Large button still blue?

  • Check that src/components/Button.tsx is saved. An unsaved file leaves the old component running in the browser.
  • Check the prop spelling is color and the value is secondary, both matching the keys in your map.
  • Why is my colour prop not overriding the background class on my button?
Render every variant on one page

Two buttons prove the props work. Six prove the merge holds for every combination, which is the only way to catch a pair that resolves the wrong way round.

  • Render all six combinations by replacing everything in src/App.tsx with this:
import { Button } from "./components/Button";

// The exact values the Button props accept
const sizes = ["sm", "md", "lg"] as const;
const colors = ["primary", "secondary"] as const;

export default function App() {
  return (
    <div className="flex flex-col gap-6 p-10">
      {colors.map(color => (
        // One row per colour, one button per size
        <div key={color} className="flex items-center gap-4">
          {sizes.map(size => (
            <Button key={size} size={size} color={color}>
              {color} {size}
            </Button>
          ))}
        </div>
      ))}
    </div>
  );
}

What does this code do?

  • sizes and colors list the same values your props accept, so the page can loop over them.
  • as const keeps those values as literal types, so TypeScript accepts them where the props expect specific strings.
  • The nested loop renders one row per colour and one button per size, which covers all six combinations.
  • Save src/App.tsx.

Before you look at the browser, how many buttons do you expect to see, and how many different background colours among them?

  • Look at your browser.

You should see six buttons in two rows. The top row is blue in three sizes and the bottom row is grey in the same three sizes.

Fewer than six buttons on the page?

  • Open your browser console. A blank page or a missing row usually comes with a React error naming the file and line.
  • Check that both loops return their JSX inside parentheses, and that sizes and colors both end with as const.
  • My React page is not rendering every button from my nested map, what is wrong?

Each of those buttons was handed two padding classes and two background classes. Before you check the markup, how many of each do you think survived into the page?

  • Right-click the large grey button and choose **Inspect**.
  • Read the class attribute on the highlighted <button> element in the **Elements** panel.

You should see exactly one padding class, p-6, and exactly one background class, bg-slate-200. The base p-4 and bg-blue-600 never reached the page.

Why the base classes disappeared

cn groups your classes by the CSS property they set. When two classes land in the same group, the one further right wins and the other is dropped before it reaches the DOM.

That is why argument order is the whole design of this component. Base, size, colour, caller, in that order, from weakest claim to strongest.

✔️ Awesome, I've got everything!

Great. Double check both files are saved and your browser is showing all six buttons.

ⓧ I'd like to double check the full code

Here is src/components/Button.tsx in full:

import { cn } from "../lib/cn";

const sizeClasses = {
  sm: "p-2 text-sm",
  md: "p-4",
  lg: "p-6 text-lg",
};

const colorClasses = {
  primary: "bg-blue-600 text-white",
  secondary: "bg-slate-200 text-slate-900",
};

type ButtonProps = {
  className?: string;
  size?: "sm" | "md" | "lg";
  color?: "primary" | "secondary";
  children: React.ReactNode;
};

export function Button({ className, size = "md", color = "primary", children }: ButtonProps) {
  const baseClasses = "p-4 bg-blue-600 text-white rounded font-medium";
  // Base, then size, then colour, then className: later arguments win
  return (
    <button className={cn(baseClasses, sizeClasses[size], colorClasses[color], className)}>
      {children}
    </button>
  );
}

And src/App.tsx:

import { Button } from "./components/Button";

// The exact values the Button props accept
const sizes = ["sm", "md", "lg"] as const;
const colors = ["primary", "secondary"] as const;

export default function App() {
  return (
    <div className="flex flex-col gap-6 p-10">
      {colors.map(color => (
        // One row per colour, one button per size
        <div key={color} className="flex items-center gap-4">
          {sizes.map(size => (
            <Button key={size} size={size} color={color}>
              {color} {size}
            </Button>
          ))}
        </div>
      ))}
    </div>
  );
}

Your component library now resolves every variant down to one padding class and one background class. Next up, you'll measure how long that merge actually takes.

Benchmark the Class Merge Performance

Your button page now renders every size and colour combination correctly. Each button resolves to exactly one padding class and one background.

That correctness has a price. Every render pushes your class list through a merge, and so far you have no number for what that costs.

In this step you'll measure it. You'll run the same merge 100,000 times through cn and through tailwind-merge on its own, then read both timings in your terminal.

In this step, get ready to:
  • Set up a TypeScript benchmark file you can run from your terminal.
  • Time a single merge and find out why one call is not a measurement.
  • Loop the merge 100,000 times and compare cn against tailwind-merge.
Set up a benchmark file you can run from the terminal

Your benchmark runs outside the browser, straight on Node.js, so it can loop as fast as your machine allows.

The file is TypeScript, so you need a runner that executes it without a separate build step. That runner is tsx.

Your dev server is still holding the terminal you've been working in.

  • In your code editor's terminal panel, click the + icon to open a second terminal tab.
  • Install the tsx runner as a dev dependency by running this command:
npm install -D tsx

npm prints a summary of the packages it added once the install finishes.

Install failed or hung?

Check you ran the command inside the folder that holds your package.json, because npm installs into whichever folder your terminal is sitting in.

If the install stalls, cancel it with Ctrl+C and run the same command again.

Why is my npm install failing?

  • Create the benchmark folder and its file by running these commands:
mkdir benchmark
touch benchmark/mergeBenchmark.ts
  • Confirm the file exists by running:
ls benchmark

You should see mergeBenchmark.ts in the output.

Time a single merge and read the clock

Timing anything in JavaScript works the same way. You read a clock before the work, read it again after, then subtract.

  • Open benchmark/mergeBenchmark.ts in your code editor.
  • Add this code to the empty file:
import { cn } from "../src/lib/cn";

// The two class lists your Button merges: its own base, and the caller's override
const base = "px-4 py-2 bg-blue-500 text-white";
const incoming = "px-8 bg-red-500";

// Read the clock, do one merge, read the clock again
const start = performance.now();
cn(base, incoming);
const end = performance.now();

console.log(`One merge: ${(end - start).toFixed(4)} ms`);

What does this code do?

  • base and incoming conflict on padding and on background, which is exactly the case your Button has to resolve.
  • performance.now() returns a timestamp in milliseconds, so subtracting one reading from the other gives you the time the merge took.
  • .toFixed(4) keeps four decimal places, because a single merge finishes in far less than a millisecond.
  • Save benchmark/mergeBenchmark.ts.

Before you run this, what number do you expect to see for one merge?

  • Run the benchmark by running this command:
npx tsx ./benchmark/mergeBenchmark.ts

You'll see a line starting with One merge: followed by a very small number.

  • Run that exact same command a second time.

The number moves. Sometimes it doubles, sometimes it halves, and you never touched the code.

Why one merge is not a measurement

A single call is over in microseconds. At that scale the clock is measuring the noise around the work as much as the work itself.

The very first call also pays for warm-up that every later call skips. Run the same merge a hundred thousand times and both problems average out into a number you can trust.

Seeing an error instead of a timing?

Check the import path reads ../src/lib/cn, because your benchmark folder sits one level above src.

Check you saved the file before running the command, since tsx reads what is on disk.

Why is my tsx script failing to import cn?

Loop the merge and compare cn with tailwind-merge

Your cn function does two jobs. It runs a clsx pass to flatten its arguments, then hands the result to tailwind-merge to resolve the conflicting Tailwind CSS classes.

Calling tailwind-merge directly skips that first pass. Looping both a hundred thousand times is what makes the difference big enough to read.

  • In benchmark/mergeBenchmark.ts, find the timing block you just wrote:
const start = performance.now();
cn(base, incoming);
const end = performance.now();

console.log(`One merge: ${(end - start).toFixed(4)} ms`);
  • Replace that block with a loop over the same pair, so it now looks like this:
const ITERATIONS = 100000;

// Time 100,000 merges through cn, which wraps clsx around tailwind-merge
const cnStart = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  cn(base, incoming);
}
const cnEnd = performance.now();

console.log(`cn: ${(cnEnd - cnStart).toFixed(2)} ms`);
  • Save benchmark/mergeBenchmark.ts.
  • Run the benchmark again by running this command:
npx tsx ./benchmark/mergeBenchmark.ts

You'll see a line starting with cn: and a number far larger than before, because you just did a hundred thousand times the work.

  • Run that command once more.

This time the number barely moves between runs. That stability is what turns a reading into a measurement.

  • At the top of benchmark/mergeBenchmark.ts, add this import below your existing one:
import { twMerge } from "tailwind-merge";
  • At the bottom of the file, below your cn log line, add a second loop that does the same work without the clsx pass:
// Time the same 100,000 merges through tailwind-merge on its own
const twStart = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  twMerge(base, incoming);
}
const twEnd = performance.now();

console.log(`tailwind-merge: ${(twEnd - twStart).toFixed(2)} ms`);

What does this second loop do?

  • twMerge is the function tailwind-merge exports, and it is the same function your cn calls underneath.
  • Both loops merge the identical pair of class lists the identical number of times, so the only thing left between them is the clsx layer.
  • Reusing ITERATIONS keeps the two loops honest, because a fair comparison needs the same count on both sides.
  • Save benchmark/mergeBenchmark.ts.

Before you run this, which of the two timings do you expect to be lower, and why?

  • Run the finished benchmark by running this command:
npx tsx ./benchmark/mergeBenchmark.ts

You now get two lines, one labelled cn: and one labelled tailwind-merge:.

The tailwind-merge total comes in lower. Every cn call runs the clsx pass before it reaches that same merge, and a hundred thousand of those passes add up.

What the gap actually costs you

Divide either total by 100,000 to get the cost of a single merge. Both land at a fraction of a microsecond.

A component renders a handful of times per interaction, not a hundred thousand. The clsx layer buys you conditional and array arguments for a cost no user will ever feel.

✔️ Awesome, I've got everything!

Great, double check you've saved benchmark/mergeBenchmark.ts.

ⓧ I'd like to double check the full code

import { cn } from "../src/lib/cn";
import { twMerge } from "tailwind-merge";

// The two class lists your Button merges: its own base, and the caller's override
const base = "px-4 py-2 bg-blue-500 text-white";
const incoming = "px-8 bg-red-500";

const ITERATIONS = 100000;

// Time 100,000 merges through cn, which wraps clsx around tailwind-merge
const cnStart = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  cn(base, incoming);
}
const cnEnd = performance.now();

// Time the same 100,000 merges through tailwind-merge on its own
const twStart = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
  twMerge(base, incoming);
}
const twEnd = performance.now();

console.log(`cn: ${(cnEnd - cnStart).toFixed(2)} ms`);
console.log(`tailwind-merge: ${(twEnd - twStart).toFixed(2)} ms`);

Only getting one timing back?

Check the second loop sits below the ITERATIONS line, because it reuses that value.

Check the import reads tailwind-merge, which is the package your cn helper already depends on.

Why is only one of my two benchmark loops printing?

Both numbers exist now, and they live in a terminal you're about to close. Next up, you'll put them on the page beside the buttons they explain.

Display Benchmark Results on the Page

Your terminal now holds two timings for the same class list. Anyone looking at your page has no idea they exist.

A number lands harder when it sits next to the thing it explains. In this step you'll run the same benchmark inside your React app and print both timings above your button variants.

In this step, get ready to:
  • Run the same cn and tailwind-merge benchmark inside your app.
  • Display both timings above your button variants.
  • Reload the page and check the numbers hold up.
Run the same benchmark inside your app

The browser has its own timer, performance.now(), the same one your terminal script uses.

  • Add the benchmark helper to src/App.tsx by pasting this at the very top of the file, above your component:
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { cn } from "./lib/cn";

// The same conflicting class list your terminal benchmark merges
const BASE_CLASSES = "px-4 py-2 bg-blue-500";
const OVERRIDE_CLASSES = "px-8 bg-emerald-500";
const ITERATIONS = 100000;

// Merges the two class lists ITERATIONS times and returns how long that took
function timeMerge(merge: (a: string, b: string) => string) {
  const start = performance.now();
  for (let i = 0; i < ITERATIONS; i++) {
    merge(BASE_CLASSES, OVERRIDE_CLASSES);
  }
  return performance.now() - start;
}

What does this code do?

timeMerge takes any merge function and hands back a number of milliseconds. That lets one helper time both libraries.

The two class lists conflict on padding and background on purpose. Merging a real conflict is the work you actually care about timing.

  • Wire the benchmark into your page by pasting this inside your App component, just above its return:
// Holds both timings so the page can render them
const [timings, setTimings] = useState<{ cn: number; twMerge: number } | null>(null);

useEffect(() => {
  // Run each benchmark once, right after the page first renders
  const cnTime = timeMerge((a, b) => cn(a, b));
  const twMergeTime = timeMerge((a, b) => twMerge(a, b));
  setTimings({ cn: cnTime, twMerge: twMergeTime });
  console.log("cn", cnTime, "tailwind-merge", twMergeTime);
}, []);

What's happening here?

The empty array at the end of useEffect means the benchmark runs once per page load. Without it, every state update would kick off another 200,000 merges.

The console.log is your proof the numbers exist before you try to render them.

  • Save src/App.tsx.
  • Switch back to your browser.
  • Press **Cmd+Option+J** (macOS) or **Ctrl+Shift+J** (Windows) to open the console.
  • Look for a logged line reading cn followed by a number, then tailwind-merge followed by a number.

Console empty, or seeing a duplicate import?

If your editor flags a duplicate react import, delete the new import line and add useEffect and useState to the import that was already at the top of the file.

If the console stays empty, check your effect ends with }, []); and that you pasted it inside the component rather than below it.

Why isn't my useEffect logging anything?

Show both timings next to your buttons

The numbers exist, but only in a panel most people never open. Getting them onto the page puts the cost and the result in the same glance.

  • Print both timings on the page by pasting this inside the markup your App component returns, just above your button variants:
{/* Both timings, printed above the button variants */}
<div className="mb-6 font-mono text-sm">
  <p>cn: {timings ? timings.cn.toFixed(1) : "..."} ms</p>
  <p>tailwind-merge: {timings ? timings.twMerge.toFixed(1) : "..."} ms</p>
</div>

Why the three dots?

The first render happens before the effect runs, so timings is still null at that moment.

Showing ... until the numbers land keeps the layout stable instead of flashing empty text.

  • Save src/App.tsx.
  • Switch back to your browser.
  • Confirm two lines now sit above your buttons, one starting cn: and one starting tailwind-merge:, each ending in a millisecond figure.

Don't see the timings above your buttons?

Check the new div sits inside the element your component returns. Pasting it after the closing tag gives you two root elements and a build error.

If both numbers stay stuck on ..., your effect never finished. Open the console and look for a red error.

Why is my JSX throwing a parse error after adding this div?

✔️ Awesome, I've got everything!

Great, double check you've saved your file.

ⓧ I'd like to double check the full code

Scroll up and confirm all three pieces are in src/App.tsx, in this order:

  • The imports plus the timeMerge helper, at the very top of the file.
  • The timings state and the useEffect block, inside your component above the return.
  • The div printing both timings, inside the returned markup above your button variants.
Check the numbers hold up across reloads

One measurement is a data point, not a result. Before you reload, do you expect both numbers to come back identical every time?

  • Reload your browser page three times, reading both numbers each time.

Both figures shift by a few milliseconds on every reload. The gap between them stays in roughly the same band.

Why the numbers move

The browser optimises hot code while it runs, and other tabs compete for the same processor. That noise is why you looped 100,000 times instead of timing a single merge.

Read the pattern across reloads, not one run. A difference that survives several reloads is a real difference.

Secret mission

Stress Test cn With Arbitrary Values

Your buttons now merge tidy utility classes without breaking a sweat. Arbitrary values like p-[13px] and variant prefixes like hover:bg-emerald-500 are a different story. Push both through cn and read the class list your button actually renders with.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your project running, pause it to come back later, or delete it entirely. Everything you built runs locally and both merge packages are open source, so there are no ongoing costs.

Resources you used:

  • The dev server running in your terminal, plus the browser tab open at your local preview URL.
  • Your project folder on the Desktop, holding src/components/Button.tsx, src/lib/cn.ts, src/App.tsx, and benchmark/mergeBenchmark.ts.
  • The npm packages downloaded into node_modules inside that folder, including Tailwind CSS and the two merge libraries.

Keep everything running

No action needed. Choose this if you're still actively building on the component library.

  • Leave the dev server running so the button variants and the two merge timings stay on screen.
  • Keep the browser tab open at your local preview URL.

What stays on your machine either way

Node.js and npm stay installed whichever option you pick here. They are general purpose tools you'll want for the next project, so nothing below removes them.

Pause - I'll come back to this later

Shut down the running process to free up memory. Your files stay exactly where they are.

  • Press Ctrl+C in the terminal running the dev server to stop it.
  • Close the browser tab pointing at your local preview URL.
  • Leave your project folder on the Desktop so you can restart the dev server whenever you want.

Delete - I don't want to use this again

Remove all project files and start fresh. This also removes the packages you installed, because they live inside the project folder.

  • Press Ctrl+C in the terminal running the dev server to stop it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • On macOS or Linux, delete the project folder and everything inside it by running this command:
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • On Windows, delete it by running this command instead:
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
  • Confirm the folder is gone by running:
ls

Your project folder no longer appears in the list of items on your Desktop.

Still seeing the folder in the list?

Check that the name you typed matches the folder exactly, including capitalisation.

Your code editor may also be holding the folder open. Close it, then run the delete command again.

Why won't my project folder delete from the Desktop?

Nice Work!

Nice Work

Nice work. You turned a silent Tailwind class conflict into a button that respects every override you pass it, and you have the timings to prove how fast the merge runs.

You've learned how to:

  • Reproduce a real Tailwind conflict in the browser by passing a padding class into a button that already sets its own padding.
  • Build a class merging utility in src/lib/cn.ts so every button resolves to exactly one padding class and one background class.
  • Measure the merge yourself with a benchmark that loops the same class list and prints both timings, then surface those numbers on the page.
  • Secret Mission: Take on an optional challenge that pushes your component library further.

Ready to quiz yourself?