Skip to main content

Mantle

Mantle is ngrok's UI library and design system that powers its front-end.

Overview

Mantle is a carefully designed system of React components and utilities that establishes a unified design language and consistent user experience across ngrok's web applications. Built with flexibility, extensibility, and developer ergonomics in mind, Mantle prioritizes accessibility, performance, and long-term maintainability. Developed in TypeScript, it offers strong typing, rich IDE support, and increased confidence through compile-time safety. By progressively enhancing standard DOM elements, it not only improves usability and accessibility, but also fills functional gaps. The result is a stable foundation for building cohesive, modern UIs throughout the platform.

To learn more about the design principles and architectural decisions that guide Mantle, see the Philosophy page.

All of Mantle's components are styled using Tailwind. and we compose around the following unstyled primitive component libraries:

Mantle uses Phosphor Icons as the primary icon library, a versatile and consistent set of icons. In addition, custom-designed icons tailored to ngrok's needs are available through the @ngrok/mantle/icons module.

Status

Mantle is a living design system and the standard UI library for all ngrok user interfaces. It is continuously evolving as new components, patterns, and improvements are added.

Mantle is available on NPM and is open source on GitHub.

Setup

Installation

note

Mantle requires react and react-dom version 19.

Install @ngrok/mantle and all required peerDependencies:

install required mantle peerDependencies

# npmnpm install -E @ngrok/mantle @phosphor-icons/react react react-dom # pnpmpnpm add -E @ngrok/mantle @phosphor-icons/react react react-dom # bunbun add -E @ngrok/mantle @phosphor-icons/react react react-dom

Install tailwindcss as a dev dependency (all frameworks):

# npmnpm install -DE tailwindcss # pnpmpnpm add -DE tailwindcss # bunbun add -DE tailwindcss

Stylesheet

Create one CSS entry file, app.css, and import both mantle stylesheets from it. Each framework section below says where the file lives.

/* app.css */@import "@ngrok/mantle/mantle.css";@import "@ngrok/mantle/source-all.css";

mantle.css imports Tailwind and the theme tokens for both themes. source-all.css holds one @source directive that points Tailwind at mantle's dist/ directory. Tailwind 4 skips node_modules when it scans for class names, so without that directive it generates none of the classes mantle's components use. The components then render without their styles: an open Dialog, for example, shows its content in the page flow with no backdrop.

warning

Import source-all.css from the same CSS file as mantle.css, not from JavaScript. Tailwind compiles each CSS entry on its own, so an @source in a separate file adds nothing to the stylesheet that holds mantle.css.

Add your own @source directives to the same file when you keep components in a workspace package that Tailwind's automatic detection can't see.

In production, source-all.css makes Tailwind scan every file in mantle's dist/, so the stylesheet carries the utilities of every mantle component, whether your app renders it or not. On an app that renders Badge alone, that's 181 KB of CSS where 71 KB would do. If you build with Vite, add mantleSourcesPlugin from @ngrok/mantle-vite-plugins to vite.config.ts to ship only the classes your bundle uses. The CSS above stays as it is.

React Router

Install the additional dev dependencies for the Tailwind Vite plugin:

# npmnpm install -DE @tailwindcss/vite # pnpmpnpm add -DE @tailwindcss/vite # bunbun add -DE @tailwindcss/vite

Add the @tailwindcss/vite plugin to your Vite configuration:

// vite.config.tsimport { reactRouter } from "@react-router/dev/vite";import { defineConfig } from "vite";import tsconfigPaths from "vite-tsconfig-paths";import tailwindcss from "@tailwindcss/vite"; export default defineConfig({	plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],});

In your app's app/root.tsx, import app.css (created in Stylesheet, here at app/app.css) and set up the Theme Provider, Toaster, and Tooltip Provider.

warning

It is critical to include PreventWrongThemeFlashScript early in the <head> of your app to prevent a flash of unstyled content (FOUC). For SSR apps, also send font preloads as HTTP Link headers using preloadFontLink so fonts start fetching before the browser parses any HTML.
// app/entry.server.tsx — send font preload Link headersimport { assetsCdnOrigin, preloadFontLink } from "@ngrok/mantle/theme"; // inside your handleRequest / onShellReady:responseHeaders.set(	"Link",	[		`<${assetsCdnOrigin}>; rel=preconnect; crossorigin`,		preloadFontLink("roobert"),		preloadFontLink("jetbrains-mono"),		preloadFontLink("family-regular"),	].join(", "),);
// app/root.tsximport {	PreventWrongThemeFlashScript,	ThemeProvider,	useInitialHtmlThemeProps,} from "@ngrok/mantle/theme";import { Toaster } from "@ngrok/mantle/toast";import { TooltipProvider } from "@ngrok/mantle/tooltip";import type { PropsWithChildren } from "react";import {	isRouteErrorResponse,	Links,	Meta,	Outlet,	Scripts,	ScrollRestoration,} from "react-router"; import type { Route } from "./+types/root";import "./app.css"; export function Layout({ children }: PropsWithChildren) {	const initialHtmlThemeProps = useInitialHtmlThemeProps({		className: "h-full",	}); 	return (		// suppressHydrationWarning prevents React from fighting the inline		// theme script that corrects the class before paint on prerendered/SSR pages.		<html {...initialHtmlThemeProps} lang="en-US" dir="ltr" suppressHydrationWarning>			<head>				<meta charSet="utf-8" />				<meta name="viewport" content="width=device-width, initial-scale=1" />				{/* PreventWrongThemeFlashScript must be as early as possible in <head> */}				<PreventWrongThemeFlashScript />				<Meta />				<Links />			</head>			<body>				<ThemeProvider>					<TooltipProvider>						<Toaster />						{children}					</TooltipProvider>				</ThemeProvider>				<ScrollRestoration />				<Scripts />			</body>		</html>	);} export default function App() {	return <Outlet />;} export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {	let message = "Oops!";	let details = "An unexpected error occurred.";	let stack: string | undefined; 	if (isRouteErrorResponse(error)) {		message = error.status === 404 ? "404" : "Error";		details =			error.status === 404 ? "The requested page could not be found." : error.statusText || details;	} else if (import.meta.env.DEV && error && error instanceof Error) {		details = error.message;		stack = error.stack;	} 	return (		<main className="pt-16 p-4 container mx-auto">			<h1>{message}</h1>			<p>{details}</p>			{stack && (				<pre translate="no" className="w-full p-4 overflow-x-auto">					<code>{stack}</code>				</pre>			)}		</main>	);}

