Skip to main content

Command Palette

Search for a command to run...

ThemeProvider is a React Context Provider that provides the current theme to the application and a function to change it.

Setup

To use the ThemeProvider, wrap your application's entry point. This should be done as high in the component tree as possible.

You should also add the MantleThemeHeadContent component to the head of your application to prevent a Flash of Unstyled Content (FOUC) when the app first loads as well as preload all of our custom fonts. This is the recommended approach for most applications.

root.tsx

import { MantleThemeHeadContent, ThemeProvider, useInitialHtmlThemeProps } from "@ngrok/mantle/theme";

export default function App() {
// 👇 get the initial theme props to prevent hydration errors
const initialHtmlThemeProps = useInitialHtmlThemeProps({
className: "h-full",
});

return (
// 👇 spread the theme props onto the <html> element
<html {...initialHtmlThemeProps} lang="en-US" dir="ltr">
<head>
// 👇 add this as high in the <head> as possible!
 <MantleThemeHeadContent />
 <meta charSet="utf-8" />
<meta name="author" content="ngrok" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
 <body className="h-full min-h-full overflow-y-scroll bg-body">
	 // 👇 wrap your app entry in the ThemeProvider
		<ThemeProvider>
			<Outlet />
			</ThemeProvider>
				</body>
			</html>
		);
	}

Custom Head Content

Only use this section if you cannot use MantleThemeHeadContent.

Sometimes you cannot use the MantleThemeHeadContent component because your web server is not able to render React components. In this case, you can copy the following script and add it to your application's <head>:

index.html

<script>
(function preventThemeFlash(args) {
	const {
		storageKey,
		defaultTheme,
		themes: themes2,
		resolvedThemes: resolvedThemes2,
		prefersDarkModeMediaQuery: prefersDarkModeMediaQuery2,
		prefersHighContrastMediaQuery: prefersHighContrastMediaQuery2
	} = args;
	function isTheme2(value) {
		return typeof value === "string" && themes2.includes(value);
	}
	function getThemeFromCookie(name) {
		const cookie = document.cookie;
		if (!cookie) {
			return null;
		}
		try {
			const cookies = cookie.split(";");
			const themeCookie = cookies.find((c2) => c2.trim().startsWith(`${name}=`));
			const cookieValue = themeCookie?.split("=")[1];
			const storedTheme2 = cookieValue ? decodeURIComponent(cookieValue) : null;
			return storedTheme2;
		} catch (_2) {
			return null;
		}
	}
	function buildCookie(name, val) {
		const expires = /* @__PURE__ */ new Date();
		expires.setFullYear(expires.getFullYear() + 1);
		const hostname = location.hostname;
		const protocol = location.protocol;
		const domainAttribute = hostname === "ngrok.com" || hostname.endsWith(".ngrok.com") ? "; domain=.ngrok.com" : "";
		const secureAttribute = protocol === "https:" ? "; Secure" : "";
		return `${name}=${encodeURIComponent(val)}; expires=${expires.toUTCString()}; path=/${domainAttribute}; SameSite=Lax${secureAttribute}`;
	}
	function writeCookie(name, val) {
		try {
			document.cookie = buildCookie(name, val);
		} catch (_2) {
		}
	}
	function resolveThemeValue(theme, isDark2, isHighContrast2) {
		if (theme === "system") {
			if (isHighContrast2) {
				return isDark2 ? "dark-high-contrast" : "light-high-contrast";
			}
			return isDark2 ? "dark" : "light";
		}
		return theme;
	}
	let cookieTheme = null;
	let lsTheme = null;
	let storedTheme = null;
	try {
		cookieTheme = getThemeFromCookie(storageKey);
	} catch (_2) {
	}
	if (isTheme2(cookieTheme)) {
		storedTheme = cookieTheme;
	} else {
		try {
			lsTheme = window.localStorage?.getItem(storageKey) ?? null;
		} catch (_2) {
		}
		if (isTheme2(lsTheme)) {
			storedTheme = lsTheme;
		}
	}
	const preference = isTheme2(storedTheme) ? storedTheme : defaultTheme;
	const isDark = matchMedia(prefersDarkModeMediaQuery2).matches;
	const isHighContrast = matchMedia(prefersHighContrastMediaQuery2).matches;
	const resolvedTheme = resolveThemeValue(preference, isDark, isHighContrast);
	const html = document.documentElement;
	if (html.dataset.appliedTheme !== resolvedTheme || html.dataset.theme !== preference) {
		for (const themeClass of resolvedThemes2) {
			html.classList.remove(themeClass);
		}
		html.classList.add(resolvedTheme);
		html.dataset.theme = preference;
		html.dataset.appliedTheme = resolvedTheme;
	}
	const hadValidCookie = isTheme2(cookieTheme);
	try {
		if (isTheme2(lsTheme) && !hadValidCookie) {
			writeCookie(storageKey, lsTheme);
			try {
				window.localStorage.removeItem(storageKey);
			} catch (_2) {
			}
		} else if (!hadValidCookie) {
			writeCookie(storageKey, preference);
		}
	} catch (_2) {
	}
})({"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>

Font Preloading

You will also need to ensure that you add the PreloadCoreFonts component to your app as well if you're using the custom setup.

index.html

<head>
	<link rel="preload" href="https://assets.ngrok.com/fonts/euclid-square/EuclidSquare-Regular-WebS.woff" as="font" type="font/woff" crossorigin="anonymous"/>
	<link rel="preload" href="https://assets.ngrok.com/fonts/euclid-square/EuclidSquare-RegularItalic-WebS.woff" as="font" type="font/woff" crossorigin="anonymous"/>
	<link rel="preload" href="https://assets.ngrok.com/fonts/euclid-square/EuclidSquare-Medium-WebS.woff" as="font" type="font/woff" crossorigin="anonymous"/>
	<link rel="preload" href="https://assets.ngrok.com/fonts/euclid-square/EuclidSquare-MediumItalic-WebS.woff" as="font" type="font/woff" crossorigin="anonymous"/>
	<link rel="preload" href="https://assets.ngrok.com/fonts/ibm-plex-mono/IBMPlexMono-Text.woff" as="font" type="font/woff" crossorigin="anonymous"/>
	<link rel="preload" href="https://assets.ngrok.com/fonts/ibm-plex-mono/IBMPlexMono-TextItalic.woff" as="font" type="font/woff" crossorigin="anonymous"/>
	<link rel="preload" href="https://assets.ngrok.com/fonts/ibm-plex-mono/IBMPlexMono-SemiBold.woff" as="font" type="font/woff" crossorigin="anonymous"/>
	<link rel="preload" href="https://assets.ngrok.com/fonts/ibm-plex-mono/IBMPlexMono-SemiBoldItalic.woff" as="font" type="font/woff" crossorigin="anonymous"/>
	<link rel="preload" href="https://assets.ngrok.com/fonts/inter/Inter-VariableFont.ttf" as="font" type="font/ttf" crossorigin="anonymous"/>
	<link rel="preload" href="https://assets.ngrok.com/fonts/inter/Inter-Italic-VariableFont.ttf" as="font" type="font/ttf" crossorigin="anonymous"/>
</head>

Usage

In your application, you can use the useTheme hook to get and change the current theme:

app.tsx

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... */}
		</>
	);
}

