Chapter 08
Icons, Tailwind & CSS Customization
Icons, Tailwind CSS v4, and design tokens for React 19.2 SPAs — icon library landscape, SVG delivery, CSS variables, tailwind-merge, dark mode, container queries.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can pick an icon library for each surface (chrome, content, brand), set up Tailwind CSS v4 on Vite in under five minutes, design a token system in CSS variables that drives both Tailwind utilities and component libraries (shadcn, Blueprint), and ship a working light/dark theme that survives SSR and respects user preference.
🧭 Prerequisites — Ch 7 (shadcn) — many examples extend that install.
🔹 8.1 The icon-library landscape (2026)
None of these is “best” — they fit different needs.
| Library | Style | Distribution | Tree-shaking | Use when… |
|---|---|---|---|---|
| Lucide React | Clean, line, 1.5px | Tree-shaken comps | ✅ Excellent | Default for product UIs; shadcn ships it |
| Heroicons | Tailwind-flavoured | Tree-shaken comps | ✅ Excellent | Tailwind-first marketing pages, design alignment |
| Phosphor | 6 weights | Tree-shaken comps | ✅ Excellent | Need stylistic variants per context |
| Tabler | Line, very large set | Tree-shaken comps | ✅ Excellent | Need an obscure icon ASAP |
| Iconify | Universal — wraps everything | On-demand fetch | ⚠️ Different | One API across many icon packs; multi-brand systems |
| Blueprint Icons | Dense, application-style | Font + comps | ⚠️ Heavier | Inside a Blueprint app (Ch 10) |
// Lucide
import { Check, Plus, ChevronRight } from 'lucide-react';
<Check className="size-4" />
// Heroicons (outline / solid)
import { CheckIcon } from '@heroicons/react/24/outline';
// Phosphor
import { Check } from '@phosphor-icons/react';
<Check weight="bold" size={16} />
// Iconify (any pack, on-demand)
import { Icon } from '@iconify/react';
<Icon icon="lucide:check" />
My default: Lucide for product UIs (shadcn ships it; same visual language), Iconify if you need access to many packs without committing to one. Multiple libraries inside one app fight visually — pick one per region.
🔹 8.2 SVG delivery strategies
Three modes — when each wins:
-
React component per icon (tree-shaken). Lucide, Heroicons, Phosphor default.
- Pro: dead-code elimination per icon used.
- Con: many small modules in dev (matters for HMR — cross-link Ch 17.1).
-
SVG sprite. One
<symbol>file referenced via<use href="#icon-id">.- Pro: one HTTP request, browser caches forever.
- Con: harder per-instance colour customisation if
currentColorisn’t set.
-
Inline SVG. Paste the markup; for one-offs (logos, brand marks).
- Pro: full control, no dedup overhead.
- Con: no dedup if reused.
┌─ Bundle impact (Lucide, 500-icon set) ───────────────────┐
│ All icons imported: ~150 KB │
│ 12 icons used + tree-shake: ~3 KB │
│ Sprite of those 12: ~3 KB + 1 HTTP request │
└──────────────────────────────────────────────────────────┘
🔹 8.3 Icon accessibility
The four-line checklist:
- Decorative →
aria-hidden="true". Most chrome icons are decorative. - Meaningful →
role="img"+ accessible label viaaria-label. - Icon-only button →
aria-labelon the button,aria-hiddenon the icon. - Stroke colour follows text → use
currentColor(Lucide / Heroicons / Phosphor do by default).
// decorative
<Check aria-hidden="true" className="size-4" />
// meaningful (standalone)
<Check role="img" aria-label="Done" className="size-4 text-green-600" />
// icon-only button
<Button aria-label="Delete invoice" variant="ghost" size="icon">
<Trash2 aria-hidden="true" className="size-4" />
</Button>
🔹 8.4 Tailwind CSS v4 with Vite
Why v4 — CSS-first config. No tailwind.config.js required for basic use. Native @theme. Container queries built in.
npm install tailwindcss @tailwindcss/vite
📄 vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});
📄 src/styles/globals.css
@import "tailwindcss";
@theme {
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--font-display: "Inter", system-ui, sans-serif;
--radius-pill: 9999px;
}
Tailwind generates bg-brand-500, text-brand-600, font-display, rounded-pill utilities from that block. No config file needed.
Arbitrary values — bg-[#1d4ed8], grid-cols-[200px_1fr]. Fine for one-offs. A smell if you use them constantly — extract to a token in @theme.
🔹 8.5 CSS variables and design tokens
A token system feeds Tailwind, shadcn, Blueprint, and any third-party widgets you adopt later.
The three-layer hierarchy (cross-link Ch 11.6):
- Primitive — raw values (
--color-blue-500). - Semantic — role-based aliases (
--color-primary→ primitive). - Component — token per component, only when needed (
--button-bg→ semantic).
:root {
/* primitives */
--color-blue-500: oklch(70% 0.2 250);
--color-blue-600: oklch(60% 0.2 250);
--color-gray-50: oklch(98% 0.005 250);
--color-gray-900: oklch(15% 0.005 250);
/* semantic */
--color-bg: var(--color-gray-50);
--color-text: var(--color-gray-900);
--color-primary: var(--color-blue-500);
--color-primary-hover: var(--color-blue-600);
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
}
.dark {
--color-bg: var(--color-gray-900);
--color-text: var(--color-gray-50);
}
Why oklch() — perceptually uniform colour space. Lightness changes look right across hues. Tailwind v4 generates oklch values by default.
shadcn and Blueprint both read --color-primary (or equivalent) when you map their tokens through. One source of truth, two libraries.
🔹 8.6 Custom utility classes and @theme extensions
Tailwind v4’s @theme block extends the default theme. Define a new token, get the utility for free:
@theme {
--color-success: oklch(60% 0.18 150);
--animate-shimmer: shimmer 2s linear infinite;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
Now bg-success, text-success, and animate-shimmer are real utilities.
For custom utilities that don’t fit @theme (e.g., a special hover gradient), use @utility:
@utility brand-gradient {
background: linear-gradient(135deg, var(--color-brand-500), var(--color-brand-600));
color: white;
}
<div class="brand-gradient"> works as a utility class.
🔹 8.7 tailwind-merge, clsx, cva
Three small helpers that change how you write variants:
import { cn } from '@/lib/cn'; // your wrapper
cn('px-4', isDanger && 'bg-red-500'); // clsx — conditional strings
cn('px-4', 'px-2'); // tailwind-merge — px-2 wins
📄 src/lib/cn.ts — the canonical wrapper
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
cva (class-variance-authority) for typed variants:
import { cva, type VariantProps } from 'class-variance-authority';
const badgeVariants = cva('inline-flex items-center rounded-pill font-medium', {
variants: {
intent: { neutral: 'bg-gray-100 text-gray-900', success: 'bg-success/15 text-success' },
size: { sm: 'px-2 py-0.5 text-xs', md: 'px-3 py-1 text-sm' },
},
defaultVariants: { intent: 'neutral', size: 'md' },
});
type Props = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>;
export const Badge = ({ intent, size, className, ...rest }: Props) => (
<span {...rest} className={cn(badgeVariants({ intent, size }), className)} />
);
🔹 8.8 Dark mode strategies
Two viable patterns:
- Class-based —
<html class="dark">. Works with shadcn out of the box. User can override system preference. prefers-color-scheme-only — purest, no manual override.
Avoiding the FOUC (flash of unstyled content):
<!-- index.html -->
<script>
const t = localStorage.theme ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
if (t === 'dark') document.documentElement.classList.add('dark');
</script>
Inline at the top of <head>, before any stylesheet links. Runs synchronously; the class is set before paint.
next-themes packages this pattern. Works in pure SPAs despite the name.
Tri-state toggle: Light / Dark / System. “System” stores no preference; reads matchMedia each time. Simple to implement, surprisingly often the right default.
🔹 8.9 Container queries
Components adapt to their container, not the viewport. Crucial for dashboards where the same widget renders at 240 px and 720 px wide.
.card-wrap { container-type: inline-size; }
.card-content {
display: grid;
gap: 1rem;
}
@container (min-width: 400px) {
.card-content { grid-template-columns: 1fr 1fr; }
}
Tailwind v4:
<div className="@container">
<div className="grid gap-4 @md:grid-cols-2">…</div>
</div>
The @container class enables container queries on the element; @md: applies only when the container is wider than the md breakpoint. Same component, three sidebar widths, three layouts.
🪤 Common Pitfalls
- Hard-coding hex colours in components → tokens exist; use them.
- Forgetting
aria-hiddenon decorative icons → screen-reader noise. - Combining many icon libraries → bundle bloat and visual chaos.
- Skipping
tailwind-merge→px-4 px-2survives, breaking expectations. - Theme toggle without inline-boot script → FOUC on first paint.
- Arbitrary values everywhere instead of token discipline.
- Container queries without
container-type→@containersilently no-ops.
✅ Recap
- Pick one primary icon library per region. Lucide is the safe default with shadcn.
- Tailwind v4 is CSS-first — design your tokens in
@themeonce. - A shared token layer makes shadcn and Blueprint coexist peacefully.
- Use
cva+tailwind-mergeto keep variants typed and conflict-free. - Container queries replace most viewport-based responsive design for components.
🔗 Further Reading
- https://lucide.dev/ — Lucide icons (default recommendation).
- https://heroicons.com/ — Heroicons.
- https://phosphoricons.com/ — Phosphor icons.
- https://iconify.design/ — Iconify (aggregator).
- https://tailwindcss.com/ — Tailwind v4 docs.
- https://cva.style/ —
class-variance-authority. - https://open-props.style/ — Open Props design-tokens reference.
- Ch 7 (shadcn), Ch 11.6 (token export from a library).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.