Customize
Dark mode
Dark mode is a dark class on <html>. The token file swaps the variables under it, so every component retints with no per-component work.
The class strategy
VibeUI uses a class rather than the prefers-color-scheme media query so users can choose independently of their OS. The token file registers a matching Tailwind variant:
@custom-variant dark (&:where(.dark, .dark *));Without that line Tailwind v4 would apply dark: utilities based on the OS setting, ignoring your toggle. It is the first line of vibeui.css, so you get it by importing the file.
| Mode | Palette | Accent |
|---|---|---|
| Light | Warm bone canvas, white surfaces, ink text | The ink (monochrome) |
| Dark | Warm stone canvas, charcoal surfaces, off-white text | The ink (monochrome) |
Avoid the flash of the wrong theme
The server cannot know the user's choice, so render a tiny inline script in <head> that sets the class before first paint:
export function ThemeScript() {
const code = `
(function(){
try {
var s = localStorage.getItem('theme');
var d = matchMedia('(prefers-color-scheme: dark)').matches;
var t = s === 'light' || s === 'dark' ? s : (d ? 'dark' : 'light');
if (t === 'dark') document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = t;
} catch (e) {}
})();`
return (
<script
// Runs from the server HTML; inert text/plain once rendered on the client,
// which keeps React from warning about a <script> in a component.
type={typeof window === "undefined" ? "text/javascript" : "text/plain"}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: code }}
/>
)
}Render it inside <head> and add suppressHydrationWarning to <html>, because the script changes the class before React hydrates.
A provider with no effects
You rarely need state for the theme: the class on <html> is the state. Subscribing to it with useSyncExternalStore means the toggle can never drift from the page, and there is no useEffect that sets state on mount.
"use client"
import * as React from "react"
import { Moon, Sun } from "@phosphor-icons/react"
const subscribe = (cb: () => void) => {
const mo = new MutationObserver(cb)
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
return () => mo.disconnect()
}
const isDark = () => document.documentElement.classList.contains("dark")
function setTheme(next: "light" | "dark") {
document.documentElement.classList.toggle("dark", next === "dark")
document.documentElement.style.colorScheme = next
localStorage.setItem("theme", next)
}
export function ThemeToggle() {
const dark = React.useSyncExternalStore(subscribe, isDark, () => false)
return (
<button
type="button"
onClick={() => setTheme(dark ? "light" : "dark")}
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
className="relative flex size-9 items-center justify-center rounded-sm text-muted hover:bg-surface-muted hover:text-ink"
>
{/* Swap with CSS so the first paint always matches the real theme. */}
<Sun size={18} weight="bold" className="hidden dark:block" />
<Moon size={18} weight="bold" className="dark:hidden" />
</button>
)
}Scoping and previews
Tokens are plain CSS variables, so a dark (or light) class on any element pins that subtree to a palette. This site uses it for the live playground, and also accepts ?theme=light / ?theme=dark in the URL for shareable previews.
<div className="light rounded-xl bg-canvas p-6 text-ink">…always light…</div>
<div className="dark rounded-xl bg-canvas p-6 text-ink">…always dark…</div>Checklist
| Check | Why |
|---|---|
| <html> has suppressHydrationWarning | The inline script edits its class before hydration. |
| Script is in <head> | It must run before the body paints. |
| color-scheme is set | Native scrollbars, form controls and autofill follow it. |
| No hard-coded hex in your own components | Use tokens (bg-surface, text-ink) so they retint. |
| Images and charts checked in both modes | Transparent PNGs and SVGs often assume a light background. |