Try it Free

How We Added Dark Mode (and Light Mode) to SendRec

You’re recording a screen share at 2 PM in a sunlit room. The dark navy dashboard looks fine on your monitor. But your colleague opens it on a bright laptop screen in a coffee shop and squints through every page.

Dark mode isn’t just an aesthetic preference — it’s a usability feature. And the lack of a light alternative was one of the most common requests we heard after launching SendRec.

We added three theme modes: Dark, Light, and System. System follows your OS preference and updates in real time when you toggle it. The whole implementation is 12 files, zero new dependencies, and a 93-line React hook.

The starting point

SendRec’s dashboard was already built on CSS custom properties. Eight variables defined in :root controlled every color:

:root {
  --color-bg: #0f1923;
  --color-surface: #1a2b3c;
  --color-border: #2a3f54;
  --color-text: #e2e8f0;
  --color-text-secondary: #94a3b8;
  --color-accent: #00b67a;
  --color-accent-hover: #00a06b;
  --color-error: #f87171;
}

Every component used these variables. Background colors, text, borders, accents — all driven by var(--color-bg) and friends. This meant adding a light theme was mostly a matter of providing a second set of values.

Mostly.

The CSS: one selector, fifteen variables

The light theme is a [data-theme="light"] attribute selector that overrides the root variables:

[data-theme="light"] {
  --color-bg: #f8fafc;
  --color-surface: #ffffff;
  --color-border: #e2e8f0;
  --color-text: #0f172a;
  --color-text-secondary: #64748b;
  --color-accent: #00915f;
  --color-accent-hover: #007a50;
  --color-error: #dc2626;
}

The accent green shifts slightly darker in light mode (#00915f vs #00b67a) because the original bright green doesn’t have enough contrast against a white background.

We also added seven new semantic variables that both themes define — things like --color-overlay, --color-shadow, and --color-on-accent. These replaced hardcoded values scattered across components.

The flash problem

If you store the theme preference in localStorage and apply it with React, there’s a visible flash. The page loads with the default dark theme, React mounts, reads localStorage, and switches to light. For a split second, the user sees the wrong theme.

The fix is an inline script in index.html that runs before React:

<script>
  (function() {
    var theme = localStorage.getItem('theme') || 'system';
    var resolved = theme;
    if (theme === 'system') {
      resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark' : 'light';
    }
    document.documentElement.setAttribute('data-theme', resolved);
  })();
</script>

This runs synchronously before the browser paints. By the time React mounts, the correct data-theme attribute is already on <html>, so the CSS variables resolve to the right values from the first frame.

The hook: useSyncExternalStore, not Context

The standard React approach for global state is a Context provider. But theme state has a specific characteristic that makes Context unnecessary: it’s a singleton. There’s exactly one theme for the entire app, stored in localStorage, applied to the document element.

We used useSyncExternalStore with module-level state:

let currentTheme: Theme = getStoredTheme();
let currentResolved: ResolvedTheme = resolveTheme(currentTheme);
const listeners = new Set<() => void>();

let snapshotRef = { theme: currentTheme, resolvedTheme: currentResolved };

function subscribe(callback: () => void) {
  listeners.add(callback);
  return () => listeners.delete(callback);
}

function getSnapshot() {
  return snapshotRef;
}

useSyncExternalStore gives us tear-free reads — React guarantees the UI stays consistent even during concurrent rendering. The snapshot is an immutable object that gets replaced on every theme change, so React knows when to re-render.

The hook itself is three things: apply the theme to the DOM, listen for OS preference changes, and provide a setter:

export function useTheme() {
  const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);

  useEffect(() => {
    document.documentElement.setAttribute("data-theme", snapshot.resolvedTheme);
  }, [snapshot.resolvedTheme]);

  useEffect(() => {
    const mql = window.matchMedia("(prefers-color-scheme: dark)");
    function handleChange() {
      if (currentTheme === "system") {
        currentResolved = getSystemPreference();
        updateSnapshot();
        notifyListeners();
      }
    }
    mql.addEventListener("change", handleChange);
    return () => mql.removeEventListener("change", handleChange);
  }, []);

  const setTheme = useCallback((newTheme: Theme) => {
    currentTheme = newTheme;
    currentResolved = resolveTheme(newTheme);
    localStorage.setItem("theme", newTheme);
    updateSnapshot();
    notifyListeners();
  }, []);

  return { theme: snapshot.theme, resolvedTheme: snapshot.resolvedTheme, setTheme };
}

