Mantle's theme system provides ThemeProvider (context + cookie persistence), MantleStyleSheets (lazy-loaded dark/HC CSS via <link media>), and PreventWrongThemeFlashScript (FOUC prevention inline script) for light, dark, and high-contrast modes.
To use the ThemeProvider, wrap your application's entry point. This should be done as high in the component tree as possible.
For SSR apps, add PreventWrongThemeFlashScript and MantleStyleSheets to the <head> to prevent a Flash of Unstyled Content (FOUC), and send font preloads as HTTP Link response headers using preloadFontLink. Delivering font hints as headers starts fetches before the browser parses any HTML, improving LCP on mobile.
MantleStyleSheets renders <link rel="stylesheet"> tags for the dark, light-high-contrast, and dark-high-contrast theme CSS files. Each stylesheet is gated behind a media attribute matching its OS preference so it is non-render-blocking for users who do not need it. Pass ssrCookie (from extractThemeCookie) so the server can render the correct media attribute for users with a manually-selected theme — when a non-system theme is resolved from the cookie, the SSR HTML is already correct and no inline fix script is needed, avoiding FOUC without relying on JavaScript hydration.
The CSS URLs must be passed as props. Import them with ?url in your app code so Vite's URL transform applies correctly:
// entry.server.tsx — send font preloads as HTTP Link headers
import { assetsCdnOrigin, preloadFontLink } from "@ngrok/mantle/theme";
responseHeaders.set(
"Link",
[
`<${assetsCdnOrigin}>; rel=preconnect; crossorigin`,
preloadFontLink("roobert"),
preloadFontLink("jetbrains-mono"),
preloadFontLink("family-regular"),
].join(", "),
);// root.tsx loader — extract the theme cookie so the server can render the correct media attribute
import { 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 and MantleStyleSheets in <head>
import darkCssUrl from "@ngrok/mantle/mantle-dark.css?url";
import darkHighContrastCssUrl from "@ngrok/mantle/mantle-dark-high-contrast.css?url";
import lightHighContrastCssUrl from "@ngrok/mantle/mantle-light-high-contrast.css?url";
import {
extractThemeCookie,
mantleStyleSheetUrls,
MantleStyleSheets,
PreventWrongThemeFlashScript,
ThemeProvider,
useInitialHtmlThemeProps,
} from "@ngrok/mantle/theme";
const themeUrls = mantleStyleSheetUrls({
darkCssUrl,
lightHighContrastCssUrl,
darkHighContrastCssUrl,
});
export function Layout({ children }: PropsWithChildren) {
const loaderData = useRouteLoaderData<typeof loader>("root");
const initialHtmlThemeProps = useInitialHtmlThemeProps({
className: "h-full",
});
return (
<html {...initialHtmlThemeProps} lang="en-US" dir="ltr" suppressHydrationWarning>
<head>
{/* 👇 add as early as possible in <head> to prevent FOUC */}
<PreventWrongThemeFlashScript nonce={nonce} />
{/* 👇 lazy-loads dark/HC theme stylesheets; media attrs prevent render-blocking */}
<MantleStyleSheets {...themeUrls} nonce={nonce} ssrCookie={loaderData?.ssrCookie} />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}If you cannot set HTTP response headers (e.g. a plain Vite SPA), omit ssrCookie from MantleStyleSheets and use PreloadFont elements instead of preloadFontLink. The inline fix script inside MantleStyleSheets still handles FOUC correctly at runtime.
import darkCssUrl from "@ngrok/mantle/mantle-dark.css?url";
import darkHighContrastCssUrl from "@ngrok/mantle/mantle-dark-high-contrast.css?url";
import lightHighContrastCssUrl from "@ngrok/mantle/mantle-light-high-contrast.css?url";
import {
mantleStyleSheetUrls,
MantleStyleSheets,
PreloadFont,
PreventWrongThemeFlashScript,
} from "@ngrok/mantle/theme";
const themeUrls = mantleStyleSheetUrls({
darkCssUrl,
lightHighContrastCssUrl,
darkHighContrastCssUrl,
});
<head>
<PreventWrongThemeFlashScript nonce={nonce} />
<MantleStyleSheets {...themeUrls} nonce={nonce} />
<PreloadFont name="roobert" />
<PreloadFont name="jetbrains-mono" />
<PreloadFont name="family-regular" />
</head>;If your server cannot render React components (e.g. a legacy Go service), inline the two script strings directly into your HTML <head>, surrounding the three theme <link> tags. Generate the strings via preventWrongThemeFlashScriptContent and fixMediaScriptContent from @ngrok/mantle/theme — run them in a Node.js build step and embed the output into your template.
The FOUC prevention script must run before <body> to write html[data-applied-theme]. The media fix script must run after the <link> tags so they are already in the DOM when it executes.
<!-- 1. FOUC prevention: runs immediately, reads the stored theme from cookie/localStorage
and writes data-theme + data-applied-theme onto <html> before first paint.
Generated by: preventWrongThemeFlashScriptContent() from "@ngrok/mantle/theme" -->
<script nonce="...">
(function k(e) {
let {
storageKey: t,
defaultTheme: n,
themes: r,
resolvedThemes: i,
prefersDarkModeMediaQuery: a,
prefersHighContrastMediaQuery: o,
} = e;
function s(e) {
return typeof e == `string` && r.includes(e);
}
function c(e) {
let t = document.cookie;
if (!t) return null;
try {
let n = t
.split(`;`)
.find((t) => t.trim().startsWith(`${e}=`))
?.split(`=`)[1];
return n ? decodeURIComponent(n) : null;
} catch {
return null;
}
}
function l(e, t) {
let n = new Date();
n.setFullYear(n.getFullYear() + 1);
let r = window.location.hostname,
i = window.location.protocol,
a = r === `ngrok.com` || r.endsWith(`.ngrok.com`) ? `; domain=.ngrok.com` : ``,
o = i === `https:` ? `; Secure` : ``;
return `${e}=${encodeURIComponent(t)}; expires=${n.toUTCString()}; path=/${a}; SameSite=Lax${o}`;
}
function u(e, t) {
try {
document.cookie = l(e, t);
} catch {}
}
function d(e, t, n) {
return e === `system`
? n
? t
? `dark-high-contrast`
: `light-high-contrast`
: t
? `dark`
: `light`
: e;
}
let f = null,
p = null,
m = null;
try {
f = c(t);
} catch {}
if (s(f)) m = f;
else {
try {
p = window.localStorage?.getItem(t) ?? null;
} catch {}
s(p) && (m = p);
}
let h = s(m) ? m : n,
g = matchMedia(a).matches,
_ = matchMedia(o).matches,
v = d(h, g, _),
y = document.documentElement;
if (y.dataset.appliedTheme !== v || y.dataset.theme !== h) {
for (let e of i) y.classList.remove(e);
(y.classList.add(v), (y.dataset.theme = h), (y.dataset.appliedTheme = v));
}
let b = s(f);
try {
if (s(p) && !b) {
u(t, p);
try {
window.localStorage.removeItem(t);
} catch {}
} else b || u(t, h);
} catch {}
})({
storageKey: "mantle-ui-theme",
defaultTheme: "system",
themes: ["system", "light", "dark", "light-high-contrast", "dark-high-contrast"],
resolvedThemes: ["light", "dark", "light-high-contrast", "dark-high-contrast"],
prefersDarkModeMediaQuery: "(prefers-color-scheme: dark)",
prefersHighContrastMediaQuery: "(prefers-contrast: more)",
});
</script>
<!-- 2. Dark theme stylesheet — non-render-blocking for light-mode users -->
<link
id="mantle-dark-styles"
rel="stylesheet"
href="/path/to/mantle-dark.css"
media="(prefers-color-scheme: dark)"
/>
<!-- 3. High-contrast stylesheets — non-render-blocking unless the OS requests high contrast -->
<link
id="mantle-light-high-contrast-styles"
rel="stylesheet"
href="/path/to/mantle-light-high-contrast.css"
media="(prefers-contrast: more) and (prefers-color-scheme: light)"
/>
<link
id="mantle-dark-high-contrast-styles"
rel="stylesheet"
href="/path/to/mantle-dark-high-contrast.css"
media="(prefers-contrast: more) and (prefers-color-scheme: dark)"
/>
<!-- 4. Media fix: runs after the <link> tags, reads data-applied-theme from <html> and
corrects each stylesheet's media attribute so manually-selected themes load correctly.
Generated by: fixMediaScriptContent() from "@ngrok/mantle/theme" -->
<script nonce="...">
(function D(e) {
let {
darkLinkId: t,
lightHcLinkId: n,
darkHcLinkId: r,
mediaDark: i,
mediaLightHc: a,
mediaDarkHc: o,
forceTheme: s,
} = e,
c = document.documentElement.dataset.appliedTheme,
l = s ?? c,
u = document.getElementById(t),
d = document.getElementById(n),
f = document.getElementById(r);
(u && (u.media = l === `dark` ? `all` : i),
d && (d.media = l === `light-high-contrast` ? `all` : a),
f && (f.media = l === `dark-high-contrast` ? `all` : o));
})({
darkLinkId: "mantle-dark-styles",
lightHcLinkId: "mantle-light-high-contrast-styles",
darkHcLinkId: "mantle-dark-high-contrast-styles",
mediaDark: "(prefers-color-scheme: dark)",
mediaLightHc: "(prefers-contrast: more) and (prefers-color-scheme: light)",
mediaDarkHc: "(prefers-contrast: more) and (prefers-color-scheme: dark)",
});
</script>note
These script strings are generated bypreventWrongThemeFlashScriptContent() and fixMediaScriptContent() from @ngrok/mantle/theme. Re-run the generator after upgrading mantle to pick up any changes.In your application, you can use the useTheme hook to get and change the current theme:
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
} from "@ngrok/mantle/select";
import { isTheme, theme, useTheme } from "@ngrok/mantle/theme";
function App() {
const [currentTheme, setTheme] = useTheme();
return (
<>
<Select
value={currentTheme}
onValueChange={(value) => {
const maybeNewTheme = isTheme(value) ? value : undefined;
if (maybeNewTheme) {
setTheme(maybeNewTheme);
}
}}
>
<div className="ml-auto">
<span className="sr-only">Theme Switcher</span>
<SelectTrigger className="w-min">
<Sun className="mr-1 h-6 w-6" />
</SelectTrigger>
</div>
<SelectContent>
<SelectGroup>
<SelectLabel>Choose a theme</SelectLabel>
<SelectItem value={theme("system")}>System</SelectItem>
<SelectItem value={theme("light")}>Light</SelectItem>
<SelectItem value={theme("dark")}>Dark</SelectItem>
<SelectItem value={theme("light-high-contrast")}>Light High Contrast</SelectItem>
<SelectItem value={theme("dark-high-contrast")}>Dark High Contrast</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{/* The rest of your app... */}
</>
);
}The ThemeProvider accepts the following props in addition to the PropsWithChildren.
Note: The ThemeProvider uses a hardcoded storage key of mantle-ui-theme and defaults to system theme. These values are managed internally and do not require configuration.
Renders <link rel="stylesheet"> tags for the dark, light-high-contrast, and dark-high-contrast theme CSS files. Each stylesheet uses a media attribute matching its OS preference — it is non-render-blocking for users whose OS does not match. Place this component in <head>, immediately after <PreventWrongThemeFlashScript>.
An inline <script> is also rendered after the <link> tags (when needed). It runs synchronously before first paint and corrects any media attributes for users with a manually-selected theme, preventing FOUC without needing JavaScript hydration.
When forceTheme is set to a non-light theme, only the link tag for that theme is rendered — the others are omitted to avoid unnecessary network requests. forceTheme="light" renders no link tags since light is the base theme with no dedicated lazy stylesheet.
Use mantleStyleSheetUrls to create the required CSS URL props and spread them in.
Collects the three Vite ?url imports for mantle's theme stylesheets into a typed object ready to spread into <MantleStyleSheets>. Import the CSS files with ?url in your app code (not from a library) so Vite's asset transform applies:
import darkCssUrl from "@ngrok/mantle/mantle-dark.css?url";
import darkHighContrastCssUrl from "@ngrok/mantle/mantle-dark-high-contrast.css?url";
import lightHighContrastCssUrl from "@ngrok/mantle/mantle-light-high-contrast.css?url";
import { mantleStyleSheetUrls } from "@ngrok/mantle/theme";
const themeUrls = mantleStyleSheetUrls({
darkCssUrl,
lightHighContrastCssUrl,
darkHighContrastCssUrl,
});
// <MantleStyleSheets {...themeUrls} nonce={nonce} ssrCookie={ssrCookie} />Returns: A MantleThemeCssUrls object ready to spread into <MantleStyleSheets>.
Returns the raw JavaScript string for the inline <script> that fixes theme stylesheet media attributes after the <link> tags are parsed. This is the non-React equivalent of the inline fix script that MantleStyleSheets renders — use it when you need to embed the script directly into SSR HTML outside of a React context (e.g. a legacy Go service).
Place the output in a <script> tag immediately after the three theme <link> tags in your HTML <head>.
import { fixMediaScriptContent } from "@ngrok/mantle/theme";
// No forced theme — script reads html[data-applied-theme] at runtime
const scriptContent = fixMediaScriptContent();
// Force dark theme — script always sets the dark stylesheet to media="all"
const darkScriptContent = fixMediaScriptContent("dark");Returns: A JavaScript string ready to embed inside a <script> tag.
The PreventWrongThemeFlashScript component renders the inline script to prevent FOUC. Place it as early as possible in <head>. It accepts the following props:
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(", "),
);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.
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>;CoreFontName values:
Extracts just the theme cookie key-value pair from a full Cookie header string. Pass the result to MantleStyleSheets and useInitialHtmlThemeProps as ssrCookie — this avoids exposing other cookies from the request in 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 undefinedReturns: The mantle-ui-theme=<value> pair as a string, or undefined if the cookie is not present.
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.
Note: On the server, when ssrCookie is provided, 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, ensuring React hydration succeeds.