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.
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:
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:
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:
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:
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:
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:
The three conventions differ and your classes must match the library:
| Library | State attributes seen | Tailwind class that matched |
|---|---|---|
| Radix UI | data-state with a word (active, open, closed) | data-[state=active]:… (arbitrary form: the value matters) |
| Headless UI 2.2 | boolean attributes: data-selected, data-open, data-hover, data-focus | data-selected:… (Tailwind's boolean data-* shorthand, no brackets) |
| React Aria Components 1.22 | data-selected, data-hovered, data-focused (and data-pressed, used in my build, not clicked) | data-selected:…, data-hovered:… |
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:
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:
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.47 | Flowbite 4.0.2 | |
|---|---|---|
| Setup in CSS | @plugin "daisyui"; | @import "flowbite/src/themes/default"; @plugin "flowbite/plugin"; @source "./node_modules/flowbite"; |
| Behaviour | none in what I ran: CSS only | a JavaScript file (flowbite.min.js) reads data-* attributes |
| Theming | themes by data-theme: @plugin "daisyui" { themes: light --default, dark --prefersdark, cupcake; } produced rules for [data-theme=light], dark and cupcake | a 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 page | 10,998 B (plain Tailwind: 4,165 B) | not measured |
| CSS with one small page | 22,744 B (default themes), 23,843 B with three themes; plain Tailwind 6,151 B for that page | 26,725 B without the @source line, 57,882 B with it; plain Tailwind 10,564 B |
| Overriding | utilities win: btn btn-primary px-12 gave padding 48px (default 16px) and bg-red-500 replaced the primary background | ordinary 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:
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.
| Situation | Reasonable choice | Why |
|---|---|---|
| One or two components, a few variants | plain lookup table, no extra packages | nothing to conflict; the table is the documentation |
| A design system, many variants, callers add classes | cva + cn | typed variants, one merge point |
| Interactive widgets (tabs, dialogs, menus) | a headless library | focus 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 edit | shadcn/ui | you own the files; upstream fixes are your merges |
| Want finished looks with the least markup | daisyUI | class names, themes by attribute; utilities still override |
| Want ready widgets plus a script, any back end | Flowbite | data-* attributes and one script; check the @source cost |
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
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.
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.
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 solutionChapter 6 Quick Reference
- Conflicting utilities are decided by the order of rules in the built CSS, not by the order in the
classattribute;twMergeremoves the loser from the string beforehand cn = (...a) => twMerge(clsx(a)).clsxjoins,twMergeresolves conflicts; different variants (hover:bg-*andbg-*) do not conflict- Custom
@themenames in size, shadow or radius groups are not known totwMerge: useextendTailwindMerge(the colour case happened to work) cva(base, { variants, compoundVariants, defaultVariants })builds a string only; unknown values add nothing silently;VariantPropsmakes them a type error; merge withcn(button(...), className)- Headless state attributes: Radix
data-[state=active]:; Headless UIdata-selected:data-open:data-hover:; React Ariadata-selected:data-hovered:. A made-up variant builds without error and writes no CSS - shadcn/ui (CLI 4.21.4, style
base-nova): copiesbutton.tsxandutils.tsinto your project;cncomes from thecnpackage; you own the files - daisyUI 5:
@plugin "daisyui";, themes bydata-theme, utilities override its classes. Flowbite 4: theme import +@plugin "flowbite/plugin"+@source+ a script; the@sourceline made my small page's CSS 2.2 times larger - Plain components: a lookup table and a join; give callers props, not
className, or addcn