The matchMedia listener only fires when the theme is set to “system”. If you’ve explicitly chosen dark or light, OS preference changes are ignored.

The Chart.js problem

Most components worked immediately after switching CSS variables. But Chart.js was the exception.

Chart.js renders to a <canvas> element. Canvas doesn’t understand CSS variables — when you pass "var(--color-accent)" as a color, it renders nothing. The charts need actual color values.

The fix is to read the computed values at render time:

const { resolvedTheme } = useTheme();

useEffect(() => {
  const styles = getComputedStyle(document.documentElement);
  const accentColor = styles.getPropertyValue("--color-accent").trim();
  const labelColor = styles.getPropertyValue("--color-chart-label").trim();
  const gridColor = styles.getPropertyValue("--color-chart-grid").trim();

  // Build chart with resolved color values...
}, [resolvedTheme, /* other deps */]);

By including resolvedTheme in the dependency array, the chart rebuilds whenever the theme changes. getComputedStyle reads the current CSS variable values after the [data-theme] selector has taken effect.

Hunting down hardcoded colors

The CSS variables covered about 92% of colors in the codebase. The remaining 8% were hardcoded values in inline styles:

  • boxShadow: "0 4px 16px rgba(0,0,0,0.3)" — too dark for a white background
  • background: "rgba(0,0,0,0.6)" — modal overlays
  • color: "#fff" — text on accent-colored buttons
  • "rgba(0, 182, 122, 0.05)" — drag-and-drop highlight

Each of these became a CSS variable. rgba(0,0,0,0.3) became var(--color-shadow). The modal overlay became var(--color-overlay). Button text on accent backgrounds became var(--color-on-accent).

The search was straightforward: grep for rgba(, #fff, #000, and any hex color that wasn’t already a variable reference.

The Settings UI

The theme selector lives in Settings between Profile and Email Notifications. Three radio buttons — Dark, Light, System — with the selected option highlighted by an accent border:

const options: { value: Theme; label: string; description: string }[] = [
  { value: "dark", label: "Dark", description: "Dark background with light text" },
  { value: "light", label: "Light", description: "Light background with dark text" },
  { value: "system", label: "System", description: "Follow your operating system setting" },
];

Clicking an option calls setTheme() from the hook. The change is instant — no save button, no API call, no page reload. The preference persists in localStorage, which means it survives across sessions but doesn’t sync across devices. For a self-hosted tool where most users have one device, this is the right tradeoff.

Testing without a browser

jsdom, the DOM implementation used by Vitest, doesn’t implement window.matchMedia. The hook calls it both at module initialization and inside a useEffect for OS preference tracking.

Rather than guarding every call site, we added a global mock in the test setup:

Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: (query: string) => ({
    matches: false,
    media: query,
    addEventListener: () => {},
    removeEventListener: () => {},
    dispatchEvent: () => false,
  }),
});

This gives every test file a working matchMedia that defaults to “not dark” (light preference). Individual tests can override it when they need to test dark preference behavior.

The hook itself has nine dedicated tests covering: default state, localStorage persistence, OS preference resolution, real-time OS changes, explicit override ignoring OS changes, and cleanup.

What we left alone

The watch page and embed page — the pages viewers see — are server-rendered Go templates with their own CSS. They already have a cohesive dark design and support custom branding (company colors, logos, custom CSS). Adding a theme toggle there would conflict with per-video branding, so we intentionally kept them out of scope.

The recorder and camera components also keep their hardcoded #000 video backgrounds — black is the correct background for video preview regardless of theme.

The result

Twelve files changed. Zero new dependencies. A 93-line hook that any component can import. The theme persists across sessions, follows OS preferences in real time when set to System, and applies without a flash on page load.

The full source is on GitHub. Try it at app.sendrec.eu — open Settings and switch between Dark, Light, and System.