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.

Every example here was run, except where marked
Tailwind 4.3.3, Vite 8.3.4, React 19.3.0, Astro 7.3.8 with @astrojs/react 7.0.1, @radix-ui/react-tabs 1.2.0 and react-dialog 1.3.0, class-variance-authority 0.7.1, clsx 2.1.1, tailwind-merge 3.7.0, TypeScript 6.0.3 (the version npm installed), prettier 3.9.10 with prettier-plugin-tailwindcss 0.8.1, eslint 10.12.0 with eslint-plugin-better-tailwindcss 4.9.0, Node 24.18.0, Windows 11, headless Chrome 154 driven over the DevTools Protocol. Not run: a real screen reader, a real Windows contrast theme, Firefox and Safari, the Next.js wiring (the outline allowed Astro or Next.js; I chose Astro, the default), a deployed site, and any test of the kit's keyboard behaviour beyond the ones listed below.

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:

kit/ ├── index.html no-flash theme script (chapter 5) ├── vite.config.ts ├── eslint.config.js better-tailwindcss, entry point = tokens.css ├── .prettierrc tailwind plugin, stylesheet, cn and cva ├── .gitignore node_modules and dist (this matters, see below) └── src/ ├── ui/ │ ├── tokens.css themes and the @theme mapping │ ├── cn.ts clsx + tailwind-merge │ ├── button.tsx input.tsx card.tsx tabs.tsx dialog.tsx └── docs/ ├── App.tsx the documentation page (exports App) └── main.tsx mounts App for Vite

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.

@import 'tailwindcss'; /* The kit's own files, wherever the app that imports this stylesheet lives */ @source '../'; /* Dark mode follows the data-mode attribute, not the OS setting */ @custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *)); /* Semantic tokens are plain variables, switched by data-brand and data-mode */ :root { color-scheme: light; --surface: oklch(99% 0.003 250); --surface-raised: oklch(100% 0 0); --ink: oklch(22% 0.03 255); --ink-muted: oklch(45% 0.03 255); --line: oklch(86% 0.01 255); --primary: oklch(50% 0.2 255); --primary-ink: oklch(99% 0 0); --danger: oklch(50% 0.2 27); --danger-ink: oklch(99% 0 0); --ring: oklch(55% 0.2 255); } [data-mode='dark'] { color-scheme: dark; --surface: oklch(18% 0.02 255); --surface-raised: oklch(23% 0.025 255); --ink: oklch(95% 0.01 255); --ink-muted: oklch(75% 0.02 255); --line: oklch(36% 0.025 255); --primary: oklch(72% 0.14 255); --primary-ink: oklch(18% 0.03 255); --danger: oklch(72% 0.15 27); --danger-ink: oklch(18% 0.03 27); --ring: oklch(75% 0.14 255); } [data-brand='sunset'] { --primary: oklch(52% 0.18 40); --ring: oklch(58% 0.18 40); } [data-brand='sunset'][data-mode='dark'] { --primary: oklch(75% 0.15 55); --primary-ink: oklch(20% 0.03 55); --ring: oklch(78% 0.15 55); } @theme inline { --color-surface: var(--surface); --color-raised: var(--surface-raised); --color-ink: var(--ink); --color-muted: var(--ink-muted); --color-line: var(--line); --color-primary: var(--primary); --color-primary-ink: var(--primary-ink); --color-danger: var(--danger); --color-danger-ink: var(--danger-ink); --color-ring: var(--ring); --radius-control: 0.5rem; --radius-panel: 0.75rem; } @layer base { body { background: var(--surface); color: var(--ink); } }

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:.

