Themes & Customization·6 min read·

How dark mode works on a VeloCMS blog (and how to add a reader toggle)

There's no shipped reader-facing toggle button today — dark/light on a tenant blog is a per-blog owner setting. Here's how the real system works and how to build your own toggle with next-themes.

Dark mode is the second-most asked-for feature on any blog after a working search bar, so it's worth understanding how VeloCMS actually handles it before you go build a toggle. Two separate mechanisms are involved: the global `ThemeProvider` (built on next-themes) that runs everywhere, and a per-blog `default_theme_mode` setting that decides whether a tenant's public blog even lets the reader's preference through.

Where the provider lives

Open `src/components/theme/theme-provider.tsx`. It wraps `next-themes` with `attribute="class"`, which toggles a `.dark` class on `<html>` — not a `data-theme` attribute. (`data-theme` in VeloCMS is reserved for a different concept: which theme PRESET is active, e.g. `data-theme="atelier"` — separate from light/dark mode entirely.) The provider persists the choice to a cookie via a server action and handles the hydration handshake that prevents a white-flash on load.

Why your blog might already be locked to one mode

Public tenant blogs don't automatically inherit the reader's system preference. A blog owner sets `default_theme_mode` (Admin → Settings) to one of: `preset` (follow whatever the active theme preset is designed for), `system` (defer to the reader's OS/browser preference), `light` (hard-locked), or `dark` (hard-locked). Only in the `system` case does a reader's own dark-mode preference actually apply — and even then, there's no visible toggle button shipped for public blog chrome today. Check your blog's setting first; building a toggle on a hard-locked blog won't do anything.

Building your own toggle

If your blog is set to `system` mode and you want to give readers a manual override (rather than relying purely on their OS setting), you can build a small client component using next-themes' own `useTheme()` hook directly — no VeloCMS-specific toggle component ships today, but the underlying library is already a dependency.

"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";

export function ReaderThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null; // avoid SSR/client mismatch

  return (
    <button
      type="button"
      aria-label="Toggle dark mode"
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
    >
      {resolvedTheme === "dark" ? "\u2600\uFE0F" : "\u{1F319}"}
    </button>
  );
}

Drop this into your theme's nav layout (e.g. inside the `<nav className="site-nav">` block). Style it with your theme's own CSS rather than assuming a shared component exists.

Verify with the keyboard

Press Tab through your blog header. Your toggle must receive focus and respond to Space/Enter — a plain `<button>` gets this for free. Give it a real `aria-label` (not just an emoji as the only content) so screen reader users know what it does, and double-check your theme's CSS reset hasn't stripped the focus ring with a stray `outline: none`.