You are now ready to use mantle components in your application! Start with the Button. To let users change the theme, add the ready-made ThemeSwitcher to your header.

For apps behind authentication where responses are not publicly cached by a CDN, pass the theme cookie to useInitialHtmlThemeProps to eliminate the hydration mismatch entirely. The server then renders the correct theme class, so React hydration matches the inline script without needing suppressHydrationWarning.

caution

Do not use ssrCookie on publicly cached pages. React Router serializes loader data into the HTML, so the first visitor's theme would be baked into the CDN cache and served to everyone. For public/CDN-cached apps, rely on suppressHydrationWarning instead (shown above).

warning

Never return the full Cookie header from a loader; it would leak HttpOnly/session cookies into the HTML. Always use extractThemeCookie to return only the theme cookie.
import { extractThemeCookie, useInitialHtmlThemeProps } from "@ngrok/mantle/theme"; export const loader = async ({ request }: Route.LoaderArgs) => {	const themeCookie = extractThemeCookie(request.headers.get("Cookie"));	return { themeCookie };}; export function Layout({ children }: PropsWithChildren) {	const loaderData = useRouteLoaderData<typeof loader>("root");	const initialHtmlThemeProps = useInitialHtmlThemeProps({		className: "h-full",		ssrCookie: loaderData?.themeCookie,	});	// ...}

Vite

Install the additional dev dependencies for the Tailwind Vite plugin:

# npmnpm install -DE @tailwindcss/vite # pnpmpnpm add -DE @tailwindcss/vite # bunbun add -DE @tailwindcss/vite

Add the @tailwindcss/vite plugin to your Vite configuration:

// vite.config.tsimport { defineConfig } from "vite";import tailwindcss from "@tailwindcss/vite"; export default defineConfig({	plugins: [tailwindcss()],});

In your app's src/main.tsx, import app.css and set up the Theme Provider, Toaster, and Tooltip Provider:

// src/main.tsximport { StrictMode } from "react";import { createRoot } from "react-dom/client";import { ThemeProvider } from "@ngrok/mantle/theme";import { Toaster } from "@ngrok/mantle/toast";import { TooltipProvider } from "@ngrok/mantle/tooltip";import "./app.css";import App from "./App.tsx"; createRoot(document.getElementById("root")!).render(	<StrictMode>		<ThemeProvider>			<TooltipProvider>				<Toaster />				<App />			</TooltipProvider>		</ThemeProvider>	</StrictMode>,);

To prevent a flash of unstyled content (FOUC), add the theme script to the <head> of your index.html. The <script> below holds the output of preventWrongThemeFlashScriptContent() from @ngrok/mantle/theme, generated from the version of mantle this page was built with. Generate it again after you upgrade mantle. For a <head> that a server writes without React, see Non-React head on the Theme page.

warning

While mantle supports any type of React application, Vite is not the primary target. For now, you need to add the following script to the <head> of your app yourself. We plan to add a Vite plugin in the future to automate this.
<!-- index.html --><!doctype html><html lang="en">	<head>		<meta charset="UTF-8" />		<link rel="icon" type="image/svg+xml" href="/vite.svg" />		<meta name="viewport" content="width=device-width, initial-scale=1.0" />		<title>Vite + React + TS</title>		<script>			(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>	</head>	<body>		<div id="root"></div>		<script type="module" src="/src/main.tsx"></script>	</body></html>

You are now ready to use mantle components in your application! Start with the Button. To let users change the theme, add the ready-made ThemeSwitcher to your header.

Next.js

caution

Mantle does not yet support Next.js 15, especially with React Server Components (RSC). We are working on adding support for it soon.

React SPA

In your app's entry/root file, import app.css and set up the Theme Provider, Toaster, and Tooltip Provider:

// root.tsximport { StrictMode } from "react";import { createRoot } from "react-dom/client";import { ThemeProvider } from "@ngrok/mantle/theme";import { Toaster } from "@ngrok/mantle/toast";import { TooltipProvider } from "@ngrok/mantle/tooltip";import "./app.css";import App from "./App.tsx"; const container = window.document.getElementById("app"); if (!container) {	throw new Error(		"Something went wrong: cannot render application! Please refresh the page to try again.",	);} const root = createRoot(container); root.render(	<StrictMode>		<ThemeProvider>			<TooltipProvider>				<Toaster />				<App />			</TooltipProvider>		</ThemeProvider>	</StrictMode>,);

To prevent a flash of unstyled content (FOUC), add the theme script to the <head> of your index.html. The <script> below holds the output of preventWrongThemeFlashScriptContent() from @ngrok/mantle/theme, generated from the version of mantle this page was built with. Generate it again after you upgrade mantle. For a <head> that a server writes without React, see Non-React head on the Theme page.

warning

While mantle supports any type of React application, arbitrary React SPA apps are not the primary target. For now, you need to add the following script to the <head> of your app yourself.
<script>	(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>

You are now ready to use mantle components in your application! Start with the Button. To let users change the theme, add the ready-made ThemeSwitcher to your header.