Guides
Dark Mode
Learn how trink-ui handles dark mode through CSS variables and the .dark class strategy. Implement a toggle, detect system preferences, and use per-section theming.
How CSS variable theming works
trink-ui defines two sets of CSS variables: one under :root for light mode and one under .dark for dark mode. When the dark class is present on the <html> element, all CSS variables switch to their dark values, and every component automatically updates.
:root {
--trinkui-bg: 255 255 255; /* white */
--trinkui-fg: 10 10 10; /* near-black */
--trinkui-primary: 99 102 241; /* indigo-500 */
--trinkui-surface: 248 250 252; /* light gray */
}
.dark {
--trinkui-bg: 9 9 11; /* near-black */
--trinkui-fg: 250 250 250; /* near-white */
--trinkui-primary: 129 140 248; /* indigo-400 */
--trinkui-surface: 18 18 21; /* dark gray */
}Adding the dark class
The simplest way to enable dark mode is to add the dark class to your HTML element. In Next.js App Router:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body>{children}</body>
</html>
);
}For a toggle-based approach, you will add and remove this class dynamically with JavaScript.
Toggle implementation with localStorage
Here is a complete dark mode toggle component that persists the user's preference in localStorage:
"use client";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// Check localStorage on mount
const stored = localStorage.getItem("theme");
if (stored === "dark") {
setIsDark(true);
document.documentElement.classList.add("dark");
} else if (stored === "light") {
setIsDark(false);
document.documentElement.classList.remove("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}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="rounded-lg border border-[rgb(var(--trinkui-border))] p-2 text-[rgb(var(--trinkui-muted))] hover:bg-[rgb(var(--trinkui-surface))] hover:text-[rgb(var(--trinkui-fg))] transition-colors"
>
{isDark ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
);
}Detecting system preference
To respect the user's operating system preference as a default, check prefers-color-scheme when no stored preference exists:
"use client";
import { useEffect } from "react";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "dark") {
document.documentElement.classList.add("dark");
} else if (stored === "light") {
document.documentElement.classList.remove("dark");
} else {
// No stored preference — use system preference
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) {
document.documentElement.classList.add("dark");
}
}
// Listen for system preference changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
function handleChange(e: MediaQueryListEvent) {
if (!localStorage.getItem("theme")) {
if (e.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
}
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
return <>{children}</>;
}Wrap your root layout with this provider to automatically apply the correct theme on page load.
Per-section theming
trink-ui section components accept a theme prop that overrides the page-level theme for that section only. This lets you create visual contrast by alternating light and dark sections on the same page:
{/* Light page with dark accent sections */}
<HeroCentered theme="light" title="Light hero" ... />
<FeaturesGrid theme="light" title="Light features" ... />
<TestimonialsGrid theme="dark" title="Dark testimonials" ... />
<FAQAccordion theme="light" title="Light FAQ" ... />
<CTACentered theme="dark" title="Dark CTA" ... />
<FooterColumns theme="dark" ... />The theme prop works independently of the page-level dark class. A section with theme="dark" will always render with dark colors, even on a light-mode page.
Custom dark mode colors
Override any dark mode variable to create a custom dark palette:
.dark {
/* Deep navy background instead of near-black */
--trinkui-bg: 15 23 42;
--trinkui-fg: 241 245 249;
--trinkui-surface: 30 41 59;
--trinkui-border: 51 65 85;
--trinkui-muted: 148 163 184;
/* Teal primary instead of indigo */
--trinkui-primary: 45 212 191;
--trinkui-primary-fg: 15 23 42;
}Preventing flash of wrong theme
To avoid a flash of the wrong theme on page load, add an inline script to the <head> that runs before React hydrates:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}