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:

css
@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.

ModePaletteAccent
LightWarm bone canvas, white surfaces, ink textThe ink (monochrome)
DarkWarm stone canvas, charcoal surfaces, off-white textThe 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:

components/theme-script.tsx
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.

components/theme-toggle.tsx
"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.

tsx
<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

CheckWhy
<html> has suppressHydrationWarningThe inline script edits its class before hydration.
Script is in <head>It must run before the body paints.
color-scheme is setNative scrollbars, form controls and autofill follow it.
No hard-coded hex in your own componentsUse tokens (bg-surface, text-ink) so they retint.
Images and charts checked in both modesTransparent PNGs and SVGs often assume a light background.