import { cva, type VariantProps } from 'class-variance-authority'; import type { ButtonHTMLAttributes } from 'react'; import { cn } from './cn'; export const buttonStyles = cva( 'inline-flex items-center justify-center gap-2 rounded-control font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none', { variants: { intent: { primary: 'bg-primary text-primary-ink hover:bg-primary/90', secondary: 'border border-line bg-raised text-ink hover:bg-line/40', ghost: 'text-ink hover:bg-line/40', danger: 'bg-danger text-danger-ink hover:bg-danger/90', }, size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4 text-sm', lg: 'h-12 px-6 text-base' }, }, defaultVariants: { intent: 'primary', size: 'md' }, }, ); type Props = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonStyles>; export function Button({ intent, size, className, type = 'button', ...rest }: Props) { return <button type={type} className={cn(buttonStyles({ intent, size }), className)} {...rest} />; }

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::

import * as D from '@radix-ui/react-dialog'; import type { ReactNode } from 'react'; export const Dialog = D.Root; export const DialogTrigger = D.Trigger; export const DialogClose = D.Close; export function DialogContent({ title, description, children }: { title: string; description: string; children?: ReactNode }) { return ( <D.Portal> <D.Overlay className="fixed inset-0 bg-ink/50 transition-opacity duration-150 motion-reduce:transition-none starting:opacity-0" /> <D.Content className="fixed top-1/2 left-1/2 w-[min(28rem,calc(100vw-2rem))] -translate-1/2 rounded-panel border border-line bg-raised p-6 text-ink shadow-lg transition-opacity duration-150 motion-reduce:transition-none starting:opacity-0"> <D.Title className="text-lg font-semibold">{title}</D.Title> <D.Description className="mt-1 text-sm text-muted">{description}</D.Description> <div className="mt-4">{children}</div> </D.Content> </D.Portal> ); }

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

"check": "tsc --noEmit && prettier --check src && eslint src"
npm run check exit 0 (tsc: no errors; "All matched files use Prettier code style!"; eslint: no output) first run, before formatting: prettier reported 5 files; eslint reported 6 warnings, among them "-translate-x-1/2, -translate-y-1/2" can be simplified to "-translate-1/2" "focus-visible:outline-offset-[-2px]" can be simplified to "focus-visible:-outline-offset-2" Incorrect class order (starting:opacity-0 placed before motion-reduce:transition-none) I applied prettier --write and eslint --fix and re-ran the browser tests: results unchanged. seeded errors (bg-raised changed to bg-raisd, and p-3 added next to p-5): error Unknown class detected: bg-raisd error Conflicting class detected: "p-3" and "p-5" apply the same CSS properties: "padding" (reported twice) (the && chain stopped at Prettier first, because the typo also unsorted the list; ESLint was run directly to see these)

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.

Pairocean lightocean darksunset lightsunset dark
body text on page16.8616.2616.8616.26
muted text on card7.467.587.467.58
primary button5.877.585.837.81
danger button6.477.186.477.18
secondary button17.3414.5417.3414.54
input label16.8616.2616.8616.26
placeholder7.467.587.467.58
inactive tab7.268.487.268.48
error text6.457.136.457.13
focus ring against page (needs 3)4.768.414.519.00
CONTROL: ink on primary2.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

TAB ORDER: brand-ocean > brand-sunset > mode-light > mode-dark > b-primary > b-secondary > b-ghost > b-danger > (Small) > (Large) > email > name > tab-one > tab panel > open-dialog > (leaves the page) > back to brand-ocean (the disabled button is skipped; tab-two and tab-three are NOT in the Tab order: Radix uses arrow keys inside the list) FOCUS RING on the first control: :focus-visible true, outline solid 2px in the ring colour TABS: focus on tab-one, ArrowRight -> tab-two selected and focused, panel text "Second panel"; End -> tab-three selected DIALOG open: role dialog, aria-labelledby and aria-describedby set, focus moved inside (to Cancel); Tab pressed 3 times: focus stays inside (Cancel > Delete > ...); Escape: dialog gone, focus returned to "Open dialog" INPUT: after typing "nope": aria-invalid=true, aria-describedby=email-error, message "Enter a valid email address.", border = danger colour REDUCED MOTION: button transition-property = none (against "color, background-color, border-color, ..." normally) DIALOG animation: opacity 0 at insertion, 1 after 400 ms PERSISTENCE: choose sunset + dark, reload -> data-brand/data-mode = sunset/dark, localStorage the same; body background over the first 200 ms of frames: one value only (dark), no light frame

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).

// astro/astro.config.mjs export default defineConfig({ integrations: [react()], vite: { plugins: [tailwindcss()] }, }); // astro/src/pages/index.astro (frontmatter) import { App } from '../../../kit/src/docs/App'; // body: <App client:load />
first build: exit 0, no warning. CSS 6,396 B. bg-primary: 0 matches. outline-ring: 0. rounded-control: 0. The page rendered unstyled: the kit's classes were outside the Astro project, so the scanner never saw them. fix: one line at the top of the SHARED stylesheet (kit/src/ui/tokens.css): @source '../'; second build: exit 0. CSS 15,092 B. bg-primary: 3. outline-ring: 1. rounded-control: 1. starting: 2.

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:

same build, second run Vite against Astro ocean-light 0 of 990,000 pixels 0 of 990,000 ocean-dark 0 0 sunset-light 0 0 sunset-dark 0 0 ocean-light against ocean-dark: 981,557 pixels differ ocean-light against sunset-light: 15,535 differ (the brand colour only)

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.

no .gitignore: build 1 -> 14.06 kB build 2 (dist/ now exists) -> 28.82 kB .gitignore with "node_modules" and "dist": build 1 -> 14.06 kB build 2 -> 14.06 kB (every later build the same)

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 @source for 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).
Mistakes while preparing this chapter
1. I blamed the wrong cause for the CSS doubling. I assumed my test scripts were being scanned; moving them changed nothing. How to avoid it: change one thing at a time and re-measure; here, deleting 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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-brand and data-mode on <html>, a classic head script before paint, @custom-variant dark following data-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 dist and node_modules in .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:

ChapterYou can nowChapter's key measured result
1 Engineexplain how v4 finds classes, use @source, read the layered outputdynamic class names fail; .gitignore is honoured even without git
2 Design Tokensbuild multi-theme systems with @theme inline and variablesinline tokens are not written to the CSS; themes switch by attribute
3 Utilities and Pluginswrite @utility, @custom-variant and small pluginsa bad --value() prints an error but exits 0
4 Advanced Variantsuse groups, peers, has-, not-, in-, *:, data and aria variantsin-hover: matches whenever the pointer is on the page
5 Frameworkswire Tailwind into seven toolchainsa late theme script flashed the wrong colour for 175 ms
6 Component Patternsmerge classes, build typed variants, choose a libraryclass order in the attribute never decides conflicts
7 Toolingsize, time and lint a projectsize follows distinct class-and-variant combinations
8 Migrationmigrate v3 to v4 and verify itthe upgrade tool left 4,279 differing pixels; by hand, 0
9 Accessibilityhandle focus, contrast, motion, forced colours, RTL and printoutline-hidden alone left keyboard focus invisible
10 Capstoneship a themed, checked kit in two toolsidentical 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.