Build a Design System with Next.js
Build a multi-page app that teaches design tokens and Next.js routing.
Introduction
30 Second Summary
Every app you use daily has a consistent look. The buttons are always the same shade, the spacing always feels right, and dark mode works everywhere at once.
In this project, you will build a Design System Explorer. It is a multi-page website that teaches you how professional design systems work and how Next.js apps are structured, all at the same time.
What You'll Build
A running Next.js app with 3 pages, each showcasing a different layer of how design decisions flow from raw color values through meaningful names into reusable components.
By the end of this project, you'll have:
- A 4-tier design token system defined with Tailwind CSS v4 and CSS custom properties.
- A multi-page Next.js app with a shared navigation layout and file-based routing.
- A working dark/light mode toggle that proves theming is just swapping token values at one layer.
- Secret Mission: Re-skin the entire app by changing only token values, simulating a real design handoff scenario.
Are there any prerequisites?
You need basic HTML knowledge (tags like div, h1, p, button). CSS knowledge is NOT required.
This project teaches CSS concepts through Tailwind utility classes, so you will learn styling as you go.
Before We Start
Before We Start...
Create Your Next.js Project
Every Next.js app starts with a scaffold. A scaffold is a pre-built folder structure that gives you all the configuration files, dependencies, and boilerplate code so you can focus on building features instead of wiring things together.
In this step, you will scaffold a new Next.js project with TypeScript and Tailwind CSS v4 pre-configured. You will explore the generated files to understand what each one does, then start the dev server and confirm everything works.
In this step, get ready to:
- Scaffold a Next.js project with create-next-app.
- Explore the generated file structure and understand what each file does.
- Start the dev server and confirm hot reload works.
Scaffold a new Next.js project
Next.js provides a command-line tool called create-next-app that generates a complete project in seconds. The --yes flag accepts all defaults, which gives you TypeScript, Tailwind CSS v4, ESLint, and the App Router out of the box.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your project by running this command:
npx create-next-app@latest design-system-explorer --yes
What does this command do?
- npx runs a package directly from the npm registry without installing it globally.
- create-next-app@latest fetches the newest version of the Next.js scaffolding tool.
- design-system-explorer is the folder name for your project.
- --yes accepts all default options: TypeScript, Tailwind v4, ESLint, App Router, and Turbopack.
- Wait for the installation to finish. You should see a success message and a list of available commands.
- Move into your new project folder by running:
cd design-system-explorer
- Confirm the project files exist by running:
ls
- You should see files like package.json, next.config.ts, tsconfig.json, and an app/ folder in the output.
Seeing 'command not found' or a network error?
- Make sure Node.js is installed by running node --version. You need version 20.9 or higher for Next.js 15.
- If you see a network error, check your internet connection. The command needs to download packages from npm.
Why can't I scaffold my Next.js project?
Now open the project in VS Code.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the design-system-explorer folder, then click Open.
Explore the generated folder structure
The App Router is Next.js's file-based routing system. Instead of configuring routes manually, you create folders and files inside the app/ directory. Each folder automatically becomes a URL route.
Look at the Explorer sidebar in VS Code. Here is what each key file and folder does:
- app/layout.tsx is the root layout. It wraps every page in your app and must contain the <html> and <body> tags.
- app/page.tsx is the home page. It renders when someone visits the root URL (/).
- app/globals.css is where your global styles live. Right now it imports Tailwind CSS.
- public/ holds static assets like images and icons. Files here are served at the root URL.
- next.config.ts is the Next.js configuration file. You won't need to change it in this project.
- package.json lists your project dependencies and scripts like npm run dev.
- tsconfig.json configures TypeScript. The defaults work perfectly for this project.
How does routing work in Next.js?
Folders inside app/ become URL routes automatically. If you create a folder called app/tokens/ and put a page.tsx inside it, that page is accessible at /tokens in the browser.
You will use this pattern to create your Tokens and Components pages later.
The default scaffold includes some extra font configuration and styles that you do not need. Let's clean up two files to prepare for your own design system.
- Open app/globals.css in VS Code by clicking on it in the Explorer sidebar.
- Select all the content in the file and replace it with this single line:
@import "tailwindcss";
What does this line do?
In Tailwind CSS v4, a single @import "tailwindcss" line replaces the old @tailwind base directives. It pulls in all of Tailwind's utility classes. You will add your own design tokens below this line in the next step.
- Save app/globals.css.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
@import "tailwindcss";
Next, clean up the layout file. The default includes Google Font imports that you will replace with your own font tokens later.
- Open app/layout.tsx in the Explorer sidebar.
- Select all the content and replace it with this simplified layout:
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
What does this code do?
- import "./globals.css" loads your global stylesheet on every page.
- metadata sets the page title and description that appear in browser tabs and search results.
- RootLayout wraps every page in your app. The {children} placeholder is where page content gets inserted.
You removed the Geist font imports because you will define your own font tokens in the next step.
- Save app/layout.tsx.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Seeing red underlines in VS Code?
TypeScript might show temporary errors while your editor loads the project for the first time. Give it 10-15 seconds to finish indexing. If errors persist, try closing and reopening the file.
Help me fix TypeScript errors in my layout file.
Start the dev server and confirm hot reload
Next.js includes a development server that watches your files and refreshes the browser automatically when you save changes. This is called hot reload.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, usually above Tab).
- Start the development server by running:
npm run dev
What does this command do?
npm run dev starts a local web server on your machine. It compiles your TypeScript and Tailwind code on the fly, so you can see changes instantly without restarting.
- You should see output mentioning Local: http://localhost:3000 in the terminal.
- Open your browser and go to http://localhost:3000.
You should see the default Next.js starter page. Now let's replace it with your own content to confirm hot reload is working.
- Back in VS Code, open app/page.tsx from the Explorer sidebar.
- Select all the content and replace it with this code:
export default function HomePage() {
return (
<main>
<h1>Design System Explorer</h1>
</main>
);
}
What does this code do?
- export default function HomePage() defines a React component that Next.js renders for the home page.
- The <main> and <h1> tags are standard HTML elements. In Next.js, you write HTML-like syntax called JSX directly inside your functions.
- Save app/page.tsx.
- Switch back to your browser. You should see the text Design System Explorer appear on the page without needing to refresh manually.
Don't see 'Design System Explorer' in the browser?
- Make sure you saved the file. Press Cmd+S (macOS) or Ctrl+S (Windows).
- Check that your terminal still shows the dev server running. If you see an error, stop it with Ctrl+C and run npm run dev again.
- Make sure the browser is pointing to http://localhost:3000 and not a different URL.
My page isn't updating when I save. Help me debug.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
export default function HomePage() {
return (
<main>
<h1>Design System Explorer</h1>
</main>
);
}
Your project is scaffolded, your files are clean, and hot reload is working. Next up, you will define your 4-tier design token system and see how Tailwind v4 turns those tokens into utility classes automatically.
Define Your 4-Tier Token System
Your Next.js project is scaffolded and running. Now it is time to give your app a visual language.
Professional design systems organize colors, fonts, and spacing into named design tokens instead of scattering raw hex codes across components. In this step, you will build a complete 4-tier token system using Tailwind CSS v4's @theme directive and CSS custom properties.
In this step, get ready to:
- Define color and font Primitive tokens using Tailwind v4's @theme directive.
- Create Semantic and Component tokens that assign meaning to your primitives.
- Set up a dark mode class that swaps token values for an alternate theme.
Learn the 4-tier token hierarchy
A design token is a named value that represents a design decision. Instead of writing #2563eb directly in your components, you give it a name like --accent. Names are easier to remember, reuse, and change later.
Your token system has 4 tiers, each building on the one below it:
- Tier 1: Primitive tokens are raw values with no meaning attached. A hex color like #3b82f6 or a font name like "Inter" lives here.
- Tier 2: Base tokens give primitives a brand identity. #3b82f6 becomes brand-500.
- Tier 3: Semantic tokens assign purpose. brand-600 becomes --accent because it is used for interactive elements. These swap when you change themes.
- Tier 4: Component tokens scope decisions to specific UI elements. --accent becomes --button-bg for fine-grained control.
Why 4 tiers instead of fewer?
Most mature design systems (Salesforce Lightning, Adobe Spectrum) use 3 tiers. The 4th tier (Component) adds a level of control that lets you override one button's color without breaking the entire theme.
For daily work, the Semantic tier is the most important. Components reference semantic tokens, and themes only need to swap values at that layer.
Why use @theme and :root together?
Tailwind v4's @theme block automatically generates utility classes from your tokens. A variable named --color-brand-500 becomes the class bg-brand-500 without any extra config.
Semantic and Component tokens go in :root instead because they are dynamic. They swap values when dark mode is active, and Tailwind's @theme is for static definitions only.
Add your Primitive and Base tokens
Your globals.css file currently contains only the Tailwind import. You will add your token system below that import.
- Open app/globals.css in VS Code.
- Find the @import "tailwindcss"; line at the top. Add the following @theme block below it:
@theme {
/* Primitive: brand color palette (blue scale) */
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-200: #bfdbfe;
--color-brand-300: #93c5fd;
--color-brand-400: #60a5fa;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-800: #1e40af;
--color-brand-900: #1e3a8a;
/* Primitive: neutral color palette (gray scale) */
--color-neutral-50: #f9fafb;
--color-neutral-100: #f3f4f6;
--color-neutral-200: #e5e7eb;
--color-neutral-300: #d1d5db;
--color-neutral-400: #9ca3af;
--color-neutral-500: #6b7280;
--color-neutral-600: #4b5563;
--color-neutral-700: #374151;
--color-neutral-800: #1f2937;
--color-neutral-900: #111827;
/* Base: font family tokens */
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", monospace;
}
What does this code do?
- --color-brand-50 through --color-brand-900 defines a 10-shade blue palette. Lower numbers are lighter, higher numbers are darker.
- --color-neutral-50 through --color-neutral-900 defines a gray palette for backgrounds, text, and borders.
- --font-sans and --font-mono define your app's typeface options. Tailwind turns these into font-sans and font-mono utility classes automatically.
- Because these are inside @theme, Tailwind generates utility classes like bg-brand-500, text-neutral-700, and border-brand-200 for you.
- Save globals.css.
- Check your terminal where npm run dev is running. You should see no errors. The page at http://localhost:3000 will still look the same because you have not applied any tokens to components yet.
Seeing an error in your terminal?
- Make sure the @import "tailwindcss"; line is still at the very top of the file. The @theme block goes below it.
- Check for missing semicolons after each color value (every line inside @theme ends with a semicolon).
- Verify the closing } brace for the @theme block is present.
Why is my @theme block causing errors in Tailwind v4?
Add Semantic and Component tokens
Primitives are just raw values. The next tiers assign meaning to those values so your components never depend on a specific shade of blue directly.
- In app/globals.css, add the following :root block below the closing } of your @theme block:
:root {
/* Semantic tokens: purpose-based, theme-aware */
--background: var(--color-neutral-50);
--surface: #ffffff;
--text-primary: var(--color-neutral-900);
--text-secondary: var(--color-neutral-500);
--border: var(--color-neutral-200);
--accent: var(--color-brand-600);
--accent-hover: var(--color-brand-700);
/* Component tokens: scoped to specific UI elements */
--button-bg: var(--accent);
--button-text: #ffffff;
--button-hover-bg: var(--accent-hover);
--card-bg: var(--surface);
--card-border: var(--border);
}
What does this code do?
- Semantic tokens like --background and --accent reference your Primitive tokens using var(). They describe PURPOSE ("this is the page background") not appearance ("this is light gray").
- Component tokens like --button-bg reference the Semantic tokens above them. This creates a chain: a button reads --button-bg, which points to --accent, which points to --color-brand-600.
- The chain means you can change one value at the Semantic layer and every component that references it updates automatically.
- Save globals.css.
- Check your terminal for errors. You won't see a visual change yet because no component uses these variables.
Seeing "undefined variable" warnings?
- Check that var(--color-neutral-50) in :root matches the exact variable name you defined in @theme. A typo like --color-nuetral-50 (misspelled "neutral") will silently fail.
- Make sure the :root block is OUTSIDE the @theme block, not nested inside it.
Help me fix my CSS variable references in globals.css
Now add the dark mode swap and body styles. The .dark class redefines the same Semantic and Component variable names with different values. When this class is applied to the <html> element, every component that reads those variables automatically renders with dark colors.
- Add the following .dark class and body rule below your :root block:
.dark {
/* Dark mode: swap semantic values to darker alternatives */
--background: var(--color-neutral-900);
--surface: var(--color-neutral-800);
--text-primary: var(--color-neutral-50);
--text-secondary: var(--color-neutral-400);
--border: var(--color-neutral-700);
--accent: var(--color-brand-400);
--accent-hover: var(--color-brand-300);
/* Dark mode: component tokens follow the new semantic values */
--button-bg: var(--accent);
--button-text: var(--color-neutral-900);
--button-hover-bg: var(--accent-hover);
--card-bg: var(--surface);
--card-border: var(--border);
}
body {
/* Apply the semantic tokens to the page */
background-color: var(--background);
color: var(--text-primary);
font-family: var(--font-sans);
}
What does this code do?
- The .dark class redefines every Semantic and Component variable. Notice it uses the SAME variable names as :root but with different Primitive references. That is the entire dark mode trick.
- The body rule applies your tokens to the page. background-color: var(--background) means "use whatever --background currently resolves to." In light mode that is a light gray. In dark mode that becomes near-black.
- Save globals.css and check your browser at http://localhost:3000.
- You should see the page background change from white to a very light gray (neutral-50). The "Design System Explorer" text should also appear darker and crisper because it now uses --text-primary.
Page still looks the same?
- Make sure the body rule is outside and below the .dark block. If it is inside .dark, it only applies in dark mode.
- Try a hard refresh: press Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows). Cached styles can delay the update.
- Confirm your file still starts with @import "tailwindcss"; on line 1. Without the import, Tailwind will not process your tokens.
Why isn't my body background changing after adding CSS variables?
✔️ Awesome, I've got everything!
Great. Double check you have saved app/globals.css and your page background shows the light gray color.
ⓧ I'd like to double check the full code
Here is the complete app/globals.css file. Make sure yours matches exactly:
@import "tailwindcss";
@theme {
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-200: #bfdbfe;
--color-brand-300: #93c5fd;
--color-brand-400: #60a5fa;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-800: #1e40af;
--color-brand-900: #1e3a8a;
--color-neutral-50: #f9fafb;
--color-neutral-100: #f3f4f6;
--color-neutral-200: #e5e7eb;
--color-neutral-300: #d1d5db;
--color-neutral-400: #9ca3af;
--color-neutral-500: #6b7280;
--color-neutral-600: #4b5563;
--color-neutral-700: #374151;
--color-neutral-800: #1f2937;
--color-neutral-900: #111827;
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", monospace;
}
:root {
--background: var(--color-neutral-50);
--surface: #ffffff;
--text-primary: var(--color-neutral-900);
--text-secondary: var(--color-neutral-500);
--border: var(--color-neutral-200);
--accent: var(--color-brand-600);
--accent-hover: var(--color-brand-700);
--button-bg: var(--accent);
--button-text: #ffffff;
--button-hover-bg: var(--accent-hover);
--card-bg: var(--surface);
--card-border: var(--border);
}
.dark {
--background: var(--color-neutral-900);
--surface: var(--color-neutral-800);
--text-primary: var(--color-neutral-50);
--text-secondary: var(--color-neutral-400);
--border: var(--color-neutral-700);
--accent: var(--color-brand-400);
--accent-hover: var(--color-brand-300);
--button-bg: var(--accent);
--button-text: var(--color-neutral-900);
--button-hover-bg: var(--accent-hover);
--card-bg: var(--surface);
--card-border: var(--border);
}
body {
background-color: var(--background);
color: var(--text-primary);
font-family: var(--font-sans);
}
Your entire token system is in place. Every color, font, and component-level decision lives in one CSS file. Next up, you will build the layout and navigation that uses these tokens to style a real multi-page app.
Build the Layout and Navigation
Your token system is defined and ready to use. Now you need a structure that lets you see it in action across multiple pages.
In Next.js, the app/layout.tsx file wraps every page in your app. It is the shell that stays constant while the page content changes. You will build a layout with a sidebar navigation, then create three pages that use your token system.
In this step, get ready to:
- Build a shared layout with a sidebar navigation that links to three pages.
- Create the Home page with welcome content styled using your semantic tokens.
- Add placeholder pages for the Tokens and Components routes.
Build the layout with sidebar navigation
The layout file is the outermost wrapper of your app. Every page you create will render inside it. Your layout will have a sidebar with navigation links and a main content area where pages appear.
Before building the layout, you need to create a component it depends on: a theme toggle button. This lives in a private folder that Next.js will not turn into a URL route.
- In the VS Code Explorer sidebar, right-click on the app/ folder.
- Click New Folder and name it _components.
Why the underscore prefix?
In the Next.js App Router, any folder inside app/ that starts with an underscore is "private." Next.js will never treat it as a URL route. This lets you keep components close to where they are used without accidentally creating pages.
- Inside app/_components/, create a new file called ThemeToggle.tsx and paste the following code:
"use client";
import { useState, useEffect } from "react";
export function ThemeToggle() {
// Track whether dark mode is active
const [isDark, setIsDark] = useState(false);
// On first load, check if the user previously chose dark mode
useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "dark") {
setIsDark(true);
document.documentElement.classList.add("dark");
}
}, []);
// Toggle between light and dark by adding/removing the .dark class
function toggle() {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
}
return (
<button
onClick={toggle}
className="w-full px-3 py-2 rounded-md border text-sm font-medium cursor-pointer transition-colors"
style={{
borderColor: "var(--border)",
color: "var(--text-primary)",
backgroundColor: "var(--surface)",
}}
>
{isDark ? "Switch to Light" : "Switch to Dark"}
</button>
);
}
What does this code do?
The "use client" directive at the top tells Next.js this component runs in the browser. It needs browser APIs like localStorage and document, which do not exist on the server.
The toggle function adds or removes a dark class on the <html> element. When that class is present, all your .dark { ... } CSS variables from the previous step activate. The button styles itself using your semantic tokens, so it automatically adapts to the current theme.
- Save app/_components/ThemeToggle.tsx.
Now you are ready to build the layout that uses this component.
- Open app/layout.tsx and replace its entire contents with the following code:
import "./globals.css";
import Link from "next/link";
import { ThemeToggle } from "./_components/ThemeToggle";
// Metadata for the browser tab title and description
export const metadata = {
title: "Design System Explorer",
description: "Learn design tokens and Next.js structure",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="min-h-screen flex">
{/* Sidebar navigation */}
<nav className="w-64 border-r p-6 flex flex-col gap-6" style={{ borderColor: "var(--border)", backgroundColor: "var(--surface)" }}>
<div>
<h1 className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>
DS Explorer
</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-secondary)" }}>
Learn design systems
</p>
</div>
<ul className="flex flex-col gap-2">
<li>
<Link
href="/"
className="block px-3 py-2 rounded-md text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-700"
style={{ color: "var(--text-primary)" }}
>
Home
</Link>
</li>
<li>
<Link
href="/tokens"
className="block px-3 py-2 rounded-md text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-700"
style={{ color: "var(--text-primary)" }}
>
Tokens
</Link>
</li>
<li>
<Link
href="/components"
className="block px-3 py-2 rounded-md text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-700"
style={{ color: "var(--text-primary)" }}
>
Components
</Link>
</li>
</ul>
{/* Theme toggle pinned to the bottom of the sidebar */}
<div className="mt-auto">
<ThemeToggle />
</div>
</nav>
{/* Main content area where each page renders */}
<main className="flex-1 p-8">
{children}
</main>
</body>
</html>
);
}
What does this layout do?
- It imports Link from next/link for client-side navigation between pages without full page reloads.
- The <nav> sidebar uses your semantic tokens via inline style props. It references var(--border) and var(--surface) so it adapts to dark mode automatically.
- The {children} prop is where Next.js injects whatever page the user navigates to. This is how layouts wrap pages.
- The mt-auto class on the ThemeToggle wrapper pushes it to the bottom of the sidebar, keeping navigation links at the top.
- Save app/layout.tsx.
- Refresh your browser at http://localhost:3000. You should see a white sidebar on the left with "DS Explorer" at the top, three navigation links (Home, Tokens, Components), and a "Switch to Dark" button at the bottom.
Seeing an error in your browser?
- If you see "Module not found: Can't resolve './_components/ThemeToggle'", make sure the folder is named exactly _components (with the underscore) and the file is named ThemeToggle.tsx (capital T, capital T).
- If the page is blank or unstyled, confirm you kept the import "./globals.css" line at the top of layout.tsx.
Still stuck? Help me debug my layout.tsx error.
✔️ Awesome, I've got the sidebar!
Great. Make sure you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete ThemeToggle.tsx:
"use client";
import { useState, useEffect } from "react";
export function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "dark") {
setIsDark(true);
document.documentElement.classList.add("dark");
}
}, []);
function toggle() {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
}
return (
<button
onClick={toggle}
className="w-full px-3 py-2 rounded-md border text-sm font-medium cursor-pointer transition-colors"
style={{
borderColor: "var(--border)",
color: "var(--text-primary)",
backgroundColor: "var(--surface)",
}}
>
{isDark ? "Switch to Light" : "Switch to Dark"}
</button>
);
}
And here is the complete layout.tsx:
import "./globals.css";
import Link from "next/link";
import { ThemeToggle } from "./_components/ThemeToggle";
export const metadata = {
title: "Design System Explorer",
description: "Learn design tokens and Next.js structure",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="min-h-screen flex">
<nav className="w-64 border-r p-6 flex flex-col gap-6" style={{ borderColor: "var(--border)", backgroundColor: "var(--surface)" }}>
<div>
<h1 className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>
DS Explorer
</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-secondary)" }}>
Learn design systems
</p>
</div>
<ul className="flex flex-col gap-2">
<li>
<Link
href="/"
className="block px-3 py-2 rounded-md text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-700"
style={{ color: "var(--text-primary)" }}
>
Home
</Link>
</li>
<li>
<Link
href="/tokens"
className="block px-3 py-2 rounded-md text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-700"
style={{ color: "var(--text-primary)" }}
>
Tokens
</Link>
</li>
<li>
<Link
href="/components"
className="block px-3 py-2 rounded-md text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-700"
style={{ color: "var(--text-primary)" }}
>
Components
</Link>
</li>
</ul>
<div className="mt-auto">
<ThemeToggle />
</div>
</nav>
<main className="flex-1 p-8">
{children}
</main>
</body>
</html>
);
}
Create the Home page with token-based styling
Your layout is rendering the old placeholder from the previous step. Time to replace it with a proper Home page that uses your semantic tokens for styling.
- Open app/page.tsx and replace its entire contents with the following code:
export default function HomePage() {
return (
<div className="max-w-2xl">
{/* Page title styled with the text-primary semantic token */}
<h1 className="text-3xl font-bold mb-4" style={{ color: "var(--text-primary)" }}>
Design System Explorer
</h1>
{/* Subtitle using the text-secondary token for softer contrast */}
<p className="text-lg mb-6" style={{ color: "var(--text-secondary)" }}>
Welcome! This app teaches you how professional design systems work by showing you each layer in action.
</p>
{/* Info cards using component-level tokens for background and border */}
<div className="grid gap-4">
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}>
<h2 className="font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Tokens Page
</h2>
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
See the 4-tier token hierarchy: Primitives, Base, Semantic, and Component tokens visualized.
</p>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}>
<h2 className="font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Components Page
</h2>
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
See how Button and Card components consume tokens to stay consistent across themes.
</p>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}>
<h2 className="font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Dark Mode Toggle
</h2>
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
Use the toggle in the sidebar to switch themes. Watch how all tokens swap without changing any component code.
</p>
</div>
</div>
</div>
);
}
What does this code do?
The Home page uses inline style props to reference your CSS variables directly. For example, var(--card-bg) points to your component-level token, which in turn references var(--surface).
This chain means every card will automatically change color when dark mode activates. You did not write any dark-mode-specific code on this page. The token system handles it.
- Save app/page.tsx.
- Refresh your browser. You should see a heading "Design System Explorer", a subtitle in lighter gray, and three info cards with borders stacked vertically.
Cards not showing borders or background?
- Check that your globals.css still has the :root block with --card-bg and --card-border defined. If those variables are missing, the inline styles have nothing to reference.
- Make sure you copied the full style={{ }} props on each card div. Double curly braces are required in JSX for inline style objects.
Need help? Help me fix my Home page styling.
✔️ Awesome, I see the cards!
Great. Double check your file is saved before continuing.
ⓧ I'd like to double check the full code
export default function HomePage() {
return (
<div className="max-w-2xl">
<h1 className="text-3xl font-bold mb-4" style={{ color: "var(--text-primary)" }}>
Design System Explorer
</h1>
<p className="text-lg mb-6" style={{ color: "var(--text-secondary)" }}>
Welcome! This app teaches you how professional design systems work by showing you each layer in action.
</p>
<div className="grid gap-4">
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}>
<h2 className="font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Tokens Page
</h2>
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
See the 4-tier token hierarchy: Primitives, Base, Semantic, and Component tokens visualized.
</p>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}>
<h2 className="font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Components Page
</h2>
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
See how Button and Card components consume tokens to stay consistent across themes.
</p>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}>
<h2 className="font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Dark Mode Toggle
</h2>
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
Use the toggle in the sidebar to switch themes. Watch how all tokens swap without changing any component code.
</p>
</div>
</div>
</div>
);
}
Create the Tokens and Components route pages
Your sidebar has links to /tokens and /components, but those routes do not exist yet. Clicking them would show a 404 page.
In the App Router, creating a route is as simple as adding a folder with a page.tsx file inside it. The folder name becomes the URL path.
- In the VS Code Explorer sidebar, right-click on the app/ folder.
- Click New Folder and name it tokens.
- Inside app/tokens/, create a new file called page.tsx and paste the following code:
export default function TokensPage() {
return (
<div>
<h1>Tokens</h1>
<p>Coming soon.</p>
</div>
);
}
- Save app/tokens/page.tsx.
- Now repeat the same process for the Components route. Right-click on app/ and create a new folder called components.
- Inside app/components/, create a new file called page.tsx and paste the following code:
export default function ComponentsPage() {
return (
<div>
<h1>Components</h1>
<p>Coming soon.</p>
</div>
);
}
How does routing work here?
The app/tokens/page.tsx file makes the /tokens URL accessible. The app/components/page.tsx file makes /components accessible. No configuration or router setup is needed.
Notice that _components (with the underscore) is NOT a route, but components (without the underscore) IS. The underscore is what keeps a folder private.
- Save app/components/page.tsx.
- In your browser, click the Tokens link in the sidebar. You should see "Tokens" and "Coming soon." in the main content area.
- Click the Components link. You should see "Components" and "Coming soon." in the main content area.
- Click the Home link to return to the welcome page with the cards.
Seeing a 404 when you click a link?
- Make sure each folder is named exactly tokens and components (lowercase, no typos). The folder name must match the href in your layout links.
- Confirm the file inside each folder is named exactly page.tsx. Next.js only recognizes page.tsx as the route handler. A file named index.tsx or Page.tsx will not work.
Still not working? Help me fix my Next.js routing.
Notice that the sidebar stays visible on every page. That is the layout in action. Your page content swaps inside the <main> area while the nav remains constant.
Your multi-page app structure is now complete. Next up, you will fill in the Tokens page with a visual showcase of every tier in your design token system.
Build the Tokens Showcase Page
Your layout, navigation, and token system are all in place. Now it is time to build the page that actually shows those tokens visually.
The Tokens page is like a style guide for your design system. It will display every tier of your token hierarchy as interactive color swatches, purpose cards, and code references so you can see how values flow from raw hex codes to component-level decisions.
In this step, get ready to:
- Build a Primitives section showing your brand and neutral color palettes as swatches.
- Add a Semantic Tokens section displaying purpose-based token cards.
- Add a Component Tokens section showing how tokens scope to specific UI elements.
Display your primitive color palette
Primitives are the bottom tier of a design system. They are raw values with no meaning attached. A color like #3b82f6 is just a blue shade until you assign it a name like brand-500.
Your Tailwind CSS @theme block already created utility classes like bg-brand-500 from your token definitions. Now you will display those as visible swatches on the page.
- Open app/tokens/page.tsx in VS Code.
- Replace the entire file contents with the following code:
export default function TokensPage() {
// Data arrays mapping token names to their Tailwind utility classes
const primitiveColors = [
{ name: "brand-50", class: "bg-brand-50" },
{ name: "brand-100", class: "bg-brand-100" },
{ name: "brand-200", class: "bg-brand-200" },
{ name: "brand-300", class: "bg-brand-300" },
{ name: "brand-400", class: "bg-brand-400" },
{ name: "brand-500", class: "bg-brand-500" },
{ name: "brand-600", class: "bg-brand-600" },
{ name: "brand-700", class: "bg-brand-700" },
{ name: "brand-800", class: "bg-brand-800" },
{ name: "brand-900", class: "bg-brand-900" },
];
const neutralColors = [
{ name: "neutral-50", class: "bg-neutral-50" },
{ name: "neutral-100", class: "bg-neutral-100" },
{ name: "neutral-200", class: "bg-neutral-200" },
{ name: "neutral-300", class: "bg-neutral-300" },
{ name: "neutral-400", class: "bg-neutral-400" },
{ name: "neutral-500", class: "bg-neutral-500" },
{ name: "neutral-600", class: "bg-neutral-600" },
{ name: "neutral-700", class: "bg-neutral-700" },
{ name: "neutral-800", class: "bg-neutral-800" },
{ name: "neutral-900", class: "bg-neutral-900" },
];
return (
<div className="max-w-4xl">
<h1 className="text-3xl font-bold mb-2" style={{ color: "var(--text-primary)" }}>
Design Tokens
</h1>
<p className="text-lg mb-8" style={{ color: "var(--text-secondary)" }}>
Tokens are named design decisions. They are organized in tiers, from raw values to component-specific settings.
</p>
{/* Tier 1: Primitives - raw color values displayed as swatches */}
<section className="mb-12">
<h2 className="text-xl font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Tier 1: Primitives
</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
Raw values with no meaning attached. These are your color palette, font sizes, and spacing scale.
</p>
<div className="mb-4">
<h3 className="text-sm font-medium mb-2" style={{ color: "var(--text-primary)" }}>Brand Colors</h3>
<div className="flex gap-2">
{primitiveColors.map((color) => (
<div key={color.name} className="flex flex-col items-center gap-1">
<div className={`w-12 h-12 rounded-md border ${color.class}`} style={{ borderColor: "var(--border)" }}></div>
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>{color.name.split("-")[1]}</span>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-2" style={{ color: "var(--text-primary)" }}>Neutral Colors</h3>
<div className="flex gap-2">
{neutralColors.map((color) => (
<div key={color.name} className="flex flex-col items-center gap-1">
<div className={`w-12 h-12 rounded-md border ${color.class}`} style={{ borderColor: "var(--border)" }}></div>
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>{color.name.split("-")[1]}</span>
</div>
))}
</div>
</div>
</section>
</div>
);
}
What does this code do?
The two arrays (primitiveColors and neutralColors) map each token name to its Tailwind utility class. Because you defined --color-brand-500 in your @theme block, Tailwind automatically generated bg-brand-500 as a usable class.
The .map() function loops through each array and renders a colored 12x12 box using that utility class. The label below each swatch shows just the number (50, 100, 200, etc.) by splitting the name on the hyphen.
- Save app/tokens/page.tsx.
- Visit http://localhost:3000/tokens in your browser. You should see the heading "Design Tokens" followed by two rows of colored squares. The brand row shows a gradient from light blue to dark blue. The neutral row shows a gradient from near-white to near-black.
Don't see colored squares?
If your squares appear white or transparent, make sure the class values in your arrays match exactly (e.g., bg-brand-50 not bg-color-brand-50). The Tailwind utility class drops the color- prefix that exists in your CSS.
Also check that your dev server is still running. If you see a connection error, run npm run dev again in your terminal.
Why aren't my Tailwind color swatches showing up?
Add the base and semantic tokens section
Primitives are useful as a palette, but components should never use them directly. Semantic tokens add purpose to raw values. A semantic token like --background says "this is the page background" regardless of whether it resolves to white or dark gray.
- In app/tokens/page.tsx, find the closing </section> tag of the Tier 1 section (the line just before </div> and the closing );).
- Add the following section between that </section> and the closing </div>:
{/* Tier 2 and 3: Base and Semantic tokens with purpose-based labels */}
<section className="mb-12">
<h2 className="text-xl font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Tier 2 and 3: Base and Semantic Tokens
</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
Base tokens give primitives a brand name (brand-600 becomes "brand-primary"). Semantic tokens assign purpose: "this is the background color" or "this is the accent color". These change with themes.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--background)", borderColor: "var(--border)" }}>
<div className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>--background</div>
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>The page background. Changes in dark mode.</div>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--surface)", borderColor: "var(--border)" }}>
<div className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>--surface</div>
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>Elevated surfaces like cards and panels.</div>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--border)" }}>
<div className="text-sm font-medium" style={{ color: "var(--accent)" }}>--accent</div>
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>Interactive/brand color for buttons and links.</div>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--border)" }}>
<div className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>--text-primary</div>
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>Main body text color. High contrast.</div>
</div>
</div>
</section>
What does this code do?
Each card in the 2-column grid represents one semantic token. The card's background is set using that token's CSS variable, so the card literally demonstrates its own value.
The --accent card uses the accent color for its label text, which makes it visually obvious what "accent" means in your system. Toggle dark mode later to watch these cards change appearance automatically.
- Save app/tokens/page.tsx and check your browser.
- Below the color swatches, you should now see four cards in a 2-column grid labelled --background, --surface, --accent, and --text-primary.
Cards not appearing?
Check that you placed the new <section> block inside the <div className="max-w-4xl"> wrapper, not outside it. If you see a red error overlay in the browser, look at the terminal for a JSX syntax error pointing to an unclosed tag.
Why is my tokens page showing a syntax error after adding the semantic section?
Add the component tokens section
The top tier of the token system scopes decisions to individual UI elements. A button does not read --accent directly. It reads --button-bg, which happens to reference --accent. This extra indirection means you can override a single component without breaking anything else.
- In app/tokens/page.tsx, find the closing </section> tag of the Tier 2 and 3 section you just added.
- Add the following section between that </section> and the closing </div>:
{/* Tier 4: Component tokens scoped to specific UI elements */}
<section>
<h2 className="text-xl font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Tier 4: Component Tokens
</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
Scoped to specific UI elements. A button has its own --button-bg token that references the semantic --accent token. This extra layer gives you fine-grained control without breaking the system.
</p>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}>
<code className="text-sm font-mono block mb-2" style={{ color: "var(--accent)" }}>
--button-bg: var(--accent)
</code>
<code className="text-sm font-mono block mb-2" style={{ color: "var(--accent)" }}>
--button-text: #ffffff
</code>
<code className="text-sm font-mono block mb-2" style={{ color: "var(--accent)" }}>
--card-bg: var(--surface)
</code>
<code className="text-sm font-mono block" style={{ color: "var(--accent)" }}>
--card-border: var(--border)
</code>
</div>
</section>
What does this code do?
This section displays your component token declarations in a styled code block. Each line shows how a component token (like --button-bg) references a semantic token (like --accent).
This is the chain in action: a primitive hex code becomes a brand color, which becomes an accent, which becomes a button background. Change the primitive and everything downstream updates.
- Save app/tokens/page.tsx and check your browser.
- At the bottom of the page, you should see a card with four lines of code showing your component token declarations in the accent color.
Seeing a blank page or error?
If the entire page goes blank, open your browser DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for a JSX error. The most common cause is a missing closing tag or a misplaced curly brace.
My tokens page crashed after adding the component tokens section. How do I fix it?
Test dark mode on this page
Click the Switch to Dark button in the sidebar. Watch how the semantic token cards change background colors instantly. The Primitives swatches stay the same because raw values do not change with themes. Only semantic and component tokens swap.
✔️ Awesome, I've got everything!
Make sure you have saved app/tokens/page.tsx and that all three sections (Primitives, Base and Semantic, Component) are visible at http://localhost:3000/tokens.
ⓧ I'd like to double check the full code
Here is the complete app/tokens/page.tsx file. Compare it against yours:
export default function TokensPage() {
const primitiveColors = [
{ name: "brand-50", class: "bg-brand-50" },
{ name: "brand-100", class: "bg-brand-100" },
{ name: "brand-200", class: "bg-brand-200" },
{ name: "brand-300", class: "bg-brand-300" },
{ name: "brand-400", class: "bg-brand-400" },
{ name: "brand-500", class: "bg-brand-500" },
{ name: "brand-600", class: "bg-brand-600" },
{ name: "brand-700", class: "bg-brand-700" },
{ name: "brand-800", class: "bg-brand-800" },
{ name: "brand-900", class: "bg-brand-900" },
];
const neutralColors = [
{ name: "neutral-50", class: "bg-neutral-50" },
{ name: "neutral-100", class: "bg-neutral-100" },
{ name: "neutral-200", class: "bg-neutral-200" },
{ name: "neutral-300", class: "bg-neutral-300" },
{ name: "neutral-400", class: "bg-neutral-400" },
{ name: "neutral-500", class: "bg-neutral-500" },
{ name: "neutral-600", class: "bg-neutral-600" },
{ name: "neutral-700", class: "bg-neutral-700" },
{ name: "neutral-800", class: "bg-neutral-800" },
{ name: "neutral-900", class: "bg-neutral-900" },
];
return (
<div className="max-w-4xl">
<h1 className="text-3xl font-bold mb-2" style={{ color: "var(--text-primary)" }}>
Design Tokens
</h1>
<p className="text-lg mb-8" style={{ color: "var(--text-secondary)" }}>
Tokens are named design decisions. They are organized in tiers, from raw values to component-specific settings.
</p>
<section className="mb-12">
<h2 className="text-xl font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Tier 1: Primitives
</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
Raw values with no meaning attached. These are your color palette, font sizes, and spacing scale.
</p>
<div className="mb-4">
<h3 className="text-sm font-medium mb-2" style={{ color: "var(--text-primary)" }}>Brand Colors</h3>
<div className="flex gap-2">
{primitiveColors.map((color) => (
<div key={color.name} className="flex flex-col items-center gap-1">
<div className={`w-12 h-12 rounded-md border ${color.class}`} style={{ borderColor: "var(--border)" }}></div>
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>{color.name.split("-")[1]}</span>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-2" style={{ color: "var(--text-primary)" }}>Neutral Colors</h3>
<div className="flex gap-2">
{neutralColors.map((color) => (
<div key={color.name} className="flex flex-col items-center gap-1">
<div className={`w-12 h-12 rounded-md border ${color.class}`} style={{ borderColor: "var(--border)" }}></div>
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>{color.name.split("-")[1]}</span>
</div>
))}
</div>
</div>
</section>
<section className="mb-12">
<h2 className="text-xl font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Tier 2 and 3: Base and Semantic Tokens
</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
Base tokens give primitives a brand name (brand-600 becomes "brand-primary"). Semantic tokens assign purpose: "this is the background color" or "this is the accent color". These change with themes.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--background)", borderColor: "var(--border)" }}>
<div className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>--background</div>
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>The page background. Changes in dark mode.</div>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--surface)", borderColor: "var(--border)" }}>
<div className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>--surface</div>
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>Elevated surfaces like cards and panels.</div>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--border)" }}>
<div className="text-sm font-medium" style={{ color: "var(--accent)" }}>--accent</div>
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>Interactive/brand color for buttons and links.</div>
</div>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--border)" }}>
<div className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>--text-primary</div>
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>Main body text color. High contrast.</div>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold mb-1" style={{ color: "var(--text-primary)" }}>
Tier 4: Component Tokens
</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
Scoped to specific UI elements. A button has its own --button-bg token that references the semantic --accent token. This extra layer gives you fine-grained control without breaking the system.
</p>
<div className="p-4 rounded-lg border" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}>
<code className="text-sm font-mono block mb-2" style={{ color: "var(--accent)" }}>
--button-bg: var(--accent)
</code>
<code className="text-sm font-mono block mb-2" style={{ color: "var(--accent)" }}>
--button-text: #ffffff
</code>
<code className="text-sm font-mono block mb-2" style={{ color: "var(--accent)" }}>
--card-bg: var(--surface)
</code>
<code className="text-sm font-mono block" style={{ color: "var(--accent)" }}>
--card-border: var(--border)
</code>
</div>
</section>
</div>
);
}
Your Tokens page now visualizes the full hierarchy of a professional design system. Next up, you will build reusable Button and Card components that actually consume these tokens to prove the architecture works in practice.
Build Reusable Components
Your Tokens page visualizes every layer of your design system. Now it is time to put those tokens to work inside actual UI components.
In this step, you will create a Button and a Card component that consume your component-level tokens. These components never reference hardcoded colors. They read from --button-bg, --card-bg, and friends, which means they automatically adapt when you swap themes.
In this step, get ready to:
- Create a Button component that uses component tokens for its background, text, and hover colors.
- Create a Card component that uses card tokens for its background and border.
- Build a Components showcase page that renders your Button and Card in action.
Create the Button component
The whole point of component tokens is that individual UI elements get their own named values. Your --button-bg token already points to --accent, so the Button never needs to know which color "accent" actually is.
- In the VS Code Explorer sidebar, expand the app/_components/ folder (where ThemeToggle.tsx already lives).
- Right-click the _components folder and select New File.
- Name the file Button.tsx.
- Paste the following code into Button.tsx:
interface ButtonProps {
children: React.ReactNode;
size?: "sm" | "md" | "lg";
}
export function Button({ children, size = "md" }: ButtonProps) {
// Map each size to Tailwind padding and text classes
const sizeClasses = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
};
return (
<button
className={`rounded-md font-medium transition-colors cursor-pointer ${sizeClasses[size]}`}
style={{
backgroundColor: "var(--button-bg)",
color: "var(--button-text)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--button-hover-bg)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--button-bg)";
}}
>
{children}
</button>
);
}
What does this code do?
The ButtonProps interface defines what the component accepts: any content (children) and an optional size that defaults to "md".
The sizeClasses object maps each size to specific Tailwind padding and font-size classes. The inline styles pull colors directly from your component tokens (--button-bg, --button-text).
The onMouseEnter and onMouseLeave handlers swap the background to --button-hover-bg on hover. This approach works because Tailwind's hover: variants cannot read CSS variables from inline styles.
- Save Button.tsx.
- Check your terminal where npm run dev is running. You should see no errors.
You will not see the Button on screen yet. The Components page will render it in Substep 3.
Seeing a TypeScript error in the terminal?
- Make sure the file is named exactly Button.tsx (capital B, .tsx extension).
- Confirm the file is inside app/_components/, not app/components/. Those are two different folders.
- Check that all curly braces and parentheses are closed. A missing } will cause a parse error.
Why is my Button.tsx showing a compile error?
Create the Card component
Cards are one of the most common UI patterns. Your Card component will accept a title and description, then style itself using --card-bg and --card-border tokens.
- Right-click the _components folder again and select New File.
- Name the file Card.tsx.
- Paste the following code into Card.tsx:
interface CardProps {
title: string;
description: string;
}
export function Card({ title, description }: CardProps) {
return (
<div
className="p-6 rounded-lg border"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--card-border)",
}}
>
<h3 className="font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
{title}
</h3>
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
{description}
</p>
</div>
);
}
What does this code do?
The CardProps interface requires a title and description string. The component renders them inside a styled container.
The outer <div> uses --card-bg for its background and --card-border for its border color. The text elements use semantic tokens (--text-primary and --text-secondary). When the theme changes, all of these update automatically.
- Save Card.tsx.
- Check your terminal again. You should still see no errors.
Like the Button, the Card will not appear until you import it on a page in the next substep.
Seeing a compile error for Card.tsx?
- Make sure you have matching opening and closing curly braces for the style objects. Each style={{ }} uses double braces.
- Confirm the file extension is .tsx (not .ts). Files that return JSX need the .tsx extension.
Help me fix my Card.tsx compile error.
Why use a private _components folder?
In Next.js App Router, any folder inside app/ becomes a URL route by default. Prefixing a folder with an underscore (_components) tells Next.js to skip it for routing.
This lets you keep your component files organized inside app/ without accidentally creating routes like /Button or /Card.
Build the Components showcase page
Your components exist but no page uses them yet. The app/components/page.tsx file currently shows placeholder text. You will replace it with a showcase that imports and renders your Button and Card.
- Open app/components/page.tsx in VS Code.
- Select all the existing code and replace it with the following:
import { Button } from "../_components/Button";
import { Card } from "../_components/Card";
export default function ComponentsPage() {
return (
<div className="max-w-4xl">
<h1 className="text-3xl font-bold mb-2" style={{ color: "var(--text-primary)" }}>
Components
</h1>
<p className="text-lg mb-8" style={{ color: "var(--text-secondary)" }}>
Components consume tokens. They never use hardcoded colors. This means they automatically adapt to any theme.
</p>
{/* Button showcase section */}
<section className="mb-12">
<h2 className="text-xl font-semibold mb-4" style={{ color: "var(--text-primary)" }}>
Button
</h2>
<div className="flex flex-wrap gap-4 items-center">
<Button size="sm">Small Button</Button>
<Button size="md">Medium Button</Button>
<Button size="lg">Large Button</Button>
</div>
<p className="text-sm mt-4" style={{ color: "var(--text-secondary)" }}>
All three buttons use --button-bg and --button-text tokens. Toggle dark mode to see them adapt.
</p>
</section>
{/* Card showcase section */}
<section>
<h2 className="text-xl font-semibold mb-4" style={{ color: "var(--text-primary)" }}>
Card
</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card
title="Token Architecture"
description="Design tokens are organized in tiers. Primitives hold raw values, semantic tokens assign meaning, and component tokens scope decisions to specific UI elements."
/>
<Card
title="Figma to Code"
description="When you receive a Figma design, identify the tokens first: what are the colors, spacing, and fonts? Map them to your tier system, and the components follow naturally."
/>
<Card
title="Theme Switching"
description="Dark mode is not a rewrite. It is a swap at the semantic layer. Components never know which theme is active. They just read their tokens."
/>
</div>
</section>
</div>
);
}
What does this page do?
- The imports pull in your Button and Card from the private _components folder using a relative path (../_components/).
- The Button section renders three sizes side by side so you can see how the size prop changes padding and font size while keeping the same token-based colors.
- The Card section renders three cards in a responsive grid. Each Card receives different content through props, but they all look consistent because they share the same component tokens.
- Save app/components/page.tsx.
- Visit http://localhost:3000/components in your browser.
- You should see three blue buttons (Small, Medium, Large) at the top and three cards with text content below them.
- Hover over each button. You should see the background color darken slightly.
Components page showing an error?
- If you see "Module not found", check that the import paths start with ../_components/ (two dots, then slash, then underscore). The page file is inside app/components/ and needs to go up one level to reach app/_components/.
- Make sure your Button and Card files use export function (named export), not export default function. The import uses curly braces, which requires a named export.
- Confirm that Button.tsx and Card.tsx are saved. Unsaved files will not be found by the import.
Help me fix my Components page error.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: Button.tsx, Card.tsx, and page.tsx.
ⓧ I'd like to double check the full code
Here is the complete code for each file you created or edited in this step.
interface ButtonProps {
children: React.ReactNode;
size?: "sm" | "md" | "lg";
}
export function Button({ children, size = "md" }: ButtonProps) {
const sizeClasses = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
};
return (
<button
className={`rounded-md font-medium transition-colors cursor-pointer ${sizeClasses[size]}`}
style={{
backgroundColor: "var(--button-bg)",
color: "var(--button-text)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--button-hover-bg)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--button-bg)";
}}
>
{children}
</button>
);
}
interface CardProps {
title: string;
description: string;
}
export function Card({ title, description }: CardProps) {
return (
<div
className="p-6 rounded-lg border"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--card-border)",
}}
>
<h3 className="font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
{title}
</h3>
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
{description}
</p>
</div>
);
}
import { Button } from "../_components/Button";
import { Card } from "../_components/Card";
export default function ComponentsPage() {
return (
<div className="max-w-4xl">
<h1 className="text-3xl font-bold mb-2" style={{ color: "var(--text-primary)" }}>
Components
</h1>
<p className="text-lg mb-8" style={{ color: "var(--text-secondary)" }}>
Components consume tokens. They never use hardcoded colors. This means they automatically adapt to any theme.
</p>
<section className="mb-12">
<h2 className="text-xl font-semibold mb-4" style={{ color: "var(--text-primary)" }}>
Button
</h2>
<div className="flex flex-wrap gap-4 items-center">
<Button size="sm">Small Button</Button>
<Button size="md">Medium Button</Button>
<Button size="lg">Large Button</Button>
</div>
<p className="text-sm mt-4" style={{ color: "var(--text-secondary)" }}>
All three buttons use --button-bg and --button-text tokens. Toggle dark mode to see them adapt.
</p>
</section>
<section>
<h2 className="text-xl font-semibold mb-4" style={{ color: "var(--text-primary)" }}>
Card
</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card
title="Token Architecture"
description="Design tokens are organized in tiers. Primitives hold raw values, semantic tokens assign meaning, and component tokens scope decisions to specific UI elements."
/>
<Card
title="Figma to Code"
description="When you receive a Figma design, identify the tokens first: what are the colors, spacing, and fonts? Map them to your tier system, and the components follow naturally."
/>
<Card
title="Theme Switching"
description="Dark mode is not a rewrite. It is a swap at the semantic layer. Components never know which theme is active. They just read their tokens."
/>
</div>
</section>
</div>
);
}
Your components are live and consuming tokens. Next up, you will add a dark mode toggle that swaps the token values and proves the whole system works without touching any component code.
Add Dark Mode Theming
Your components are built, your tokens are defined, and everything renders in light mode. But professional apps need to support dark mode too.
Here is the key insight: dark mode is not about rewriting styles for every component. It is about swapping the values of your semantic tokens. Your .dark class in globals.css already does this. In this step, you will understand how the ThemeToggle component works and verify that toggling themes cascades through every page instantly.
In this step, get ready to:
- Understand how the ThemeToggle component adds and removes the dark class.
- Test dark mode across all three pages.
- Verify that no component code changes are needed for theme switching.
Understand how ThemeToggle works
Your layout already includes a ThemeToggle button in the sidebar navigation. The component lives at app/_components/ThemeToggle.tsx. Let's examine how it achieves theme switching with just a few lines of code.
- In VS Code, open app/_components/ThemeToggle.tsx from the Explorer sidebar.
Review the full contents of the file:
"use client";
import { useState, useEffect } from "react";
export function ThemeToggle() {
// Track whether dark mode is active
const [isDark, setIsDark] = useState(false);
// On mount, check localStorage for a saved theme preference
useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "dark") {
setIsDark(true);
document.documentElement.classList.add("dark");
}
}, []);
// Toggle between light and dark by adding/removing the "dark" class
function toggle() {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
}
return (
<button
onClick={toggle}
className="w-full px-3 py-2 rounded-md border text-sm font-medium cursor-pointer transition-colors"
style={{
borderColor: "var(--border)",
color: "var(--text-primary)",
backgroundColor: "var(--surface)",
}}
>
{isDark ? "Switch to Light" : "Switch to Dark"}
</button>
);
}
What does this code do?
The "use client" directive at the top tells Next.js this is a Client Component. By default, Next.js App Router components run on the server, which has no access to browser APIs like localStorage or document. The directive makes this component interactive in the browser.
The useEffect checks localStorage when the component first renders. If the user previously chose dark mode, it re-applies the dark class so their preference persists across page refreshes.
The toggle() function adds or removes the dark class on document.documentElement (the <html> element). This single class change triggers the .dark block in your CSS to override all semantic token values at once.
Why is dark mode just a class toggle?
Remember the .dark block you defined in globals.css? It reassigns every semantic token to a different value. For example, --background changes from light gray to dark gray, and --text-primary flips from near-black to near-white.
Because every component references these tokens (not hardcoded hex values), adding class="dark" to the <html> element cascades new values through the entire page instantly.
Test the theme toggle
The ThemeToggle is already rendered in your layout's sidebar. Let's see it in action.
- Open your browser to http://localhost:3000.
- Look at the bottom of the sidebar navigation on the left. You should see a button labeled Switch to Dark.
- Click the Switch to Dark button.
The entire page flips to dark mode immediately. The background turns dark, text becomes light, and cards get a darker surface color.
Where is the ThemeToggle placed in the layout?
Open app/layout.tsx and look for the <ThemeToggle /> component near the bottom of the <nav> element. The mt-auto class on its wrapper div pushes it to the bottom of the sidebar, keeping it accessible on every page.
Toggle button not showing?
- Make sure your dev server is still running. Check the terminal for errors.
- Verify that app/_components/ThemeToggle.tsx exists and starts with "use client" on the very first line.
- Check that app/layout.tsx imports ThemeToggle with the correct path: import { ThemeToggle } from "./_components/ThemeToggle".
Why isn't my dark mode toggle appearing in the sidebar?
Verify dark mode across all pages
The true test of a design token system is that theme changes cascade everywhere without touching component code.
- With dark mode active, click Tokens in the sidebar navigation.
- Notice that the page background, card backgrounds, and text colors all reflect the dark theme.
- Click Components in the sidebar.
- Observe that the buttons and cards adapt their colors automatically.
- Click Switch to Light to return to light mode.
- Navigate through all three pages again to confirm light mode looks correct.
Why did this work without changing component code?
Your Button uses var(--button-bg), your Card uses var(--card-bg), and every page uses var(--text-primary). None of them contain a hardcoded hex color like #ffffff or #111827.
When the .dark class is added, CSS reassigns those variable values. Components just read whatever the variable currently holds. This is the power of a layered token system.
- Refresh the page with dark mode active. The preference persists because ThemeToggle stores your choice in localStorage.
Your design system is complete. You have tokens flowing from primitives through semantic layers into components, and a theme toggle that proves the architecture works. Next up: put it all to the test by re-skinning the entire app in the Secret Mission.
Secret mission
Re-skin the App from a New Design Brief
A designer just sent you a brand new direction: deep purple, warm amber, serif typography. Your challenge is to re-skin the entire app by changing ONLY token values. Zero component code changes allowed. If your token architecture is solid, every page and both themes will adapt automatically.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Next.js project folder on your Desktop.
- Node modules installed inside the project folder.
- Local development server (running on port 3000).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with your design tokens and components.
- Your dev server stays running at http://localhost:3000.
- You can keep adding pages, tokens, and components to practice.
Pause - I'll come back to this later
Shut down the running development server to free up memory and your terminal. Your project files stay intact on disk.
- Go to the terminal where npm run dev is running.
- Press Ctrl+C to stop the development server.
- When you want to resume, open the project folder in VS Code and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and start fresh. This permanently deletes your code.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Delete the project folder from your Desktop using Finder (macOS) or File Explorer (Windows).
- That is everything. No cloud resources, accounts, or services need to be cleaned up.
Nice Work!
Nice Work!
You just built a complete design system from scratch and learned Next.js at the same time. You now have the mental model to look at any Figma design and know exactly how to translate it into code.
You've learned how to:
- Build a 4-tier token system (Primitive, Base, Semantic, Component) using Tailwind CSS v4's @theme directive and CSS custom properties.
- Structure a multi-page Next.js app using the App Router, where layouts wrap pages, folders become routes, and underscored folders stay private.
- Implement dark/light mode as a token swap at the semantic layer, proving that theming does not require rewriting component styles.
- Re-skinned an entire app by changing only token values, simulating how a real design brief lands in a production codebase.
Ready to quiz yourself?