API Reference

ThemeProvider

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

PropTypeDefaultDescription
children
ReactNode

The React components to be wrapped by the theme provider context.

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.

MantleThemeHeadContent

The MantleThemeHeadContent component prevents Flash of Unstyled Content (FOUC) and preloads core fonts. It accepts the following props:

PropTypeDefaultDescription
nonce
string

An optional CSP nonce to allowlist the inline FOUC prevention script. Using this helps avoid the CSP unsafe-inline directive, which disables XSS protection and would allowlist all inline scripts or styles.

Note: The FOUC prevention script uses hardcoded values for storage key (mantle-ui-theme) and default theme (system) to ensure consistency with ThemeProvider.

PreventWrongThemeFlashScript

The PreventWrongThemeFlashScript component renders only the inline script to prevent FOUC. Use this when you want full control over font preloading. It accepts the following props:

PropTypeDefaultDescription
nonce
string

An optional CSP nonce to allowlist the inline FOUC prevention script.

PreloadCoreFonts

The PreloadCoreFonts component renders preload links for the core fonts used in Mantle (Euclid Square and IBM Plex Mono). This component takes no props and is automatically included in MantleThemeHeadContent.

PreloadInterFonts

The PreloadInterFonts component renders preload links for the optional Inter variable fonts (roman and italic). Use this when your UI opts into the Inter typeface. This component takes no props.

root.tsx

import { MantleThemeHeadContent, PreloadInterFonts } from "@ngrok/mantle/theme";

<head>
	<MantleThemeHeadContent />
	<PreloadInterFonts />
</head>

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. These will be combined with the theme class.

Returns: An object with className, data-theme, and data-applied-theme props to spread onto your <html> element.

Note: On the server, this defaults to system theme (resolved to light). On the client, it reads the current theme from cookies to match what the FOUC prevention script applied, ensuring React hydration succeeds.