Monsoon v0.2.0
Made by Alexandre Kohler Star

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.

2 min readRemix

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

app/root.tsx
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 />;
}

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:

app/components/MainNav.tsx
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>
  );
}
app/components/PlanCard.tsx
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:

app/components/ThemeToggle.tsx
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:

app/routes/$team.tsx
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

package.json
{
  "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:

vite.config.ts
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

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.

No recent searches

Try , , or .

Recent

Loading the search index

No results for ""

Check the spelling, or search a class name such as px-4.