Component Patterns

Tailwind CSS Intermediate/Advanced

Chapter 6 · Component Patterns and Libraries

Utility classes are fine in a page. Once you wrap them in a component (a button with a variant and a size, which callers can also give an extra className) three problems appear: which of two conflicting classes wins, how to describe the variants without a pile of if statements, and where the behaviour (open, closed, selected, focus) comes from. This chapter takes each problem with the tool people actually use, runs it, and then compares the ready-made libraries. CSS Frameworks 7 covers shadcn/ui as an idea; here I install it and read what it wrote.

Every example here was run
Tailwind 4.3.3, Node 24.18.0, Windows 11, headless Chrome 154 with real mouse clicks. Packages: react and react-dom 19.3.0, vite 8.3.4, clsx 2.1.1, tailwind-merge 3.7.0, class-variance-authority 0.7.1, @radix-ui/react-tabs 1.2.0 and react-dialog 1.3.0, @headlessui/react 2.2.10, react-aria-components 1.22.1, daisyui 5.7.47, flowbite 4.0.2, shadcn 4.21.4 (CLI), TypeScript 7.0.2. Not run: Vue or Svelte versions of these libraries, keyboard-only testing and screen readers, React Aria's own Tailwind plugin, Flowbite's framework packages, other shadcn styles, and any browser except Chrome 154.

Problem 1: Which Class Wins?

A component sets bg-blue-500 and a caller passes bg-red-500. Both end up on the element. You might expect the later one in the attribute to win. It does not, and the reason is the CSS file, not the HTML. I built both classes and rendered four elements in Chrome:

<div id="a" class="bg-red-500 bg-blue-500">a</div> <div id="b" class="bg-blue-500 bg-red-500">b</div> <div id="c" class="px-8 px-2">c</div> <div id="d" class="px-2 px-8">d</div>
built CSS order: .bg-blue-500 {...} .bg-red-500 {...} .px-2 {...} .px-8 {...} a class="bg-red-500 bg-blue-500" background oklch(0.637 0.237 25.331) red b class="bg-blue-500 bg-red-500" background oklch(0.637 0.237 25.331) red same colour, attribute order reversed c class="px-8 px-2" padding-left 32px px-8 d class="px-2 px-8" padding-left 32px px-8

Tailwind writes the rules in its own order (red after blue, px-8 after px-2) and with equal specificity the later rule in the file wins. So the attribute order is irrelevant. A caller who adds bg-blue-500 to a component that already has bg-red-500 may get no change at all, depending on a sort order they cannot see. The fix is to remove the loser from the string before it is rendered. That is what tailwind-merge does.

clsx and tailwind-merge

Two small libraries, two jobs. clsx joins class values and drops the false ones. twMerge from tailwind-merge knows which Tailwind classes conflict and keeps the last of each conflicting group. The usual helper is named cn and combines them:

import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export const cn = (...inputs) => twMerge(clsx(inputs));
clsx("px-2", false, "px-8") "px-2 px-8" clsx only joins; nothing is removed twMerge("px-2 px-8") "px-8" twMerge("p-4 px-2") "p-4 px-2" px-2 is more specific, both kept twMerge("px-2 p-4") "p-4" p-4 covers px-2, so px-2 is dropped twMerge("bg-red-500 bg-blue-500") "bg-blue-500" twMerge("rounded-md rounded-lg") "rounded-lg" twMerge("w-4 size-8") "size-8" twMerge("hover:bg-red-500 bg-blue-500") "hover:bg-red-500 bg-blue-500" different variants do not conflict

The last line is a trap, not a bug. A component with bg-blue-600 hover:bg-blue-700 and a caller who passes bg-green-600 gets bg-green-600 but keeps the component's hover:bg-blue-700, so the button turns blue when hovered. I confirmed this with the helper above (the result is in the cva section below). The caller has to override the hover class as well.

Custom theme names confuse the merge

twMerge does not read your @theme. It recognises the built-in names. I added --text-huge: 4rem and --color-brand to a theme (chapter 2) and merged:

