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).
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:
| Situation | Is 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 it | nothing |
| The same element in several files or places (buttons, inputs, cards) | Yes: a change means editing everywhere | make 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 file | Rarely | multi-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:
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:
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:
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):
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:
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
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
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
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):
| Check | In this chapter |
|---|---|
| Use the right element | button for actions, a for navigation, dialog for modals, nav with aria-label |
| Visible keyboard focus | focus-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 technology | aria-expanded on the menu button, updated by the script |
| Labels and messages tied to fields | label for, aria-describedby for the error |
| Do not rely on colour alone | the error is a text message as well as a red border |
| Contrast | check text colours against their backgrounds with a tool; I did not measure contrast ratios here |
| Respect user preferences | motion-safe: / motion-reduce: (chapter 11), dark mode (chapter 8) |
| Responsive and zoomable | the viewport meta tag (chapter 7) and layouts that work at 390px |
Hands-On Exercises
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.
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.
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.
Chapter 12 Quick Reference
- Repeated markup: extract a component (function, partial, framework component), not CSS;
@applyonly for markup you cannot edit clsxjoins and filters;twMergeremoves conflicting Tailwind classes (px-2 py-1 p-3becomesp-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; addaria-describedby - Modal: native
<dialog>+showModal()gives focus handling, Escape and focus return; style the dim layer withbackdrop:;autofocusthe safe button - Navbar:
hidden md:flex+md:hiddenbutton witharia-expandedandaria-controls
Course Complete: What You Can Now Do
| Chapter | You can now… |
|---|---|
| 1 · Why Utility-First, and Getting Set Up | explain the approach and set up Tailwind v4 with the CLI or Vite |
| 2 · The Scale: Spacing, Sizing and Typography | use the spacing scale, sizing and type utilities fluently |
| 3 · Colour, Backgrounds and Borders | apply colour, gradients, borders, rings and shadows |
| 4 · Layout: Display, Flexbox and Grid | build layouts with flex and grid |
| 5 · Positioning, Stacking and Overflow | position, stack and clip elements |
| 6 · State Variants: Hover, Focus and Friends | style states, groups, peers and form states |
| 7 · Responsive Design and Container Queries | design mobile-first with breakpoints and container queries |
| 8 · Dark Mode and Preference Variants | support dark mode and user preferences |
| 9 · Your Theme: @theme, Variables and Fonts | customise the design system in CSS |
| 10 · Going Beyond the Utilities | use arbitrary values, custom utilities and layers, and know when to write CSS |
| 11 · Transitions, Transforms and Animation | add motion that respects reduced-motion settings |
| 12 · Building Real Components | compose classes, build components, and assemble an accessible landing page |