Goal
Build ThemeToggle and Navbar: sticky top bar, section links, mobile menu, and light/dark mode that persists in localStorage.
Create the folder src/components/ if it does not exist yet.
1. ThemeToggle.jsx
import { useEffect, useState } from "react";
function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
const saved = localStorage.getItem("theme");
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
const enabled = saved === "dark" || (!saved && prefersDark);
setDark(enabled);
document.documentElement.classList.toggle("dark", enabled);
}, []);
function toggle() {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
}
return (
<button
type="button"
onClick={toggle}
className="rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800"
aria-label="Toggle dark mode"
>
{dark ? "Light" : "Dark"}
</button>
);
}
export default ThemeToggle;
Tailwind chunks explained
| Class |
Meaning |
rounded-md |
Medium border radius |
border |
1px border |
border-slate-300 |
Light gray border |
px-3 / py-2 |
Padding inside the button |
text-sm |
Smaller label text |
font-medium |
Slightly bold label |
text-slate-700 |
Button text color |
transition |
Animate property changes (hover/dark swap) |
hover:bg-slate-100 |
Soft fill on hover |
dark:border-slate-600 |
Darker border in dark mode |
dark:text-slate-200 |
Lighter text in dark mode |
dark:hover:bg-slate-800 |
Hover fill for dark background |
React bits (not Tailwind): toggle dark on <html>, remember the choice in localStorage, fall back to system preference.
2. Navbar.jsx
import { useState } from "react";
import ThemeToggle from "./ThemeToggle";
function Navbar() {
const [open, setOpen] = useState(false);
const linkClass =
"block rounded-md px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-blue-600 dark:text-slate-200 dark:hover:bg-slate-800";
return (
<header className="sticky top-0 z-10 border-b border-slate-200 bg-white/90 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90">
<nav className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<a
href="#"
className="text-lg font-bold text-slate-900 dark:text-slate-100"
>
YourName.dev
</a>
<button
type="button"
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800 md:hidden"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls="mobile-menu"
>
Menu
</button>
<ul className="hidden items-center gap-6 text-sm font-medium text-slate-600 dark:text-slate-300 md:flex">
<li>
<a
href="#about"
className="hover:text-blue-600 dark:hover:text-blue-400"
>
About
</a>
</li>
<li>
<a
href="#skills"
className="hover:text-blue-600 dark:hover:text-blue-400"
>
Skills
</a>
</li>
<li>
<a
href="#projects"
className="hover:text-blue-600 dark:hover:text-blue-400"
>
Projects
</a>
</li>
<li>
<a
href="#contact"
className="rounded-md bg-brand-600 px-3 py-2 text-white hover:bg-brand-700 dark:bg-brand-500 dark:hover:bg-brand-600"
>
Contact
</a>
</li>
</ul>
<ThemeToggle />
</nav>
{open && (
<ul
id="mobile-menu"
className="space-y-1 border-t border-slate-200 bg-white px-4 py-3 dark:border-slate-700 dark:bg-slate-950 md:hidden"
>
<li>
<a
href="#about"
className={linkClass}
onClick={() => setOpen(false)}
>
About
</a>
</li>
<li>
<a
href="#skills"
className={linkClass}
onClick={() => setOpen(false)}
>
Skills
</a>
</li>
<li>
<a
href="#projects"
className={linkClass}
onClick={() => setOpen(false)}
>
Projects
</a>
</li>
<li>
<a
href="#contact"
className={linkClass}
onClick={() => setOpen(false)}
>
Contact
</a>
</li>
</ul>
)}
</header>
);
}
export default Navbar;
| Class |
Meaning |
sticky top-0 |
Stick to the top while scrolling |
z-10 |
Stay above page content |
border-b |
Bottom border only |
border-slate-200 |
Light divider |
bg-white/90 |
White background at 90% opacity |
backdrop-blur |
Blur content behind the translucent bar |
dark:border-slate-800 / dark:bg-slate-950/90 |
Dark-mode pairing |
Tailwind chunks explained: flex row
| Class |
Meaning |
mx-auto |
Center the nav container |
flex |
Flexbox row |
max-w-5xl |
Cap content width |
items-center |
Vertically center logo, links, toggle |
justify-between |
Push logo left and controls right |
px-6 / py-4 |
Horizontal and vertical padding |
gap-6 |
Space between desktop links |
hidden … md:flex |
Hide links on small screens; show as flex from md up |
md:hidden |
Show the Menu button only below md |
| Class |
Meaning |
bg-brand-600 |
Brand fill from @theme |
text-white |
Contrast text |
hover:bg-brand-700 |
Darker on hover |
dark:bg-brand-500 |
Slightly brighter brand in dark mode |
Tailwind chunks explained: mobile panel
| Class |
Meaning |
space-y-1 |
Vertical gap between stacked links |
border-t |
Top border under the main nav row |
block |
Full-width tap targets for mobile links |
hover:bg-slate-100 |
Soft highlight on press/hover |
3. Wire into App.jsx
import Navbar from "./components/Navbar";
function App() {
return (
<div className="min-h-screen bg-surface font-sans text-ink antialiased dark:bg-slate-950 dark:text-slate-100">
<Navbar />
<main>
<p className="p-6 text-muted">Navbar and theme toggle are ready.</p>
</main>
</div>
);
}
export default App;
Try it
- Click Dark / Light: the page and nav should flip colors
- Refresh: the choice should stick (
localStorage)
- Resize below
md: desktop links hide; Menu opens the list
Next: build the Hero section.