Skip to main content

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:

  • ThemeProvider holds the preference, saves it in a cookie, and keeps it in sync across tabs. On an ngrok.com or ngrok.ai host, the cookie covers every subdomain of that site.
  • PreventWrongThemeFlashScript puts 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.

tip

To let users change the theme, use ThemeSwitcher. It's a ready-made light/dark toggle for headers and toolbars. For an account menu, use ThemeDropdownMenuItem from the same entrypoint. It is the same light/dark toggle as one menu row.

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.

// entry.server.tsx: send font preloads as HTTP Link headersimport { assetsCdnOrigin, preloadFontLink } from "@ngrok/mantle/theme"; responseHeaders.set(	"Link",	[		`<${assetsCdnOrigin}>; rel=preconnect; crossorigin`,		preloadFontLink("roobert"),		preloadFontLink("jetbrains-mono"),		preloadFontLink("family-regular"),	].join(", "),);

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.

// root.tsx loader: extract the theme cookie so the server renders the stored themeimport { extractThemeCookie } from "@ngrok/mantle/theme"; export async function loader({ request }: Route.LoaderArgs) {	return {		ssrCookie: extractThemeCookie(request.headers.get("Cookie")),		// ...other loader data	};}
// root.tsx: place PreventWrongThemeFlashScript in <head>import {	PreventWrongThemeFlashScript,	ThemeProvider,	useInitialHtmlThemeProps,} from "@ngrok/mantle/theme"; export function Layout({ children }: PropsWithChildren) {	const loaderData = useRouteLoaderData<typeof loader>("root");	const initialHtmlThemeProps = useInitialHtmlThemeProps({		className: "h-full",		ssrCookie: loaderData?.ssrCookie,	}); 	return (		<html {...initialHtmlThemeProps} lang="en-US" dir="ltr" suppressHydrationWarning>			<head>				{/* 👇 add as early as possible in <head> to prevent FOUC */}				<PreventWrongThemeFlashScript nonce={nonce} />				<meta charSet="utf-8" />				<meta name="viewport" content="width=device-width, initial-scale=1" />				<Meta />				<Links />			</head>			<body>				<ThemeProvider ssrCookie={loaderData?.ssrCookie}>{children}</ThemeProvider>			</body>		</html>	);}

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.

import { PreloadFont, PreventWrongThemeFlashScript } from "@ngrok/mantle/theme"; <head>	<PreventWrongThemeFlashScript nonce={nonce} />	<PreloadFont name="roobert" />	<PreloadFont name="jetbrains-mono" />	<PreloadFont name="family-regular" /></head>;

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.

<!-- 1. FOUC prevention: runs immediately, reads the stored theme from the cookie,				and writes the theme class, data-theme, and data-applied-theme onto <html>.				Generated by: preventWrongThemeFlashScriptContent() from "@ngrok/mantle/theme" --><script nonce="...">	(function z({ storageKey: e, themeByCookieValue: t, forceTheme: n }) {		let r = `system`;		try {			let n = document.cookie					.split(`;`)					.map((e) => e.trim())					.find((t) => t.startsWith(`${e}=`)),				i = decodeURIComponent(n?.slice(e.length + 1) ?? ``);			Object.hasOwn(t, i) && (r = t[i] ?? `system`);		} catch {}		let i = matchMedia(`(prefers-color-scheme: dark)`).matches ? `dark` : `light`;		n == null ? r !== `system` && (i = r) : (i = n);		let a = document.documentElement;		(a.classList.remove(`light`, `dark`),			a.classList.add(i),			(a.dataset.theme = r),			(a.dataset.appliedTheme = i));	})({		storageKey: "mantle-ui-theme",		themeByCookieValue: {			system: "system",			light: "light",			dark: "dark",			"light-high-contrast": "light",			"dark-high-contrast": "dark",		},	});</script>

note

preventWrongThemeFlashScriptContent() from @ngrok/mantle/theme generates this script string. The script above is its output for the version of mantle this page was built with, so you can copy it as it is. Run the generator again after you upgrade mantle to pick up any changes.

Usage

Drop in ThemeSwitcher for a one-click light/dark toggle:

import { ThemeSwitcher } from "@ngrok/mantle/theme-switcher"; <ThemeSwitcher />;

To build your own control, read and set the preference with the useTheme hook:

