Real Components

Tailwind CSS Fundamentals

Chapter 12 · Building Real Components (Capstone)

The usual first complaint about utility classes is the long class attribute you have to repeat on every button. This chapter answers it properly and then puts the whole course to work: you will build a small landing page from components (a button with variants, a card), a responsive navbar, a form with validation states, and a modal dialog, and check what each does in a real browser. Along the way you meet the three small libraries almost every Tailwind project uses for composing class names: clsx, tailwind-merge and class-variance-authority (cva).

Every example here was run
The page in this chapter was generated by a Node script, compiled with Tailwind CSS 4.3.3 and opened in headless Chrome. The library behaviour shown is the real output of clsx 2.1.1, tailwind-merge 3.7.0 and class-variance-authority 0.7.1. Clicks, typing and keys were real input events. Two things were not tested: other browsers, and a screen reader (the accessibility checklist is a checklist, not a test result).

The Repeated-Markup Problem

A button needs about a dozen utilities, and a page has many buttons. Is that a problem? It depends where the repetition is, and the answer decides how you fix it:

SituationIs it a problem?What to do
A class list repeated across a loop (a list of cards, rows of a table)No: it is written once in the template and the loop repeats itnothing
The same element in several files or places (buttons, inputs, cards)Yes: a change means editing everywheremake it a component (a function, partial, include or framework component)
A piece of markup you do not control (rendered Markdown, a third-party widget)Yes: you cannot add classes@layer components and @apply (chapter 10)
One value repeated inside one fileRarelymulti-cursor editing, or a theme variable (chapter 9)

So the answer to "I have repeated classes" is the same as for any other repeated code: extract the thing, not the CSS. A component keeps the markup, the classes and any behaviour together in one place, and works with every variant (hover:, md:) because those are still just classes. That is why the earlier recommendation was to use @apply sparingly: a component solves the problem without moving your styling into a second file.

Composing Class Names

Components need to build a class string from conditions and options. Three tiny libraries cover it, and each does one job. I ran all the outputs below in Node.

clsx: Conditional Classes

clsx joins strings, skipping anything false, null or empty, and turns an object of { class: condition } into the classes whose condition is true:

import { clsx } from 'clsx'; clsx('px-4', false && 'x', { 'font-bold': true, hidden: false }, ['py-2', null, 'text-sm']); clsx('a', undefined, null, 0, '', false, 'b');
"px-4 font-bold py-2 text-sm" "a b"

tailwind-merge: Resolving Conflicts

Here is a fact that surprises people: the order of classes in the class attribute does not decide which one wins. The order of the rules in the generated stylesheet does. I measured it:

<div class="p-2 p-8"> <div class="p-8 p-2"> <div class="bg-red-500 bg-blue-500"> <div class="bg-blue-500 bg-red-500">
p-2 p-8 padding 32px p-8 p-2 padding 32px same, whichever order bg-red-500 bg-blue-500 oklch(0.637 0.237 25.331) red-500 bg-blue-500 bg-red-500 oklch(0.637 0.237 25.331) red-500: same

That matters the moment a component accepts extra classes from outside (class="px-8" on a button that already has px-4): the caller's class is not guaranteed to win. twMerge fixes it by understanding Tailwind's classes, removing the earlier ones that conflict with a later one:

import { twMerge } from 'tailwind-merge'; twMerge('px-2 py-1 p-3'); twMerge('bg-red-500 bg-blue-500'); twMerge('p-3 px-2'); twMerge('text-sm text-red-500 text-lg'); twMerge('hover:bg-red-500 hover:bg-blue-500 bg-green-500');
"p-3" (p-3 covers px and py, so they go) "bg-blue-500" "p-3 px-2" (a later, more specific class does not remove the shorthand) "text-red-500 text-lg" (size and colour are different things: only text-sm is dropped) "hover:bg-blue-500 bg-green-500" (variants are tracked separately)

The usual helper joins the two: const cn = (...inputs) => twMerge(clsx(inputs));. Use tailwind-merge 3.x with Tailwind v4 (its README says 3.7.0 supports Tailwind 4.0 up to 4.3, and v2.6.0 is for Tailwind v3).

cva: Variants as Data

cva turns a button's options into a function: a base string, named variant groups, defaults, and optional compound rules (compoundVariants):

import { cva } from 'class-variance-authority'; const btn = cva('inline-flex items-center rounded-md font-medium', { variants: { intent: { primary: 'bg-blue-600 text-white', danger: 'bg-red-600 text-white' }, size: { sm: 'px-2 py-1 text-sm', md: 'px-4 py-2' }, }, compoundVariants: [{ intent: 'danger', size: 'sm', class: 'uppercase' }], defaultVariants: { intent: 'primary', size: 'md' }, }); btn(); btn({ intent: 'danger', size: 'sm' }); btn({ class: 'px-8' }); twMerge(btn({ class: 'px-8' }));
"inline-flex items-center rounded-md font-medium bg-blue-600 text-white px-4 py-2" "inline-flex items-center rounded-md font-medium bg-red-600 text-white px-2 py-1 text-sm uppercase" "inline-flex items-center rounded-md font-medium bg-blue-600 text-white px-4 py-2 px-8" (a conflict!) "inline-flex items-center rounded-md font-medium bg-blue-600 text-white py-2 px-8" (merged)