twMerge("text-huge text-lg") // two font sizes twMerge("text-brand text-red-500") // two colours, one custom const tm = extendTailwindMerge({ extend: { classGroups: { 'font-size': [{ text: ['huge'] }] } }, }); tm("text-lg text-huge") tm("text-huge text-lg")
twMerge("text-huge text-lg") "text-huge text-lg" both kept: wrong, two sizes twMerge("text-brand text-red-500") "text-red-500" right, an unknown name after text- is guessed to be a colour tm("text-lg text-huge") "text-huge" tm("text-huge text-lg") "text-lg"

Colours happen to work because an unknown text-name is treated as a colour. A custom font size does not, so the library has to be told with extendTailwindMerge. Any custom token in a size, shadow or radius group needs the same registration, or conflicts silently survive. (I only tested the two cases above; the library's own documentation describes the limitation.)

Problem 2: Variants Without a Mess

cva (class-variance-authority) turns a table of variants into a function that returns a class string. I wrote a button and called it:

import { cva } from 'class-variance-authority'; const button = cva('inline-flex items-center rounded-md font-medium', { variants: { intent: { primary: 'bg-blue-600 text-white hover:bg-blue-700', danger: 'bg-red-600 text-white' }, size: { sm: 'px-2 py-1 text-sm', md: 'px-4 py-2 text-base' }, }, compoundVariants: [{ intent: 'danger', size: 'sm', class: 'uppercase' }], defaultVariants: { intent: 'primary', size: 'md' }, });
button() inline-flex items-center rounded-md font-medium bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 text-base button({ intent: "danger", size: "sm" }) inline-flex items-center rounded-md font-medium bg-red-600 text-white px-2 py-1 text-sm uppercase button({ size: "lg" }) // a value that does not exist inline-flex items-center rounded-md font-medium bg-blue-600 text-white hover:bg-blue-700 button({ class: "px-8" }) // the caller's extra class ... px-4 py-2 text-base px-8 both px-4 and px-8 kept: cva does not merge twMerge(button({ class: "px-8" })) ... py-2 text-base px-8 px-4 removed cn(button(), "bg-green-600") inline-flex items-center rounded-md font-medium text-white hover:bg-blue-700 px-4 py-2 text-base bg-green-600

Four observations. The base string, the chosen variant classes and the compound variant (uppercase only for a small danger button) are joined in a fixed order, with defaults filling anything you did not pass. An unknown value (size: "lg") silently contributes nothing; the button loses its size classes and nothing warns you at run time. cva only builds the string; resolving conflicts is a separate step, which is why components wrap it: cn(button({ intent, size }), className). And the last line shows the hover trap from above: bg-green-600 replaced bg-blue-600 but hover:bg-blue-700 is still there.

The warning for the unknown value comes from the types. I put this file in a TypeScript 7.0.2 project with strict mode and ran tsc --noEmit:

import { cva, type VariantProps } from "class-variance-authority"; const button = cva("btn", { variants: { size: { sm: "px-2", md: "px-4" } }, defaultVariants: { size: "md" } }); type Props = VariantProps<typeof button>; export const ok: Props = { size: "sm" }; export const bad: Props = { size: "lg" };
src/t.ts(5,29): error TS2322: Type '"lg"' is not assignable to type '"md" | "sm" | null | undefined'.

VariantProps gives the component's props their type for free, so a wrong variant is a compile error instead of a missing style. Without TypeScript you get the silent version.

The scanner still has to see whole class names

The class strings inside cva are ordinary strings in your source, so the scanner from chapter 1 finds them. The rule from that chapter still holds: write complete names ('bg-red-600'), never assemble them ('bg-' + color + '-600'). The variant maps in this chapter were built and their classes were present in the output.

Problem 3: Where Does the Behaviour Come From?

A tab list or a dialog needs state, keyboard handling, focus management and ARIA attributes. Headless libraries supply all of that and no styling. They expose their state as attributes on the elements, and Tailwind's attribute variants (chapter 4) style them. I built one page with three libraries and clicked them in Chrome, reading the attributes before and after:

// Radix: data-state="active" | "inactive" <RTabs.Trigger value="a" className="data-[state=active]:bg-blue-500">One</RTabs.Trigger> // Headless UI 2: boolean data attributes <Tab className="data-selected:bg-blue-500 data-hover:underline data-focus:outline-2">One</Tab> // React Aria Components <Tab className="data-selected:bg-blue-500 data-hovered:underline">One</Tab>
Radix tab 2, after clicking it: aria-selected=true data-state=active Radix tab 1 (now inactive): aria-selected=false data-state=inactive Headless UI tab 2, after clicking: aria-selected=true data-headlessui-state="selected hover" data-hover="" data-selected="" Headless UI tab 1 (not selected): aria-selected=false data-headlessui-state="" (no data-selected at all) React Aria tab 2, after clicking: aria-selected=true data-hovered=true data-selected=true data-focused=true React Aria tab 1 (not selected): aria-selected=false (no data-selected) Radix dialog trigger: closed: aria-expanded=false data-state=closed open: aria-expanded=true data-state=open Headless UI disclosure button: closed: aria-expanded=false open: aria-expanded=true data-open=""

The three conventions differ and your classes must match the library:

LibraryState attributes seenTailwind class that matched
Radix UIdata-state with a word (active, open, closed)data-[state=active]:… (arbitrary form: the value matters)
Headless UI 2.2boolean attributes: data-selected, data-open, data-hover, data-focusdata-selected:… (Tailwind's boolean data-* shorthand, no brackets)
React Aria Components 1.22data-selected, data-hovered, data-focused (and data-pressed, used in my build, not clicked)data-selected:…, data-hovered:…
A class that looks right and generates nothing
My first React Aria attempt used selected:bg-blue-500 and hovered:underline. Tailwind has no such variants, so the build produced no rule for them and no error. (React Aria publishes a separate Tailwind plugin that adds names like that; I did not install or test it.) Without it, use data-selected:. After wiring any library, search the built CSS for one of its classes.

What you get for free, from the Radix dialog test: clicking the trigger rendered the dialog into <body> (a portal) with role="dialog", focus moved into the dialog (to its Close button), the page behind got data-aria-hidden, and after closing, the dialog was gone from the DOM and focus returned to the trigger. That is the part you do not want to write yourself. Keyboard operation and screen-reader output I did not test.

Choosing: all three worked with Tailwind in a Vite build (the page's CSS was 9.19 kB with all three libraries' examples). Radix is widely copied; Headless UI is made by the Tailwind team and also has a Vue edition (not run here); React Aria has a separate hooks API. I did not benchmark or audit any of them, so pick on the components you need and read each one's accessibility notes.

shadcn/ui: Install Once, Own the Code

CSS Frameworks 7 explains the idea: the components are not a dependency, the CLI copies their source into your project. I made a Vite + React + TypeScript project with Tailwind, the @/ path alias, and ran npx shadcn@latest init -d -y (CLI 4.21.4). It created components.json, edited src/index.css, and wrote two files:

// src/lib/utils.ts export { cn } from "cn" // src/components/ui/button.tsx (the top and bottom, middle shortened) import { Button as ButtonPrimitive } from "@base-ui/react/button" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" const buttonVariants = cva("group/button inline-flex shrink-0 items-center ...", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "...", ghost: "...", destructive: "...", link: "..." }, size: { default: "h-8 gap-1.5 px-2.5 ...", xs: "...", sm: "...", lg: "...", icon: "size-8", ... }, }, defaultVariants: { variant: "default", size: "default" }, }) function Button({ className, variant = "default", size = "default", ...props }) { return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> }
components.json: "style": "base-nova", "cssVariables": true, aliases "@/components", "@/lib/utils" dependencies added: @base-ui/react, class-variance-authority, cn, lucide-react, tw-animate-css, @fontsource-variable/geist NOT added: clsx, tailwind-merge

Everything in this chapter is in those two files: cva for variants, cn to merge, a headless primitive (here Base UI, not Radix: that is what this CLI version's default style uses) for behaviour, and semantic colour tokens (bg-primary, text-primary-foreground) that chapter 2's @theme inline pattern defines in index.css. The surprise was utils.ts: I expected the familiar twMerge(clsx(inputs)), but it is one line re-exporting cn from an npm package named cn. I checked it: version 0.4.0, repository shadcn-ui/cn, maintainer shadcn, described as a drop-in replacement for clsx and tailwind-merge. I ran 15 of my merge inputs through both libraries and they gave identical output; the package's README claims the same for 356,000 differential tests and large speed-ups, which I did not measure. Its custom-theme API is createCn, the equivalent of extendTailwindMerge (I ran the same text-huge example through it and it resolved the conflict).

Then I built a page with four buttons and measured what the component does with a caller's className:

<Button id="d">Default</Button> <Button id="w" className="px-8">px-8</Button> <Button id="g" className="bg-green-600 hover:bg-green-700">Green</Button> <Button id="o" variant="outline">Outline</Button>
d bg-primary hover:bg-primary/80 px-2.5 padding-left 10px background oklch(0.205 0 0) w bg-primary hover:bg-primary/80 px-8 padding-left 32px px-2.5 was removed from the class list g bg-green-600 hover:bg-green-700 px-2.5 bg-primary and hover:bg-primary/80 removed; hovered: oklch(0.527 0.154 150.069) o bg-background hover:bg-muted px-2.5 background oklch(1 0 0) build: 23.56 kB CSS, 257 kB JavaScript (83 kB gzip)

The caller's classes replaced the matching ones, including the hover: pair, because here the caller overrode both. The file is yours: you can edit it, and nothing updates it behind your back. That is the trade: no dependency to upgrade, but every upstream fix is a merge you perform yourself.

daisyUI and Flowbite: Classes Instead of Components

The other approach ships class names (btn btn-primary) and the CSS behind them, as a Tailwind plugin. I installed both and measured what they cost on small pages.

daisyUI 5.7.47Flowbite 4.0.2
Setup in CSS@plugin "daisyui";@import "flowbite/src/themes/default"; @plugin "flowbite/plugin"; @source "./node_modules/flowbite";
Behaviournone in what I ran: CSS onlya JavaScript file (flowbite.min.js) reads data-* attributes
Themingthemes by data-theme: @plugin "daisyui" { themes: light --default, dark --prefersdark, cupcake; } produced rules for [data-theme=light], dark and cupcakea theme file (default, minimal, …) defines tokens such as --color-brand (var(--color-blue-700) in the default theme), used as bg-brand
CSS with an empty page10,998 B (plain Tailwind: 4,165 B)not measured
CSS with one small page22,744 B (default themes), 23,843 B with three themes; plain Tailwind 6,151 B for that page26,725 B without the @source line, 57,882 B with it; plain Tailwind 10,564 B
Overridingutilities win: btn btn-primary px-12 gave padding 48px (default 16px) and bg-red-500 replaced the primary backgroundordinary Tailwind classes in your markup

The sizes are minified output for tiny pages, so treat them as a rough scale, not a benchmark: a styling library costs kilobytes before you write a line. The override result matters more. daisyUI's rules sit in their own cascade layer (daisyui, with sub-layers), beneath Tailwind's utilities layer (chapter 1), so a utility beats a component class. That is why btn px-12 works without a merge helper.

Flowbite's @source line scans the entire library for class names, which is why that build grew to 57,882 B. The dropdown test showed the second difference: with the script loaded, clicking a button with data-dropdown-toggle="dd" changed the panel from hidden (display: none) to block. I did not test Flowbite without the script, its React or Vue packages, daisyUI's interactive components, or either library's accessibility.

When a Plain Component Is Enough

For a small product, a lookup table and a join can do the job of cva:

const sizes = { sm: 'px-2 py-1 text-sm', md: 'px-4 py-2 text-base' }; const intents = { primary: 'bg-blue-600 text-white', danger: 'bg-red-600 text-white' }; const Button = ({ intent = 'primary', size = 'md' } = {}) => ['inline-flex rounded-md', intents[intent], sizes[size]].join(' ');
Button() inline-flex rounded-md bg-blue-600 text-white px-4 py-2 text-base Button({ size: 'lg' }) inline-flex rounded-md bg-blue-600 text-white (unknown value: size classes silently missing) with "as const" in TypeScript: plain["lg"] -> error TS7053: ... Property 'lg' does not exist on type ...

The output matches the cva version, including the silent unknown value, and TypeScript catches the typo in both. What cva adds is compound variants, defaults in one place, and VariantProps deriving the prop types from the table. What the plain version lacks is exactly what a caller's className needs: conflict handling. A plain component can avoid the need by not accepting arbitrary classes at all (offer props instead). Once callers pass className, add cn.

SituationReasonable choiceWhy
One or two components, a few variantsplain lookup table, no extra packagesnothing to conflict; the table is the documentation
A design system, many variants, callers add classescva + cntyped variants, one merge point
Interactive widgets (tabs, dialogs, menus)a headless libraryfocus and ARIA are the hard part; I saw the dialog's focus handling work, keyboard and screen readers untested
Want finished-looking components you can editshadcn/uiyou own the files; upstream fixes are your merges
Want finished looks with the least markupdaisyUIclass names, themes by attribute; utilities still override
Want ready widgets plus a script, any back endFlowbitedata-* attributes and one script; check the @source cost
Mistakes while preparing this chapter
1. I used selected: and hovered: as if they were Tailwind variants. The build raised no error and wrote no rule. How to avoid it: after adding a library's classes, search the built CSS for one of them before testing in the browser; a silent absence is the usual failure.

2. I expected shadcn's utils.ts to contain twMerge(clsx(…)). That is what older tutorials (and my memory) show; the CLI I ran generated a re-export from a different package. How to avoid it: read the files the tool wrote instead of describing them from memory, and look up an unfamiliar dependency (owner, repository, version) before relying on it.

3. My plain-component test crashed when called with no arguments. A destructured parameter needs a default (= {}). How to avoid it: run the no-argument call first; it is the most common call.

4. I wrote a tsconfig.json with baseUrl, and TypeScript 7 rejected it (TS5102: Option 'baseUrl' has been removed). How to avoid it: when a tool's major version changes, read its first error message; this one named the replacement.

Hands-On Exercises

Exercise 1

Render four elements with the conflicting classes bg-red-500 bg-blue-500 and px-8 px-2 in both attribute orders and report which wins. Then run ten class pairs through twMerge, include a custom font size from @theme, show that it is not merged, and fix it with extendTailwindMerge.

📄 View solution
Exercise 2

Write a cva button with two variant groups, a compound variant and defaults. Call it five ways (defaults, both set, an unknown value, an extra class, wrapped in cn), show the hover: leftover when a caller overrides the background, and show the TypeScript error for the unknown value.

📄 View solution
Exercise 3

In a Vite + React build, style a tab list with one headless library using its state attributes. Click the second tab in headless Chrome and report the attributes and background of both tabs before and after. Then use a variant name that does not exist and show that the CSS has no rule for it.

📄 View solution

Chapter 6 Quick Reference

  • Conflicting utilities are decided by the order of rules in the built CSS, not by the order in the class attribute; twMerge removes the loser from the string beforehand
  • cn = (...a) => twMerge(clsx(a)). clsx joins, twMerge resolves conflicts; different variants (hover:bg-* and bg-*) do not conflict
  • Custom @theme names in size, shadow or radius groups are not known to twMerge: use extendTailwindMerge (the colour case happened to work)
  • cva(base, { variants, compoundVariants, defaultVariants }) builds a string only; unknown values add nothing silently; VariantProps makes them a type error; merge with cn(button(...), className)
  • Headless state attributes: Radix data-[state=active]:; Headless UI data-selected: data-open: data-hover:; React Aria data-selected: data-hovered:. A made-up variant builds without error and writes no CSS
  • shadcn/ui (CLI 4.21.4, style base-nova): copies button.tsx and utils.ts into your project; cn comes from the cn package; you own the files
  • daisyUI 5: @plugin "daisyui";, themes by data-theme, utilities override its classes. Flowbite 4: theme import + @plugin "flowbite/plugin" + @source + a script; the @source line made my small page's CSS 2.2 times larger
  • Plain components: a lookup table and a join; give callers props, not className, or add cn
Coming next
Chapter 7 is about keeping a Tailwind project healthy: what affects output size and build time, the Prettier plugin that sorts classes, ESLint and editor tooling, a code-review checklist, visual-regression testing and browser-support decisions. Start with Tailwind CSS Intermediate/Advanced 7.