Design system

Dark mode

Dark mode is the same token mechanism as theming, applied through a selector. The library ships a complete dark token set — you decide how the selector gets applied.

Both conventions are supported

The dark tokens are declared under [data-theme="dark"] and .dark. Whichever convention your application already uses, it works — including alongside Tailwind's dark variant or next-themes defaults.

/* Both selectors are shipped, so the library drops into either
   convention without your application adapting to it. */
[data-theme="dark"],
.dark {
  --abba-background: var(--abba-neutral-950);
  --abba-foreground: var(--abba-neutral-50);
  /* …the full dark token set */
}

Avoiding the flash

Applying the theme in an effect means the light theme paints first and then snaps to dark. It is brief, it is ugly, and it is the detail people judge a themed site on. The fix is a small blocking script in <head> that runs before first paint.

// app/theme-script.tsx — a Server Component
const script = `
(function () {
  try {
    var stored = localStorage.getItem("theme");
    var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
    var theme = stored === "light" || stored === "dark"
      ? stored
      : (prefersDark ? "dark" : "light");
    document.documentElement.setAttribute("data-theme", theme);
    document.documentElement.style.colorScheme = theme;
  } catch (e) {
    document.documentElement.setAttribute("data-theme", "light");
  }
})();
`;

export function ThemeScript() {
  return <script dangerouslySetInnerHTML={{ __html: script }} />;
}
// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    // suppressHydrationWarning covers the attribute the script sets
    // before React takes over. Without it React warns on every load.
    <html lang="en" suppressHydrationWarning>
      <head>
        <ThemeScript />
      </head>
      <body>{children}</body>
    </html>
  );
}

Why suppressHydrationWarning

The script mutates <html> before React hydrates, so the server-rendered markup and the DOM legitimately differ. The attribute tells React that this specific difference is intended; it does not suppress warnings anywhere else in the tree.

A toggle

Read the attribute the script already set rather than recomputing the preference — two sources of truth is how a toggle ends up disagreeing with the page it is on. Subscribing with useSyncExternalStore rather than copying the value into state also keeps the button correct if anything else changes the theme, and avoids a setState in an effect.

"use client";

import { IconButton } from "@abbainitiative/ui";
import { useSyncExternalStore } from "react";

// The attribute on <html> is the store. Subscribing to it means the
// button can never disagree with the page, and there is no second
// copy of the preference to keep in sync.
function subscribe(onStoreChange: () => void) {
  const observer = new MutationObserver(onStoreChange);
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ["data-theme"],
  });
  return () => observer.disconnect();
}

const getSnapshot = () =>
  document.documentElement.getAttribute("data-theme") === "dark"
    ? "dark"
    : "light";

// On the server the theme is genuinely unknown, so say so rather than
// guessing and announcing the wrong label half the time.
const getServerSnapshot = () => null;

export function ThemeToggle() {
  const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

  return (
    <IconButton
      variant="ghost"
      aria-label={
        theme === null
          ? "Switch theme"
          : `Switch to ${theme === "dark" ? "light" : "dark"} theme`
      }
      onClick={() => {
        const next = theme === "dark" ? "light" : "dark";
        document.documentElement.setAttribute("data-theme", next);
        document.documentElement.style.colorScheme = next;
        localStorage.setItem("theme", next);
      }}
      icon={<ThemeIcon theme={theme} />}
    />
  );
}

Using next-themes

If you would rather not hand-roll it, next-themes works with no adaptation.

// next-themes works without modification — its default
// attribute is class, which matches the .dark selector.
import { ThemeProvider } from "next-themes";

<ThemeProvider attribute="class">{children}</ThemeProvider>

// Or, to use the data attribute instead:
<ThemeProvider attribute="data-theme">{children}</ThemeProvider>

System preference only

A toggle is not compulsory. Following the operating system is a legitimate choice and needs no client JavaScript at all.

/* No toggle at all? Follow the operating system and stop
   there. This needs no JavaScript whatsoever. */
@media (prefers-color-scheme: dark) {
  :root {
    /* Re-declare the dark token set here, or add the class
       server-side from a cookie. */
  }
}

What changes in dark mode

  • Primary and accent step lighter. Cedar 600 on near-black is unreadable, so the dark set points primary at Cedar 400 and inverts the foreground.
  • State colours are re-picked, not inverted. Mechanically inverting a red produces a cyan; each dark state colour was chosen against the dark background.
  • Shadows become higher-opacity black. The warm-tinted light-mode shadows are invisible on a near-black surface.
  • Surfaces separate by lightness. --abba-background-raised is lighter than the page in dark mode and identical to it in light mode, matching how elevation actually reads in each.

Testing it

Use the toggle in this site's header — every example on every page is rendered with the real components, so what you see here is what your application gets.