The third line is the reason the libraries are used together: cva chooses the classes, twMerge settles any conflicts. The full treatment of these libraries and of design systems built on them belongs to the next course; this is enough to build with.

The Components

The components are plain functions returning HTML, so they work in any build (a static generator, a server template). In React, Vue or Svelte they become framework components with the same cn and cva code inside. The button:

import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { cva } from 'class-variance-authority'; export const cn = (...inputs) => twMerge(clsx(inputs)); const buttonStyles = cva( 'inline-flex items-center justify-center rounded-md font-medium transition-colors ' + 'focus-visible:outline-2 focus-visible:outline-offset-2 ' + 'disabled:pointer-events-none disabled:opacity-50', { variants: { intent: { primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline-blue-600', secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:outline-gray-500', danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600', }, size: { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-base' }, }, defaultVariants: { intent: 'primary', size: 'md' }, }, ); export function Button({ intent, size, class: extra, label, ...attrs }) { const a = Object.entries(attrs).map(([k, v]) => (v === true ? k : `${k}="${v}"`)).join(' '); return `<button ${a} class="${cn(buttonStyles({ intent, size }), extra)}">${label}</button>`; } export function Card({ title, body }) { return `<article class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm"> <h3 class="text-lg font-semibold text-gray-900">${title}</h3> <p class="mt-2 text-gray-600">${body}</p> </article>`; }
Button({ type: 'button', label: 'Get started' }) background oklch(0.546 0.245 262.881), padding-left 16px hover over it background oklch(0.488 0.243 264.376) (blue-700) Button({ size: 'sm', class: 'px-8', label: 'Override' }) padding-left 32px, padding-top 6px, font-size 14px

The last line shows the pattern working: the small size would give px-3, the caller asked for px-8, cn dropped the conflict and the caller won, while the small size's py-1.5 and text-sm were kept (6px and 14px). Note also that Tailwind finds the class names in the generated page because they appear in full in the file it scans; the components never build a class name out of pieces.

The Page

Three parts of the page deserve a closer look, because each combines several ideas from the course.

A Responsive Navbar

<nav class="mx-auto flex max-w-5xl items-center justify-between p-4" aria-label="Main"> <a href="#" class="text-xl font-bold">Acme</a> <ul id="links" class="hidden items-center gap-6 md:flex"> … </ul> <button id="menuBtn" type="button" aria-expanded="false" aria-controls="mobileMenu" class="rounded-md p-2 hover:bg-gray-100 md:hidden">Menu</button> </nav> <ul id="mobileMenu" class="hidden border-t border-gray-200 p-4 md:hidden"> … </ul>
390px wide: links display none, menu button block, mobile menu none, aria-expanded "false" click Menu: mobile menu block, aria-expanded "true" click Menu: mobile menu none, aria-expanded "false" 900px wide: links display flex, menu button none, mobile menu none

It is mobile-first (chapter 7): the desktop links are hidden md:flex and the button md:hidden. The only JavaScript flips aria-expanded and toggles the hidden class; the attribute is what tells assistive technology whether the menu is open. The feature-card grid below it uses grid gap-6 sm:grid-cols-2 lg:grid-cols-3: measured at 390px it is one column (343px), at 700px two, at 1100px three.

A Form with Validation States

<input id="email" type="email" required aria-describedby="emailErr" class="peer block w-full rounded-md border border-gray-300 px-3 py-2 invalid:border-yellow-500 user-invalid:border-red-500 user-invalid:bg-red-50"> <p id="emailErr" class="hidden text-sm text-red-600 peer-user-invalid:block"> Enter a valid email address. </p>
page loaded, field empty :invalid true :user-invalid false border yellow-500 error message display none typed "abc", clicked elsewhere :user-invalid true border red-500 background red-50 error message display block replaced with "me@example.com", clicked elsewhere :user-invalid false border gray (the normal colour) error message display none

This is the important difference between the two variants. invalid: matches as soon as the browser considers the field invalid, which for a required field is before the user has typed anything, so a red border there would greet people with an error on arrival. user-invalid: only matches after the person has interacted with the field. In my test the field was yellow on load and turned red only after typing a bad value and leaving it. The peer class on the input lets the sibling message react to it (peer-user-invalid:block), and aria-describedby ties the message to the field for screen readers. I used novalidate on the form so the browser's own bubble does not compete with the page's message.

A Modal Dialog

<dialog id="dlg" aria-labelledby="dlgTitle" class="m-auto w-full max-w-sm rounded-xl p-6 backdrop:bg-black/50"> <h2 id="dlgTitle" class="text-lg font-semibold">Delete account?</h2> … <button id="cancel" type="button" autofocus … >Cancel</button> <button id="confirm" type="button" … >Delete</button> </dialog> document.getElementById('open').addEventListener('click', () => dlg.showModal()); document.getElementById('cancel').addEventListener('click', () => dlg.close());
before: dlg.open false click "Delete account": dlg.open true, focus moved to #cancel (the autofocus button) backdrop: oklab(0 0 0 / 0.5) (backdrop:bg-black/50) Tab, Tab, Tab: confirm, then focus left the page's elements, then cancel (it never landed on a button behind the dialog) Escape: dlg.open false, focus returned to #open

