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.
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:
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:
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:
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.