Installation
Install Monsoon in Laravel Blade
Blade layouts are server rendered HTML, so Monsoon works with a single <link>. If your app already runs Vite through @vite, import the package instead and let Vite fingerprint it.
Install
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', config('app.name'))</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monsooncss@0.2.0/dist/monsoon.min.css">
<script>(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')})()</script>
</head>
<body class="bg-background text-foreground antialiased">
@yield('content')
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
</body>
</html>npm install monsooncssImport the package at the very top of the CSS entry (an @import must come before any other rule):
@import 'monsooncss';
/* your own styles below */<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', config('app.name'))</title>
<script>(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')})()</script>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-background text-foreground antialiased">
@yield('content')
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
</body>
</html>Prefer JavaScript entries? import 'monsooncss'; at the top of resources/js/app.js works the same way.
If resources/css/app.css came from a starter kit with another utility setup, remove those lines so two resets do not compete.
Use the classes
Blade's @class directive and $attributes->class() take arrays where each key is a full class string, which reads well and stays purge friendly:
@props(['name', 'price', 'featured' => false])
<article {{ $attributes->class([
'rounded-2xl border bg-surface p-6 space-y-4',
'ring-2 ring-ring' => $featured,
'shadow-sm' => ! $featured,
]) }}>
<h2 class="text-lg font-semibold">{{ $name }}</h2>
<p class="text-3xl font-bold">{{ $price }}</p>
{{ $slot }}
<a href="{{ route('register', ['plan' => $name]) }}" @class([
'block rounded-lg px-4 py-2 text-center font-medium',
'bg-primary text-primary-foreground hover:bg-primary/90' => $featured,
'bg-secondary text-secondary-foreground hover:bg-secondary/80' => ! $featured,
])>Choose {{ $name }}</a>
</article>@extends('layouts.app')
@section('content')
<main class="mx-auto max-w-5xl px-6 py-12">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@foreach ($plans as $plan)
<x-plan-card :name="$plan->name" :price="$plan->price" :featured="$plan->featured">
<p class="text-sm text-muted-foreground">{{ $plan->summary }}</p>
</x-plan-card>
@endforeach
</div>
</main>
@endsectionDark mode
The head script in the layout sets the scheme before paint. Add a button anywhere in your views and a small script in public/js to flip it:
<button type="button" id="theme-toggle" class="rounded-lg border px-3 py-2 text-sm hover:bg-muted">
Toggle theme
</button>const button = document.getElementById('theme-toggle');
button.addEventListener('click', () => {
const root = document.documentElement;
const next = root.classList.contains('dark') ? 'light' : 'dark';
root.classList.remove('dark', 'light');
root.classList.add(next);
button.setAttribute('aria-pressed', String(next === 'dark'));
try { localStorage.setItem('monsoon-theme', next); } catch (e) {}
});To render the right scheme on the very first request without JavaScript, store the choice in a cookie and print it on <html class="{{ $theme }}"> after checking it is dark or light.
Theme with --seed
Write the seed inline from your data. Blade escapes HTML, but it does not make a value safe inside CSS, so validate it when it is saved:
<section style="--seed: {{ $team->brand_color ?? '#0d9488' }}" class="rounded-2xl bg-surface p-6 space-y-4">
<h2 class="text-xl font-bold text-primary">{{ $team->name }}</h2>
<a href="{{ route('teams.show', $team) }}" class="inline-block rounded-lg bg-primary text-primary-foreground px-4 py-2">Open</a>
</section>$request->validate([
'brand_color' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
]);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
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"css:purge": "monsooncss purge \"resources/views/**/*.blade.php\" \"resources/js/**/*.{js,vue,jsx}\" \"app/View/**/*.php\" -o resources/css/monsoon.purged.css",
"build": "npm run css:purge && vite build"
}
}With the Vite setup, alias the package for builds only. The @import 'monsooncss' stays; npm run dev serves the full file and npm run build the purged one:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import { fileURLToPath } from 'node:url';
const purged = fileURLToPath(new URL('./resources/css/monsoon.purged.css', import.meta.url));
export default defineConfig(({ command }) => ({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
],
resolve: {
alias: command === 'build' ? { monsooncss: purged } : {},
},
}));With the CDN setup, write to public/css/monsoon.css instead and switch the link by environment:
@production
<link rel="stylesheet" href="{{ asset('css/monsoon.css') }}">
@else
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monsooncss@0.2.0/dist/monsoon.min.css">
@endproductionThe 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. Class names stored in the database or in config files belong in the safelist.
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
Does Monsoon work with Livewire?
Yes. Livewire morphs the DOM but keeps the class on <html>, and Monsoon classes in Livewire views behave like any other Blade markup. Add resources/views/livewire/** to the purge glob if you keep views there.
Do I need Vite to use Monsoon in Laravel?
No. The CDN link in the layout is enough. Vite is only useful if you already bundle your own CSS and JavaScript.