Theming Light / dark switching

Light / dark switching

pretable and Material 3 ship both light and dark variants — toggle by setting data-theme on <html>.

pretable.css and material.css each ship both light and dark variants: light at :root, dark in a [data-theme="dark"] block. Switch between them at runtime by toggling data-theme="dark" on the root <html> element. Nothing re-imports, and no JavaScript touches the token values.

Dark mode toggle

A button flips the data-theme attribute to dark on a wrapper div — pretable.css's dark block is a bare attribute selector, so the grid repaints instantly with no JavaScript touching a token value.

.md

This demo scopes the attribute to a wrapper div instead of <html>, purely so it doesn't also re-theme this documentation page around it. Your own app should toggle data-theme on <html>, as described below — the wrapper here is a docs-site adaptation, not the recommended integration.

The two blocks carry identical specificity — :root is a pseudo-class, [data-theme="dark"] an attribute selector, and both weigh (0,1,0) — so it is source order, not specificity, that hands the win to the dark block once the attribute matches. That matters for your own overrides too: anything you write at :root after the import beats the theme's dark block as well as its light one. See Override tokens.

pretable.css additionally sets color-scheme on both blocks, so the browser's own surfaces — form controls, scrollbars, the default canvas — follow the grid rather than staying light underneath it. material.css does not; if you use it, set color-scheme yourself alongside the attribute.

Excel is light-only by design. There is no [data-theme="dark"] block in excel.css, so setting the attribute matches nothing and the grid stays light. If you need dark mode, use pretable or Material 3, add your own [data-theme="dark"] block after the Excel import, or author a theme file with both variants. See Custom themes.

The stylesheet side

There is nothing to configure. Import the theme once, and both modes are already in the file:

css
@import "@pretable/ui/themes/pretable.css";
@import "@pretable/ui/grid.css";

The trap this pattern sets, and where it bit us

Both shipped dark themes now come out right on their own, but one of them did not until recently, and the reason generalizes to any theme you author.

material.css derives most of its grid controls from other tokens, which is why its dark block can be short and still be correct: --pretable-checkbox-checked-bg is var(--pretable-accent), so it follows the accent into dark by itself. Its partner was a literal — --pretable-checkbox-checked-fg: #fff, written in the :root block and inherited into dark unchanged. So the fill moved and the ink did not, and a checked checkbox drew a white mark on the dark scheme's light-blue primary at 1.70:1 — under the 3:1 WCAG floor for a graphical object, on the one mark that says a row is selected.

That is the design language showing through rather than a typo. In M3 the pair is primary / on-primary, and on-primary is light in one scheme and dark in the other, so no single literal can serve both. White is the correct value at :root, where it sits on the light primary at 6.47:1. The dark block now restates it as M3's baseline dark on-primary, which reads 7.73:1:

css
[data-theme="dark"] {
  --pretable-checkbox-checked-fg: #003258; /* on-primary (dark) */
}

Nothing to do on your side — that ships in material.css. Take the shape of the bug, though: a derived token and a literal token that have to stay legible against each other will drift apart the moment one of them is re-derived. pretable.css avoids it by restating every color token it declares in its dark block, literals included — the discipline Custom themes recommends for your own. A contrast check on that pair now runs in CI against all three themes in both modes.

Everything below is about getting one attribute onto <html> at the right moment.

React state-driven

Hold the theme mode in React state, sync to the DOM in an effect:

ThemeProvider.tsx
import { useEffect, useState } from "react";
 
type ThemeMode = "light" | "dark";
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [mode, setMode] = useState<ThemeMode>("light");
 
  useEffect(() => {
    if (mode === "dark") {
      document.documentElement.dataset.theme = "dark";
    } else {
      delete document.documentElement.dataset.theme;
    }
  }, [mode]);
 
  return (
    <>
      <button onClick={() => setMode(mode === "light" ? "dark" : "light")}>
        Switch to {mode === "light" ? "dark" : "light"}
      </button>
      {children}
    </>
  );
}

Wrap your app with this provider. Anywhere a <Pretable> renders inside, the grid responds to the mode change automatically. The theme file's [data-theme="dark"] block declares the dark color overrides; CSS cascade does the rest.

OS-respect (prefers-color-scheme)

For apps that should follow the OS dark-mode setting without asking:

SystemThemeProvider.tsx
import { useEffect, useState } from "react";
 
function getSystemMode(): "light" | "dark" {
  if (typeof window === "undefined") return "light";
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}
 
export function SystemThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [mode, setMode] = useState<"light" | "dark">(getSystemMode);
 
  useEffect(() => {
    const mql = window.matchMedia("(prefers-color-scheme: dark)");
    const handler = (e: MediaQueryListEvent) => {
      setMode(e.matches ? "dark" : "light");
    };
    mql.addEventListener("change", handler);
    return () => mql.removeEventListener("change", handler);
  }, []);
 
  useEffect(() => {
    if (mode === "dark") {
      document.documentElement.dataset.theme = "dark";
    } else {
      delete document.documentElement.dataset.theme;
    }
  }, [mode]);
 
  return <>{children}</>;
}

Composition with density

data-theme="dark" and data-density are independent attributes. They compose:

html
<html data-theme="dark" data-density="spacious">
  ...
</html>

A theme's [data-theme="dark"] block overrides color tokens (cell background, text, accent, gridlines) without touching density tokens, and its [data-density="spacious"] block overrides density tokens without touching colors. Both apply, in either order.

The engine listens for either attribute change via a MutationObserver on the grid element and every ancestor up to <html>, and re-renders the grid with new heights when density flips. No additional wiring needed, at either level.

SSR considerations

If your app renders on the server, the initial HTML doesn't know the user's mode. Two patterns:

  1. Cookie-driven SSR. Read a theme cookie server-side, set <html data-theme="dark"> in the initial markup if it's "dark". Avoids a flash of light content.
  2. Client-only. Don't set data-theme server-side; let the React effect set it after hydration. Brief flash of light mode is acceptable for low-traffic apps.

The OS-respect pattern above is client-only by default — getSystemMode returns "light" server-side because window is undefined.

Where to go next