LearnAI ToolsCareerPractice BuildsPlayContact
Tailwind CSSBeginner~1.5 hours

Dark Mode Landing Page

Build a full landing page with a working dark/light mode toggle.

Dark ModeState VariantsTheming

Overview

Almost every modern SaaS landing page ships a dark/light toggle, and Tailwind's `dark:` variant is built specifically for it — every utility class can have a dark-mode counterpart that only applies when the page is "in" dark mode. This project builds a small landing page (hero, features, toggle button) where every color is doubled up with a `dark:` alternative, then wires a real button to switch between them and remember the choice across page reloads.

By default, Tailwind decides dark mode purely from the OS-level `prefers-color-scheme` media query, which cannot be flipped by a button click. This project explicitly reconfigures that behavior to class-based dark mode, where a single `dark` class on `<html>` — added and removed by JavaScript — is what every `dark:` utility on the page is actually watching for.

What You'll Build
  • A hero section and a three-column features section, both fully styled with `dark:` variants.
  • A fixed toggle button that switches the whole page between light and dark instantly.
  • Real JavaScript that adds/removes a `dark` class on `<html>` — the actual mechanism `dark:` classes depend on.
  • A `tailwind.config` override that switches dark-mode detection from media-query-based to class-based.
  • localStorage persistence, so a reloaded page keeps whichever theme the visitor last chose.

Prerequisites

  • Utility-first fundamentals and the Tailwind spacing scale (`p-*`, `px-*`, `mb-*`, etc.).
  • State variants — specifically how a `dark:` prefixed class works the same way a `hover:` prefixed class does.
  • Basic DOM JavaScript — `document.getElementById`, `classList.add/remove/toggle`, and `addEventListener`.
  • What `localStorage.getItem`/`setItem` do, and that they persist across page reloads in the same browser.
  • Responsive prefixes (`sm:`) for the hero heading, which is reused from the Responsive Card Grid project.

Project Structure

This is a single HTML file, but unlike the Responsive Card Grid project it needs two extra pieces beyond the CDN `<script>`: a small inline `tailwind.config` script that switches on class-based dark mode, and a page-level JavaScript block that actually flips the `dark` class and remembers the choice. Both live in the same file — there is still no separate build step.

Step 1: Configure Class-Based Dark Mode

Out of the box, the Tailwind Play CDN — like Tailwind itself — treats dark mode as `media`-based: it follows the operating system's color-scheme setting and offers no way for a button click to override it. Setting `darkMode: 'class'` switches every `dark:` utility on the page to instead depend on whether the `<html>` element carries a literal `class="dark"`, which JavaScript is free to add or remove.

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Loads the Tailwind Play CDN build: compiles utility classes found in the DOM, live in the browser -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
// This MUST run AFTER the CDN <script> tag above, because it configures the global
// "tailwind" object that tag creates — running it first would throw a ReferenceError.
//
// darkMode: 'class' is the key line: without it, dark: utilities only ever respond to the
// OS's prefers-color-scheme setting, and no click handler could ever override that.
tailwind.config = {
darkMode: 'class',
};
</script>
</head>

Step 2: Build the Hero Section with Dark Variants

With class-based dark mode configured, every color-related utility on the page gets a `dark:` sibling. Since `<html>` starts with no `dark` class present, the page renders in its light-mode colors by default — the toggle built in Step 4 is what will actually add that class.

<!-- transition-colors animates the background swap smoothly when the "dark" class is toggled,
instead of the colors snapping instantly -->
<body class="bg-white dark:bg-slate-900 transition-colors duration-300">
<section class="max-w-4xl mx-auto px-6 py-24 text-center">
<h1 class="text-4xl sm:text-5xl font-bold text-slate-900 dark:text-white mb-4">
Ship Faster with Aurora
</h1>
<!-- Every dark: prefixed class here only APPLIES once <html class="dark"> is present — until then,
the browser simply ignores it and uses the plain (light-mode) utility written right beside it -->
<p class="text-lg text-slate-600 dark:text-slate-300 mb-8">
The all-in-one dashboard for teams who want less setup and more shipping.
</p>
<button class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-6 py-3 rounded-lg">
Get Started Free
</button>
</section>
</body>