import { Select } from "@ngrok/mantle/select";import { $theme, isTheme, useTheme } from "@ngrok/mantle/theme";import { SunIcon } from "@phosphor-icons/react/Sun"; function App() {	const [currentTheme, setTheme] = useTheme(); 	return (		<Select.Root			value={currentTheme}			onValueChange={(value) => {				if (isTheme(value)) {					setTheme(value);				}			}}		>			<Select.Trigger className="w-min" aria-label="Theme">				<SunIcon className="mr-1 h-6 w-6" />			</Select.Trigger>			<Select.Content>				<Select.Group>					<Select.Label>Choose a theme</Select.Label>					<Select.Item value={$theme("system")}>System</Select.Item>					<Select.Item value={$theme("light")}>Light</Select.Item>					<Select.Item value={$theme("dark")}>Dark</Select.Item>				</Select.Group>			</Select.Content>		</Select.Root>	);}

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.

const [, setTheme] = useTheme(); <Button type="button" onClick={(event) => setTheme("dark", { origin: event.currentTarget })}>	Use Dark theme</Button>;

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.

import { Button } from "@ngrok/mantle/button"; <aside className="invert-theme bg-base text-strong rounded-2xl p-4 shadow-lg">	<p>This panel renders dark on light pages, light on dark pages.</p>	<Button appearance="filled" intent="neutral">		Save	</Button></aside>;

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. Pass invert-theme in the floating part's className to invert it too.
  • Theme variant utilities do not flip. dark:* and light:* 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-600 rather than --color-accent-600 and the change flows into islands too.
  • Nesting is idempotent. An invert-theme inside 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 @theme palette 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 paint
  • ThemeProvider, after mount
  • useInitialHtmlThemeProps, 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:

// root.tsxconst FORCED_THEME = "light" as const; export default function Root() {	const htmlProps = useInitialHtmlThemeProps({ forceTheme: FORCED_THEME, ssrCookie }); 	return (		<html {...htmlProps}>			<head>				<PreventWrongThemeFlashScript forceTheme={FORCED_THEME} nonce={nonce} />			</head>			<body>				<ThemeProvider forceTheme={FORCED_THEME} ssrCookie={ssrCookie}>					<App />				</ThemeProvider>			</body>		</html>	);}

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:

await context.addCookies([	{ name: "mantle-ui-theme", value: "dark", url: "http://localhost:3000" },]);await page.reload();

API Reference

ThemeProvider

The ThemeProvider accepts the following props in addition to the PropsWithChildren.

PropTypeDefaultDescription
childrenReactNodeThe React components to be wrapped by the theme provider context.
forceTheme?ResolvedThemePin the theme that renders, ignoring the stored preference and the OS. See Locking a page to one theme — pass the same value to every component that forces a theme.
ssrCookie?stringThe theme cookie pair from the request, via extractThemeCookie. On the server, the provider seeds the theme from it, so useTheme and useAppliedTheme hydrate without a mismatch. When omitted, the server renders system. Pass the same value you pass useInitialHtmlThemeProps.

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:

PropTypeDefaultDescription
forceTheme?ResolvedThemePin the theme this page paints in, before first paint. See Locking a page to one theme.
nonce?stringAn optional CSP nonce to allowlist the inline FOUC prevention script.

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.

import { assetsCdnOrigin, preloadFontLink } from "@ngrok/mantle/theme"; // In your server entry (e.g. entry.server.tsx):headers.set(	"Link",	[		`<${assetsCdnOrigin}>; rel=preconnect; crossorigin`,		preloadFontLink("roobert"),		preloadFontLink("jetbrains-mono"),	].join(", "),);
ParameterTypeDescription
nameCoreFontNameThe name of the individual font to preload.

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.

import { PreloadFont } from "@ngrok/mantle/theme"; <head>	<PreloadFont name="roobert" />	<PreloadFont name="jetbrains-mono" /></head>;
PropTypeDefaultDescription
nameCoreFontNameThe name of the individual font to preload.

CoreFontName values:

ValueFont
"roobert"Roobert proportional variable font
"jetbrains-mono"JetBrains Mono variable weight
"jetbrains-mono-italic"JetBrains Mono italic variable weight
"family-regular"Family regular
"family-italic"Family italic

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.

import { extractThemeCookie } from "@ngrok/mantle/theme"; // In your root loader:const ssrCookie = extractThemeCookie(request.headers.get("Cookie"));// e.g. "mantle-ui-theme=dark" or undefined
ParameterTypeDescription
cookieHeaderstring | null | undefinedThe raw Cookie request header value.

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:

PropTypeDefaultDescription
className?string""Additional CSS classes to apply to the <html> element. The hook combines these with the theme class.
forceTheme?ResolvedThemePin the resolved theme in the returned props. See Locking a page to one theme.
ssrCookie?stringTheme cookie string to read the persisted theme during SSR. Pass only the theme cookie pair (via extractThemeCookie) rather than the full raw Cookie header to avoid leaking sensitive cookies.

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.