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.
- 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) inspectsconst 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 workingfunction 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 firesconst saved = safeGet('theme');// Only fall back to the OS-level preference if the visitor has never explicitly chosen hereconst 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>Sample Run
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.