Theme
Mantle has two themes, light and dark. The stored preference is one of three values: "system" (the default, which follows the OS prefers-color-scheme setting), "light", or "dark". The theme system has two parts:
ThemeProviderholds the preference, saves it in a cookie, and keeps it in sync across tabs. On anngrok.comorngrok.aihost, the cookie covers every subdomain of that site.PreventWrongThemeFlashScriptputs the right theme class on<html>before first paint, so the page never flashes the wrong theme.
mantle.css holds both themes. The theme class on <html> selects one.
Setup
Wrap your app in ThemeProvider as high in the component tree as you can. Then add PreventWrongThemeFlashScript to <head>, as early as you can.
For SSR apps, send font preloads as HTTP Link response headers with preloadFontLink. The browser then starts each font fetch before it parses any HTML.
Pass the theme cookie from the request to ThemeProvider and useInitialHtmlThemeProps as ssrCookie. The server then renders the stored theme, and the client hydrates without a mismatch.
Non-SSR / no header control
If you can't set HTTP response headers (a plain Vite SPA, for example), use PreloadFont elements instead of preloadFontLink, and leave out ssrCookie.
Non-React head
If your server can't render React components (a legacy Go service, for example), inline the script into your HTML <head>. Generate the script string with preventWrongThemeFlashScriptContent from @ngrok/mantle/theme: run it in a Node.js build step and embed the output in your template.
The script must run before <body>, because it writes the theme class onto <html> before first paint. To lock the page to one theme, pass it: preventWrongThemeFlashScriptContent("light"). See Locking a page to one theme.
Usage
Drop in ThemeSwitcher for a one-click light/dark toggle:
To build your own control, read and set the preference with the useTheme hook:
The theme swap
Every setTheme call that changes the applied theme runs the same swap as ThemeSwitcher: the new theme grows in a circle over the old one in a view transition. Pass origin to grow the circle from the control the user pressed. When you omit it, the circle grows from the focused element, else from the center of the viewport. Under prefers-reduced-motion: reduce, or in a browser without view transitions, the theme changes at once.
When the next theme depends on the current one, pass an updater, such as (theme) => (theme === "dark" ? "light" : "dark"). It reads the latest theme, even when a second press comes before the first swap runs. A dialog overlay or a dropdown menu that closes in the same press leaves at once, so it does not freeze under the circle.
High contrast
Mantle no longer ships high-contrast themes. A user who picked one in an earlier version keeps the base theme it was built on: a stored light-high-contrast reads as light, and dark-high-contrast reads as dark. ThemeProvider rewrites the old cookie value on mount, so every app on the cookie's domain agrees. An OS high-contrast setting (prefers-contrast: more) no longer changes the theme; "system" follows prefers-color-scheme alone.
Inverted theme islands
Add the invert-theme class to any element to render that subtree in the opposite theme of the
page: light ⇄ dark. The island is styled by the
opposite theme's own definitions, so every mantle component and color token inside it just works:
no conditional rendering, no per-component overrides.
Sandbar is built on this: its floating panel is an invert-theme
island, which is why the bar and its buttons always read inverted from the page in both
themes.
How it works: every mantle color utility resolves a CSS custom property at the element that uses
it, and custom properties inherit, so each theme's definitions are also applied directly to
.invert-theme elements on the opposite theme's pages, re-declaring the theme variables where they
win over the values inherited from :root.
Things to know:
- Portals escape the island. Floating content that portals to
<body>(dropdown menus, tooltips, dialogs, …) renders in the page theme. Passinvert-themein the floating part'sclassNameto invert it too. - Theme variant utilities do not flip.
dark:*andlight:*key off the page theme on<html>, not the inversion scope; this applies to your own utilities as much as to mantle's. Four mantle internals still carry small variant-gated tweaks (Progress Bar, Progress Donut, Skeleton, Table). Avoid composing those inside an island until they migrate to theme-declared tokens. - Rebrand at the ramp, not the alias. Islands re-declare the semantic aliases
(
--color-accent-*,--color-danger-*, …) on themselves so they re-resolve against the inverted ramps, and a declaration on the element beats an inherited value. Override--color-sky-600rather than--color-accent-600and the change flows into islands too. - Nesting is idempotent. An
invert-themeinside an inverted subtree stays inverted relative to the page; it does not revert to the page theme. - Rebranding a ramp does not reach islands. An island restores mantle's pinned Tailwind
defaults, so a consumer
@themepalette override applies to the page but not inside an island. Closing that would need a shadow copy of every ramp under a second name. - Shadows flip with the island. Shadow utilities resolve the shadow tokens at the element that uses them, and islands re-declare those tokens, so content inside a light island gets light-theme shadows, keeping the island's interior coherent.
Locking a page to one theme
forceTheme is not one prop: it's the same value passed to each piece that writes the theme class
on <html>:
PreventWrongThemeFlashScript, before first paintThemeProvider, after mountuseInitialHtmlThemeProps, in the server-rendered markup
mantle.css holds both themes. If one writer resolves the stored preference instead of the forced
theme, it puts class="dark" on a forceTheme="light" page, :root.dark outranks the light
base, and the page paints dark. Pass the same value everywhere:
The stored preference is still read, and setTheme still writes it, so other pages honor the
user's choice. Only what this page paints is pinned: data-applied-theme and the class are the
forced theme, while data-theme remains the preference. useAppliedTheme returns the forced theme,
and ThemeSwitcher is disabled, because a click cannot change
what this page paints.
That divergence is safe by construction. Every theme block guards its data-theme selector with
:not([data-applied-theme]), so once anything applies a theme, the preference stops selecting one.
Set data-theme on its own and it still picks the theme, exactly as before.
Testing and automation
The theme preference lives in the mantle-ui-theme cookie. ThemeProvider reads the cookie again and applies its theme to <html> on mount, when the tab becomes visible, on pageshow, when another tab changes the theme, and when the OS color scheme changes. ThemeProvider does not read the <html> class, so the next theme change replaces a direct edit to it.
To test a theme in a browser automation script, set the cookie, then reload:
API Reference
ThemeProvider
The ThemeProvider accepts the following props in addition to the PropsWithChildren.
The ThemeProvider uses a hardcoded storage key of mantle-ui-theme and defaults to system theme. The provider manages both values internally, so they need no configuration.
PreventWrongThemeFlashScript
The PreventWrongThemeFlashScript component renders the inline script to prevent FOUC. Place it as early as possible in <head>. It accepts the following props:
preloadFontLink
Preferred for SSR. Returns an HTTP Link header value that preloads a single core font by name. This is the server-side equivalent of PreloadFont — identical in effect, but delivered as an HTTP header so the browser can start the font fetch before it has parsed any HTML, giving better CWV scores on mobile and slow connections.
Returns: A Link header value string, e.g. <https://assets.ngrok.com/fonts/roobert/roobert-proportional-vf.woff2>; rel=preload; as=font; type="font/woff2"; crossorigin.
PreloadFont
The PreloadFont component renders a single preload <link> element for one named core font. Prefer preloadFontLink in SSR apps where you can set HTTP response headers — sending the hint as a header starts the fetch earlier than HTML parsing allows. Use PreloadFont when you have no access to response headers.
CoreFontName values:
extractThemeCookie
Extracts only the theme cookie key-value pair from a full Cookie header string. Pass the result to ThemeProvider and useInitialHtmlThemeProps as ssrCookie. This keeps the other cookies from the request out of loader data.
Returns: The mantle-ui-theme=<value> pair as a string, or undefined if the cookie is not present.
useInitialHtmlThemeProps
The useInitialHtmlThemeProps hook returns props that should be applied to the <html> element to prevent React hydration errors. It accepts the following options:
Returns: An object with className, data-theme, and data-applied-theme props to spread onto your <html> element.
On the server, when ssrCookie is set, the hook reads the theme from the cookie to render the correct class. When omitted, it defaults to system theme (resolved to light). On the client, it reads the current theme from document.cookie to match what the FOUC prevention script applied, so React hydration succeeds.