Installation
Install Monsoon in Remix
Remix manages stylesheets through the links export, which renders <link> tags into <Links /> in the root. Import Monsoon with ?url, return it from links, and the stylesheet is in place before hydration.
Install
These examples use Remix with Vite. React Router v7 in framework mode is the successor of Remix and uses the same approach; only the imports change (see the note below).
import type { LinksFunction } from '@remix-run/node';
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
const themeScript = "(function(){var t;try{t=localStorage.getItem('monsoon-theme')}catch(e){}var d=t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.add(d?'dark':'light')})()";
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/monsooncss@0.2.0/dist/monsoon.min.css' },
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<Meta />
<Links />
</head>
<body className="bg-background text-foreground antialiased">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}npm install monsooncssimport type { LinksFunction } from '@remix-run/node';
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
import monsoonHref from 'monsooncss?url';
const themeScript = "(function(){var t;try{t=localStorage.getItem('monsoon-theme')}catch(e){}var d=t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.add(d?'dark':'light')})()";
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: monsoonHref },
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<Meta />
<Links />
</head>
<body className="bg-background text-foreground antialiased">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}?url asks Vite for the URL of the built file instead of injecting it, which is what links expects. The vite/client types already declare *?url imports, so TypeScript is satisfied. A plain import 'monsooncss' in root.tsx also works if you prefer side effect imports.
React Router v7: import Links, Meta, Outlet, Scripts and ScrollRestoration from 'react-router', and type the export as Route.LinksFunction with import type { Route } from './+types/root'. Everything else on this page is unchanged.
suppressHydrationWarning on <html> is needed because the head script adds dark or light there before React hydrates.
Use the classes
NavLink takes a function for className, which keeps active and inactive styles as full strings:
import { NavLink } from '@remix-run/react';
const base = 'rounded-lg px-3 py-2 text-sm font-medium';
export function MainNav() {
return (
<nav className="flex items-center gap-2">
{[['/', 'Home'], ['/pricing', 'Pricing'], ['/docs', 'Docs']].map(([to, label]) => (
<NavLink
key={to}
to={to}
end
className={({ isActive }) =>
isActive
? base + ' bg-primary text-primary-foreground'
: base + ' text-muted-foreground hover:bg-muted'
}
>
{label}
</NavLink>
))}
</nav>
);
}export function PlanCard({ name, price, featured = false }: { name: string; price: string; featured?: boolean }) {
return (
<article className={featured ? 'rounded-2xl border bg-surface p-6 space-y-4 ring-2 ring-ring' : 'rounded-2xl border bg-surface p-6 space-y-4 shadow-sm'}>
<h2 className="text-lg font-semibold">{name}</h2>
<p className="text-3xl font-bold">{price}</p>
<button
type="submit"
className={featured ? 'w-full rounded-lg bg-primary text-primary-foreground px-4 py-2 font-medium hover:bg-primary/90' : 'w-full rounded-lg bg-secondary text-secondary-foreground px-4 py-2 font-medium hover:bg-secondary/80'}
>
Choose {name}
</button>
</article>
);
}Dark mode
The toggle does not read the theme during render, so server and client markup always match:
export function ThemeToggle() {
function toggle() {
const root = document.documentElement;
const next = root.classList.contains('dark') ? 'light' : 'dark';
root.classList.remove('dark', 'light');
root.classList.add(next);
try { localStorage.setItem('monsoon-theme', next); } catch (e) {}
}
return (
<button type="button" onClick={toggle} className="rounded-lg border px-3 py-2 text-sm hover:bg-muted">
Toggle theme
</button>
);
}Prefer the choice on the server? Store it in a cookie, read it in the root loader, and render <html className={theme}>. Then the head script is only needed for visitors without the cookie.
Theme with --seed
Return the brand color from a loader and set it as a custom property on the route wrapper:
import type { LoaderFunctionArgs } from '@remix-run/node';
import { Outlet, useLoaderData } from '@remix-run/react';
import { getTeam } from '~/models/team.server';
export async function loader({ params }: LoaderFunctionArgs) {
const team = await getTeam(params.team!);
return { brandColor: team.brandColor };
}
export default function TeamRoute() {
const { brandColor } = useLoaderData<typeof loader>();
return (
<div style={{ '--seed': brandColor } as React.CSSProperties} className="min-h-dvh bg-background">
<Outlet />
</div>
);
}Validate stored colors before rendering them. Every semantic token (primary, secondary, accent, surfaces, muted text, borders and the focus ring) is derived from --seed in the browser. Theming lists the tokens.
Using the purge CLI
{
"scripts": {
"dev": "remix vite:dev",
"css:purge": "monsooncss purge \"app/**/*.{js,jsx,ts,tsx}\" -o app/monsoon.purged.css",
"build": "npm run css:purge && remix vite:build",
"start": "remix-serve ./build/server/index.js"
}
}Alias the package for builds only. The pattern form is needed here, because a plain string alias would not match the ?url suffix:
import { vitePlugin as remix } from '@remix-run/dev';
import { defineConfig } from 'vite';
import { fileURLToPath } from 'node:url';
const purged = fileURLToPath(new URL('./app/monsoon.purged.css', import.meta.url));
export default defineConfig(({ command }) => ({
plugins: [remix()],
resolve: {
alias: command === 'build' ? [{ find: /^monsooncss(?=\?|$)/, replacement: purged }] : [],
},
}));In React Router v7 the scripts are react-router dev and react-router build, and the plugin is reactRouter() from @react-router/dev/vite. With the CDN setup, write to public/monsoon.css and return /monsoon.css from links.
The purge CLI reads your files as plain text and keeps every class name it can see. It cannot see a name that is assembled at runtime, so write each class in full: isActive ? 'bg-primary' : 'bg-muted' survives a purge, 'bg-' + color does not. Names that only exist at runtime (from a database, a CMS or an API) go in --safelist bg-success,bg-danger.
The purged file always keeps the reset, the theme tokens, the .dark and .light scopes and the keyframes, so dark mode and --seed keep working. See the purge CLI reference for every option.
Next steps
- Browse every utility and its variants in the class reference.
- Read how the scheme is chosen in Dark mode, and how tokens are derived in Theming.
- Other React setups: React with Vite and Next.js.
Frequently asked questions
Should I use the links export or a side effect import?
Both work with Vite. links with ?url is the documented Remix pattern and lets you add media or preload hints; the side effect import is shorter.
Does this work in React Router v7 framework mode?
Yes. Swap the imports to react-router, type the export with Route.LinksFunction, and keep the rest of the setup.