Step 3: Build the Features Section

The features section repeats the same light/dark pairing pattern across a 3-column grid of cards, plus one extra nuance: `dark:shadow-none` removes the (barely visible, slightly muddy-looking) light-mode shadow once the background is already dark, where a plain border reads more cleanly than a shadow does.

<section class="bg-slate-50 dark:bg-slate-800 py-16 transition-colors duration-300">
<div class="max-w-5xl mx-auto px-6 grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-white dark:bg-slate-900 rounded-xl p-6 shadow-sm dark:shadow-none">
<!-- dark:shadow-none: a soft shadow that looks fine on a light card looks murky on a dark
one, so dark mode drops it entirely and relies on the card's own background contrast -->
<h3 class="font-semibold text-slate-900 dark:text-white mb-2">Instant Deploys</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">Push to main, live in seconds.</p>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-6 shadow-sm dark:shadow-none">
<h3 class="font-semibold text-slate-900 dark:text-white mb-2">Built-in Auth</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">Secure logins with zero config.</p>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-6 shadow-sm dark:shadow-none">
<h3 class="font-semibold text-slate-900 dark:text-white mb-2">Live Analytics</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">See usage the moment it happens.</p>
</div>
</div>
</section>

Step 4: Wire Up the Toggle Button with JavaScript

The button itself just needs a fixed position so it is reachable from anywhere on the page, and its own `dark:` styling so it stays legible in both themes. The JavaScript below is the real mechanism: it toggles the `dark` class on `document.documentElement` (i.e. `<html>`) — the exact class every `dark:` utility on the page has been waiting for since Step 1.

<!-- Fixed top-right so the toggle is reachable no matter how far down the page you scroll -->
<button id="theme-toggle"
class="fixed top-4 right-4 bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-white
px-4 py-2 rounded-full text-sm font-medium shadow">
<span id="theme-toggle-label">Dark Mode</span>
</button>
// document.documentElement is <html> — the exact element darkMode: 'class' (Step 1) inspects
const root = document.documentElement;
const toggleBtn = document.getElementById('theme-toggle');
const toggleLabel = document.getElementById('theme-toggle-label');
function applyTheme(isDark) {
// Adding/removing "dark" here is the ENTIRE mechanism — every dark: utility in the markup
// is simply watching for this one class to be present or absent on <html>
root.classList.toggle('dark', isDark);
toggleLabel.textContent = isDark ? 'Light Mode' : 'Dark Mode';
}
toggleBtn.addEventListener('click', () => {
const isDark = !root.classList.contains('dark');
applyTheme(isDark);
// Step 5 adds a line here to remember this choice past a page reload
});

Step 5: Persist the Theme Choice with localStorage

Right now the toggle works, but refreshing the page forgets the choice and falls back to the light-mode default. Two additions fix that: on page load, check `localStorage` for a previously saved theme (falling back to the OS `prefers-color-scheme` if nothing was ever saved), and on every click, save the new choice back to `localStorage`.

One defensive detail worth calling out: `localStorage` can throw a `SecurityError` in restrictive contexts — most notably a sandboxed iframe without same-origin access, which is exactly how the live preview at the bottom of this page is rendered. Wrapping every `localStorage` call in a small `try`/`catch` helper means the toggle still works everywhere, even where persistence silently isn't available.

// Wrap localStorage access so a restrictive environment (private browsing, a sandboxed
// iframe like this project's own live preview below, etc.) can't crash the toggle —
// it just silently skips persistence in that case, while the toggle itself keeps working
function safeGet(key) {
try { return localStorage.getItem(key); } catch (e) { return null; }
}
function safeSet(key, value) {
try { localStorage.setItem(key, value); } catch (e) { /* storage unavailable here — ignore */ }
}
// Runs once, immediately on page load, before the click handler below ever fires
const saved = safeGet('theme');
// Only fall back to the OS-level preference if the visitor has never explicitly chosen here
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(saved ? saved === 'dark' : prefersDark);
toggleBtn.addEventListener('click', () => {
const isDark = !root.classList.contains('dark');
applyTheme(isDark);
// Persist the explicit choice, so the NEXT load reads this instead of the OS preference again
safeSet('theme', isDark ? 'dark' : 'light');
});

