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 } from "@ngrok/mantle/theme";
export default function App() {
return (
<html className="h-full" 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() {
const RESOLVED = ["light","dark","light-high-contrast","dark-high-contrast"];
const DEF = "system";
const KEY = "mantle-ui-theme";
const doc = document, root = doc.documentElement;
function isTheme(v) {
return typeof v === "string" && (v === "system" || RESOLVED.indexOf(v) > -1);
}
function readCookie(name){
// Efficient single-pass cookie lookup: "; name=value"
const all = "; " + doc.cookie, token = "; " + name + "=";
const startIdx = all.indexOf(token);
if (startIdx < 0) {
return null;
}
const endIdx = all.indexOf(";", startIdx + token.length);
const rawValue = all.slice(startIdx + token.length, endIdx < 0 ? void 0 : endIdx) || null;
try {
return rawValue ? decodeURIComponent(rawValue) : null;
} catch(_) {
return rawValue;
}
}
function writeCookie(name, val) {
try {
const expires = new Date();
expires.setFullYear(expires.getFullYear() + 1);
const hostname = location.hostname;
const protocol = location.protocol;
const isDotNgrok = (hostname === "ngrok.com" || hostname.endsWith(".ngrok.com"));
const domain = isDotNgrok ? "; domain=.ngrok.com" : "";
const secure = protocol === "https:" ? "; Secure" : "";
doc.cookie = name + "=" + encodeURIComponent(val) + "; expires=" + expires.toUTCString() + "; path=/" + domain + "; SameSite=Lax" + secure;
} catch(_) {}
}
// 1) Read preference: cookie first, fallback to localStorage (migration support)
let cookieTheme = null, lsTheme = null, storedTheme = null;
try {
cookieTheme = readCookie(KEY);
} catch(_) {}
if (isTheme(cookieTheme)) {
storedTheme = cookieTheme;
} else {
try {
lsTheme = window.localStorage && window.localStorage.getItem(KEY);
} catch(_) {}
if (isTheme(lsTheme)) {
storedTheme = lsTheme;
}
}
const preference = isTheme(storedTheme) ? storedTheme : DEF;
// 2) Resolve only when needed to avoid unnecessary media queries
let resolvedTheme = preference;
if (preference === "system") {
const isDark = matchMedia("(prefers-color-scheme: dark)").matches;
const isHighContrast = matchMedia("(prefers-contrast: more)").matches;
resolvedTheme = isHighContrast
? (isDark ? "dark-high-contrast" : "light-high-contrast")
: (isDark ? "dark" : "light");
}
// 3) Only touch DOM if we actually need to change something (SSR optimization)
if (root.dataset.appliedTheme !== resolvedTheme || root.dataset.theme !== preference) {
// Remove all theme classes, add the correct one
for (let i = 0; i < RESOLVED.length; i++) {
root.classList.remove(RESOLVED[i]);
}
root.classList.add(resolvedTheme);
root.dataset.appliedTheme = resolvedTheme;
root.dataset.theme = preference;
}
// 4) Handle persistence/migration synchronously to prevent FOUC
const hadValidCookie = isTheme(cookieTheme);
try {
// Migrate from localStorage to cookies if needed
if (isTheme(lsTheme)) {
writeCookie(KEY, lsTheme);
try {
window.localStorage.removeItem(KEY);
} catch(_) {}
} else if (!hadValidCookie) {
// Set default cookie if none existed
writeCookie(KEY, preference);
}
} catch (_) {}
})();
</script>
Font Preloading
You will also need to ensure that you add the PreloadFonts
component to your app as well if you're using the custom setup.
index.html
<head>
<link rel="preconnect" href="https://assets.ngrok.com"/>
<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-Semibold-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"/>
</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.
MantleThemeHeadContent
The MantleThemeHeadContent
component prevents Flash of Unstyled Content (FOUC) and preloads fonts. It accepts the following props: