modern-react-spa

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.

LibraryStyleDistributionTree-shakingUse when…
Lucide ReactClean, line, 1.5pxTree-shaken comps✅ ExcellentDefault for product UIs; shadcn ships it
HeroiconsTailwind-flavouredTree-shaken comps✅ ExcellentTailwind-first marketing pages, design alignment
Phosphor6 weightsTree-shaken comps✅ ExcellentNeed stylistic variants per context
TablerLine, very large setTree-shaken comps✅ ExcellentNeed an obscure icon ASAP
IconifyUniversal — wraps everythingOn-demand fetch⚠️ DifferentOne API across many icon packs; multi-brand systems
Blueprint IconsDense, application-styleFont + comps⚠️ HeavierInside 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:

  1. 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).
  2. 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 currentColor isn’t set.
  3. 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:

  1. Decorativearia-hidden="true". Most chrome icons are decorative.
  2. Meaningfulrole="img" + accessible label via aria-label.
  3. Icon-only buttonaria-label on the button, aria-hidden on the icon.
  4. 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 valuesbg-[#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):

  1. Primitive — raw values (--color-blue-500).
  2. Semantic — role-based aliases (--color-primary → primitive).
  3. 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:

  1. Class-based<html class="dark">. Works with shadcn out of the box. User can override system preference.
  2. 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

  1. Hard-coding hex colours in components → tokens exist; use them.
  2. Forgetting aria-hidden on decorative icons → screen-reader noise.
  3. Combining many icon libraries → bundle bloat and visual chaos.
  4. Skipping tailwind-mergepx-4 px-2 survives, breaking expectations.
  5. Theme toggle without inline-boot script → FOUC on first paint.
  6. Arbitrary values everywhere instead of token discipline.
  7. Container queries without container-type@container silently 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 @theme once.
  • A shared token layer makes shadcn and Blueprint coexist peacefully.
  • Use cva + tailwind-merge to keep variants typed and conflict-free.
  • Container queries replace most viewport-based responsive design for components.

🔗 Further Reading

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.

Topics in this chapter (9)
  1. 8.1 The icon-library landscape (2026)
  2. 8.2 SVG delivery strategies
  3. 8.3 Icon accessibility
  4. 8.4 Tailwind CSS v4 with Vite
  5. 8.5 CSS variables and design tokens
  6. 8.6 Custom utility classes and @theme extensions
  7. 8.7 tailwind-merge, clsx, cva
  8. 8.8 Dark mode strategies
  9. 8.9 Container queries