Complete Code

Here is the entire page assembled — configuration, markup, and JavaScript — in the order it would actually appear in one HTML file.

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
</head>
<body class="bg-white dark:bg-slate-900 transition-colors duration-300">
<button id="theme-toggle" class="fixed top-4 right-4 bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-white px-4 py-2 rounded-full text-sm font-medium shadow">
<span id="theme-toggle-label">Dark Mode</span>
</button>
<section class="max-w-4xl mx-auto px-6 py-24 text-center">
<h1 class="text-4xl sm:text-5xl font-bold text-slate-900 dark:text-white mb-4">Ship Faster with Aurora</h1>
<p class="text-lg text-slate-600 dark:text-slate-300 mb-8">The all-in-one dashboard for teams who want less setup and more shipping.</p>
<button class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-6 py-3 rounded-lg">Get Started Free</button>
</section>
<section class="bg-slate-50 dark:bg-slate-800 py-16 transition-colors duration-300">
<div class="max-w-5xl mx-auto px-6 grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-white dark:bg-slate-900 rounded-xl p-6 shadow-sm dark:shadow-none">
<h3 class="font-semibold text-slate-900 dark:text-white mb-2">Instant Deploys</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">Push to main, live in seconds.</p>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-6 shadow-sm dark:shadow-none">
<h3 class="font-semibold text-slate-900 dark:text-white mb-2">Built-in Auth</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">Secure logins with zero config.</p>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-6 shadow-sm dark:shadow-none">
<h3 class="font-semibold text-slate-900 dark:text-white mb-2">Live Analytics</h3>
<p class="text-sm text-slate-600 dark:text-slate-400">See usage the moment it happens.</p>
</div>
</div>
</section>
<script>
const root = document.documentElement;
const toggleBtn = document.getElementById('theme-toggle');
const toggleLabel = document.getElementById('theme-toggle-label');
function safeGet(key) {
try { return localStorage.getItem(key); } catch (e) { return null; }
}
function safeSet(key, value) {
try { localStorage.setItem(key, value); } catch (e) { /* storage unavailable here — ignore */ }
}
function applyTheme(isDark) {
root.classList.toggle('dark', isDark);
toggleLabel.textContent = isDark ? 'Light Mode' : 'Dark Mode';
}
const saved = safeGet('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(saved ? saved === 'dark' : prefersDark);
toggleBtn.addEventListener('click', () => {
const isDark = !root.classList.contains('dark');
applyTheme(isDark);
safeSet('theme', isDark ? 'dark' : 'light');
});
</script>
</body>
</html>
Live Preview

Sample Run

What You'll See

Click Run to see what this code prints.

Extend This Project

  • Add a tiny blocking `<script>` at the very top of `<head>` that applies the saved theme before first paint, to eliminate the brief "flash of light mode" some browsers show.
  • Replace the text label with an animated sun/moon icon swap using two absolutely-positioned SVGs and `dark:hidden`/`hidden dark:block`.
  • Add a third "System" option alongside Light/Dark that clears the saved preference and re-follows `prefers-color-scheme`.
  • Extend the same `dark:` pattern to a pricing section or footer, reusing the color pairs already established here.
  • Add a `matchMedia` change listener so the page updates live if the visitor changes their OS theme while the tab is open (only when no explicit choice is saved).

Summary

You built a landing page with a genuinely working dark/light toggle — not just `dark:` classes that only ever react to the OS setting, but a real button backed by a `dark` class on `<html>`, class-based configuration, and localStorage persistence. This exact pattern — `darkMode: 'class'` plus a small toggle script — is the same one you will reach for in any real Tailwind project that needs a user-controlled theme switch.