The key decision is to use the native <dialog> element with showModal() rather than a div you show and hide. The browser provides the focus handling you would otherwise write by hand: focus moves into the dialog, the rest of the page cannot be reached, Escape closes it, and focus goes back to the button that opened it. Tailwind's backdrop: variant styles the dim layer behind it. Putting autofocus on the safe action (Cancel) means a keyboard user's first Enter does not delete anything. In the Tab test the middle step was the browser's own interface, which is normal for a modal dialog; I did not test with a screen reader.

An Accessibility Checklist

Tailwind styles things; it does not make them accessible. Before you ship a page, go through these (I built the example page to satisfy them):

CheckIn this chapter
Use the right elementbutton for actions, a for navigation, dialog for modals, nav with aria-label
Visible keyboard focusfocus-visible:outline-2 focus-visible:outline-offset-2 on the button, on the input too; never remove the outline without a replacement
State exposed to assistive technologyaria-expanded on the menu button, updated by the script
Labels and messages tied to fieldslabel for, aria-describedby for the error
Do not rely on colour alonethe error is a text message as well as a red border
Contrastcheck text colours against their backgrounds with a tool; I did not measure contrast ratios here
Respect user preferencesmotion-safe: / motion-reduce: (chapter 11), dark mode (chapter 8)
Responsive and zoomablethe viewport meta tag (chapter 7) and layouts that work at 390px
What this page proves, and what it does not
The components, the responsive behaviour, the validation timing and the dialog's focus handling were all observed. Cross-browser behaviour (Safari and Firefox), screen-reader output and colour-contrast ratios were not tested, so run those checks before using this as a template for a real site.

Hands-On Exercises

Exercise 1

Prove that class order in the attribute does not decide the winner, then build a cn() helper from clsx and tailwind-merge and run the five conflict examples from this chapter. Show what happens to px-4 px-8 before and after merging.

📄 View solution
Exercise 2

Write a Badge component with cva: three colours, two sizes, a default of each, and a compound rule for one combination. Print the class string for four different calls, including one that passes an extra class that conflicts, and merge it.

📄 View solution
Exercise 3

Build a sign-up form whose email field shows a yellow border while empty-and-required, a red border and an error message only after the user has typed a bad value and left the field, and returns to normal when fixed. Show the :invalid and :user-invalid state at each of the three moments, and explain why invalid: alone would be wrong for the error colour.

📄 View solution

Chapter 12 Quick Reference

  • Repeated markup: extract a component (function, partial, framework component), not CSS; @apply only for markup you cannot edit
  • clsx joins and filters; twMerge removes conflicting Tailwind classes (px-2 py-1 p-3 becomes p-3); cn = (...a) => twMerge(clsx(a)); use tailwind-merge 3.x for Tailwind v4
  • Class order in the attribute does not decide the winner; stylesheet order does, so merge conflicts before they reach the page
  • cva(base, { variants, compoundVariants, defaultVariants }) returns a function from options to a class string
  • Never build class names from fragments; the full names must appear in files Tailwind scans
  • Forms: invalid: matches immediately, user-invalid: after interaction; peer + peer-user-invalid: shows a message; add aria-describedby
  • Modal: native <dialog> + showModal() gives focus handling, Escape and focus return; style the dim layer with backdrop:; autofocus the safe button
  • Navbar: hidden md:flex + md:hidden button with aria-expanded and aria-controls

Course Complete: What You Can Now Do

ChapterYou can now…
1 · Why Utility-First, and Getting Set Upexplain the approach and set up Tailwind v4 with the CLI or Vite
2 · The Scale: Spacing, Sizing and Typographyuse the spacing scale, sizing and type utilities fluently
3 · Colour, Backgrounds and Bordersapply colour, gradients, borders, rings and shadows
4 · Layout: Display, Flexbox and Gridbuild layouts with flex and grid
5 · Positioning, Stacking and Overflowposition, stack and clip elements
6 · State Variants: Hover, Focus and Friendsstyle states, groups, peers and form states
7 · Responsive Design and Container Queriesdesign mobile-first with breakpoints and container queries
8 · Dark Mode and Preference Variantssupport dark mode and user preferences
9 · Your Theme: @theme, Variables and Fontscustomise the design system in CSS
10 · Going Beyond the Utilitiesuse arbitrary values, custom utilities and layers, and know when to write CSS
11 · Transitions, Transforms and Animationadd motion that respects reduced-motion settings
12 · Building Real Componentscompose classes, build components, and assemble an accessible landing page
Coming next
The follow-on course is Tailwind CSS Intermediate/Advanced. It opens with how the v4 engine works, then covers design tokens and multiple themes, custom utilities and plugins, advanced variants, framework integration, component patterns, migrating from v3, and a themed component kit. Start with Tailwind CSS Intermediate/Advanced 1.