title: Light / dark switching description: Activate Material 3 dark mode by toggling data-theme="dark" on the html element. Composes independently with density.
Light / dark switching
Material 3's theme file ships both light and dark variants. Switch between them at runtime by toggling data-theme="dark" on the root <html> element. CSS specificity handles the rest.
Excel is light-only by design. There's no
[data-theme="dark"]block inexcel.css. If you need dark mode, use Material 3 or build your own theme file with both variants. See Custom themes.
React state-driven
Hold the theme mode in React state, sync to the DOM in an effect:
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:
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 data-theme="dark" data-density="spacious">
...
</html>Material's [data-theme="dark"] block overrides color tokens (cell background, text, accent, gridlines) without touching density tokens. Material's [data-density="spacious"] block overrides density tokens without touching colors. Both apply.
The engine's useResolvedHeights hook (in @pretable/react) listens for either attribute change via MutationObserver and re-renders the grid with new heights when density flips. No additional wiring needed.
SSR considerations
If your app renders on the server, the initial HTML doesn't know the user's mode. Two patterns:
- Cookie-driven SSR. Read a
themecookie server-side, set<html data-theme="dark">in the initial markup if it's"dark". Avoids a flash of light content. - Client-only. Don't set
data-themeserver-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
- Density switching — runtime compact/standard/spacious.
- Override tokens — change colors per-mode by overriding inside
[data-theme="dark"]. - Custom themes — author your own dark-mode block.