Capstone Kit
Tailwind CSS Intermediate/Advanced
Chapter 10 · Capstone: A Themed Component Kit
This chapter uses everything in the course on one small project: a component kit with a button, an input, a card, tabs and a dialog; design tokens with two brand themes and dark mode; cva variants;
a documentation page; and the same kit running inside two different tools. As in the earlier chapters, nothing is described that I did not build and measure, and what went wrong on the way is listed at the end.
The Plan
The kit has three layers, each from an earlier chapter: tokens (chapter 2: semantic variables that the themes change), components (chapter 6: cva for variants, cn to merge,
Radix for behaviour) and checks (chapters 7 and 9: formatting, linting, contrast, keyboard and motion tests). The folder:
Tokens and Themes
The tokens are plain CSS variables on :root, replaced by attribute selectors for dark mode and for each brand. A separate @theme inline block turns them into Tailwind colour names
(bg-primary, text-muted), exactly the pattern from chapter 2: inline is what lets the utilities follow whichever variable value is active.
The dark: variant is redefined to follow the data-mode attribute instead of the operating system setting.
The brand and mode are set by two attributes on <html>. @source '../'; at the top points the scanner at the kit's own files, wherever the app that imports this stylesheet lives; I did not have that line at first
(see the Astro section for what happened).
The Components
Button uses cva for four intents and three sizes, merges the caller's className with cn, shows its focus ring only on focus-visible: (chapter 9), and turns off its colour
transition under motion-reduce:.
Input is a labelled field. The label is tied to the input by htmlFor, and when there is an error the input gets aria-invalid and aria-describedby pointing at the message;
the red border comes from the aria-invalid: variant, not a separate class, so styling and accessibility cannot drift apart (chapter 4). Tabs and Dialog wrap Radix, which supplies the behaviour,
and add only classes. The tab styling hangs on data-[state=active]: (chapter 6); the dialog fades in with starting:opacity-0 and turns that off under motion-reduce::
The documentation page (App.tsx) shows every component in every variant, with buttons that set the brand and the mode and save them in localStorage. The theme is applied before the page paints by a small classic
script in the <head> (chapter 5), so a saved dark choice does not flash light first.
The Checks I Ran
1. Types, formatting, linting
2. Contrast, for every brand in every mode
Using the method from chapter 9 (computed colour, converted to sRGB through a canvas, WCAG formula), I switched the page through the four theme combinations with real clicks and measured nine text pairs and the focus ring. The last row is a control: ink text on the primary colour, which must fail, to prove the method can report a failure.
| Pair | ocean light | ocean dark | sunset light | sunset dark |
|---|---|---|---|---|
| body text on page | 16.86 | 16.26 | 16.86 | 16.26 |
| muted text on card | 7.46 | 7.58 | 7.46 | 7.58 |
| primary button | 5.87 | 7.58 | 5.83 | 7.81 |
| danger button | 6.47 | 7.18 | 6.47 | 7.18 |
| secondary button | 17.34 | 14.54 | 17.34 | 14.54 |
| input label | 16.86 | 16.26 | 16.86 | 16.26 |
| placeholder | 7.46 | 7.58 | 7.46 | 7.58 |
| inactive tab | 7.26 | 8.48 | 7.26 | 8.48 |
| error text | 6.45 | 7.13 | 6.45 | 7.13 |
| focus ring against page (needs 3) | 4.76 | 8.41 | 4.51 | 9.00 |
| CONTROL: ink on primary | 2.88 (fails) | 2.15 (fails) | 2.90 (fails) | 2.01 (fails) |
Every real pair passes 4.5 (the ring needs only 3). The numbers are not all alike, so the measurement is not stuck, and the control fails in all four. I chose the colours with those targets in mind and they passed on the first run; that will not usually happen, and it is the reason to keep the test. Hover colours (the 90% mix on buttons) and the disabled state were not measured.
3. Keyboard, dialog, motion and persistence
The Second Wiring: Astro
The same components, unchanged, rendered inside an Astro page as a React island (<App client:load />), importing the kit's files from the folder next to it. The Astro configuration is the one from chapter 5:
the React integration and @tailwindcss/vite inside vite. The no-flash script is is:inline (chapter 5 again, or Astro turns it into a deferred module).
This is the monorepo failure from chapter 5, met again in a project I had designed to avoid it: no error, an almost empty stylesheet. Putting the @source line in the shared stylesheet, relative to that file, means
every app that imports the kit gets the right sources without its own configuration. With that, I ran the same keyboard, contrast, dialog and persistence scripts against the Astro build: every result was identical to the Vite
build. Then I took screenshots (900 by 1100) of both builds in all four theme combinations and compared them:
The two controls matter: the same build twice gives zero (so the comparison is not noisy) and the different themes give large differences (so it can see a change). Both wirings produce the same pixels. Sizes: the Vite build is 14.06 kB of CSS (3.64 kB gzipped) and 308.5 kB of JavaScript (96.9 kB gzipped); the Astro build has 15.1 kB of CSS and loads the same components as an island.
Two Traps I Hit on the Way
The build folder is scanned. After formatting, the CSS suddenly measured 29.4 kB instead of 14.1 kB. My first guess was that the test scripts I had put in the project folder were being read (they contain many
class-like words). I moved them out and the size stayed at 28.8 kB, so the guess was wrong. The real cause was dist/: the first build had no output folder; the second build scanned the first build's 308 kB JavaScript bundle and
generated a rule for every class-like word in it.
That is the .gitignore behaviour from chapter 1 working in your favour: always keep dist and node_modules in it, or the output grows by itself. And the second trap is the one above:
a shared stylesheet outside the app needs its own @source.
Checklist: Before You Ship a Kit
- Sources. The shared stylesheet has
@sourcefor its own files; the build output folder is ignored; a second build gives the same CSS size as the first. - Tokens. Components use semantic names only (
bg-primary), never palette names; every brand and mode defines every token. - Contrast. A script measures every text pair in every theme and includes a control that must fail.
- Keyboard. Tab order, arrow keys in tabs, dialog focus in and out, a visible
focus-visible:ring on everything interactive. - Preferences.
motion-reduce:on every transition and animation; saved theme applied before paint. - Types and lint.
tsc, Prettier and ESLint pass in one command; a seeded typo is caught. - Each wiring. The same tests and a pixel comparison per tool; do not assume the second one works because the first does.
- Browsers. Chrome 111, Safari 16.4, Firefox 128 are the minimum for Tailwind 4 (chapter 7); I tested Chrome only.
- Not done here, but before shipping: a real screen-reader pass, Firefox and Safari, a real forced-colours theme, and a release process (versioning, a changelog).
dist/ and building twice found the cause in two builds.2. The Astro page built, exit 0, with no kit styles. I had not carried the chapter 5 monorepo lesson into my own design. How to avoid it: after wiring a second tool, grep its CSS for a kit class before looking at anything else.
3. My theme-persistence test seemed to show the feature was broken. The page had scrolled, so my script clicked at a position that was not on screen. How to avoid it: scroll the target into view, or check that the click changed something, before drawing conclusions about the feature.
4. My single
check command stopped at the first failing tool. The ESLint report for the seeded typo never appeared until I ran it directly. How to avoid it: run each tool once on its own after changing the chain, or run them with ; or a task runner that reports all of them.5. I tried to create the whole project with one long shell here-document and the shell rejected it, writing nothing. How to avoid it: write files with the editor tool, and confirm the files exist before the next step.
Hands-On Exercises
Build the tokens (two brands, light and dark), a cva button and an input with error state, then measure every text pair's contrast in all four theme combinations with a control that must fail. Fix anything that fails.
Render the same components in a second tool (Astro, or Next.js if you prefer). Show the first build's CSS lacks the kit's classes, fix it with one @source line in the shared stylesheet, and prove the two builds match with screenshots, including a same-build control and a different-theme control.
Write one check command for types, formatting and linting; seed a typo and a conflict and show the report. Then show that a project without a .gitignore produces a bigger stylesheet on its second build, and fix it.
Chapter 10 Quick Reference
- Layers: tokens (CSS variables +
@theme inline), components (cva+cn+ a headless library), checks (types, Prettier, ESLint, contrast, keyboard, pixels) - Themes by attribute:
data-brandanddata-modeon<html>, a classic head script before paint,@custom-variant darkfollowingdata-mode - Measured: all nine text pairs pass in four themes (lowest 5.83), the control fails (2.01 to 2.90); Vite CSS 14.06 kB; Astro CSS 15.1 kB; Vite and Astro screenshots identical (0 of 990,000 pixels)
- Shared kit in another app:
@source '../';inside the kit's own stylesheet; without it the Astro build exited 0 with 6,396 B of CSS and no kit classes - Keep
distandnode_modulesin.gitignore: without it the second build's CSS doubled (14.06 to 28.82 kB) - Every test needs a control: the same page twice (should match), a different theme (should differ), a colour pair that must fail
Course Complete: What You Can Now Do
You have worked through the ten chapters of Tailwind CSS Intermediate/Advanced. Each one is a skill, and each was checked in a real build rather than taken from memory:
| Chapter | You can now | Chapter's key measured result |
|---|---|---|
| 1 Engine | explain how v4 finds classes, use @source, read the layered output | dynamic class names fail; .gitignore is honoured even without git |
| 2 Design Tokens | build multi-theme systems with @theme inline and variables | inline tokens are not written to the CSS; themes switch by attribute |
| 3 Utilities and Plugins | write @utility, @custom-variant and small plugins | a bad --value() prints an error but exits 0 |
| 4 Advanced Variants | use groups, peers, has-, not-, in-, *:, data and aria variants | in-hover: matches whenever the pointer is on the page |
| 5 Frameworks | wire Tailwind into seven toolchains | a late theme script flashed the wrong colour for 175 ms |
| 6 Component Patterns | merge classes, build typed variants, choose a library | class order in the attribute never decides conflicts |
| 7 Tooling | size, time and lint a project | size follows distinct class-and-variant combinations |
| 8 Migration | migrate v3 to v4 and verify it | the upgrade tool left 4,279 differing pixels; by hand, 0 |
| 9 Accessibility | handle focus, contrast, motion, forced colours, RTL and print | outline-hidden alone left keyboard focus invisible |
| 10 Capstone | ship a themed, checked kit in two tools | identical pixels in Vite and Astro; the build folder doubled the CSS |
Where to go next: the prerequisite course Tailwind CSS Fundamentals if any chapter felt thin; Web Accessibility (a11y) for the standards behind chapter 9 and a real screen-reader pass for your own kit; CSS Frameworks 7 on shadcn/ui if you want to start from an existing kit; and your own project: take the checklist above and run it on something you have already built.