Getting started
Arbitrary values without a build step
When a scale has no step that fits, write the value itself: w-[37px], bg-[#0ea5e9], grid-cols-[200px_1fr]. The stylesheet cannot contain values you invent, so they come from one of two places, neither of them a build step: a small script that writes the rules in the browser (6.0 kB with brotli), or the purge CLI, which compiles them into the purged file.
Try it
This page loads the script, so these boxes are styled by classes that are not in the stylesheet. Switch the preview to dark to see the dark: value apply.
h-[calc(2.5rem+6px)] and text-[13px]
<div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
<div class="rounded-[14px] bg-[#0ea5e9] p-4 text-white font-[650]">...</div>
<div class="rounded-[14px] bg-[oklch(0.72_0.16_150)] p-4">...</div>
<div class="rounded-[14px] border-[3px] border-[#a855f7] dark:text-[#e9d5ff]">...</div>
</div>The syntax
| You write | You get | Notes |
|---|---|---|
w-[37px] | width: 37px | Any utility prefix that takes a value, followed by the value in brackets. |
p-[3px_7px] | padding: 3px 7px | An underscore is a space. Write \_ for a real underscore. |
h-[calc(100vh-4rem)] | height: calc(100vh - 4rem) | The spaces that calc(), min(), max() and clamp() need around + and - are added for you. |
bg-[url(/img/hero_1.png)] | background-image: url(/img/hero_1.png) | Underscores inside url() stay underscores. |
bg-(--brand) | background-color: var(--brand) | Parentheses take a custom property name: a short way to write bg-[var(--brand)]. |
[mask-type:luminance] | mask-type: luminance | An arbitrary property: any property and value, in brackets. |
bg-[red]/50 | color-mix(in oklab, red 50%, transparent) | An opacity modifier, on the families that take a color. |
-mt-[3px] | margin-top: -3px | A negative value, on margins, positions, translate, rotate, z-index and a few more. |
content-['x'] | content: 'x' | Use it with before: and after:. |
text-[length:var(--size)] | font-size: var(--size) | A type hint for values that could mean two things. |
One prefix, two meanings
A few prefixes write a color or a size depending on the value, exactly like their static classes do (border-2 and border-primary). The value decides:
| Class | CSS | Why |
|---|---|---|
border-[3px] | border-width: 3px | A length is a width. |
border-[red] | border-color: red | A color, a color function or a color keyword is a color. |
text-[13px] | font-size: 13px | A length is a font size. |
text-[#eee] | color: #eee | A color is the text color. |
font-[650] | font-weight: 650 | A number is a weight, anything else is a family. |
bg-[url(/a.png)] | background-image: url(/a.png) | Images and gradients are a background image. |
bg-[cover], bg-[center_top] | background-size, background-position | Size and position keywords. |
shadow-[0_2px_8px_red] | box-shadow (it composes with ring) | A whole shadow. shadow-[red] sets only the shadow color. |
When a value is ambiguous, for example a custom property, add a hint before a colon: color:, length:, size:, position:, image:, number:, weight: or family-name:. So bg-[length:200px_100px] is a background size and border-(color:--edge) is a border color.
Variants
Every prefix of the static classes works on arbitrary values, on every family, and they can be stacked. The order on the page is the same as in the stylesheet: plain values first, then states, then breakpoints from small to large, then container and media queries.
<div class="hover:bg-[#0369a1] md:w-[37rem] dark:text-[#eee]">
<div class="@container"><div class="grid @md:grid-cols-[1fr_2fr]">...</div></div>
<nav class="print:hidden rtl:mr-[3px]">
<p class="md:hover:underline-offset-[6px]">
<span class="before:content-['#'] before:mr-[.25em]">These are the rules the last line writes:
.before\:content-\[\'\#\'\]::before{content:'#'}
@media (min-width: 768px){.md\:hover\:underline-offset-\[6px\]:hover{text-underline-offset:6px}}Besides the static variants, arbitrary values also take before:, after:, placeholder:, selection:, marker:, file:, backdrop:, first-line: and first-letter: (pseudo elements are always written last), plus visited:, checked:, invalid:, required:, empty:, target:, open:, group-focus: and peer-focus:.
The dark: prefix uses the same rule as the rest of Monsoon: the nearest themed ancestor wins, at any depth.
Runtime or compiled
Both modes use the same parser, from src/arbitrary/, so a class means exactly the same thing in development and in production.
Load the script. monsoon.js includes it next to the interactive layer; monsoon-arbitrary.js is the engine alone, 6.0 kB with brotli.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monsooncss@0.2.0">
<script src="https://cdn.jsdelivr.net/npm/monsooncss@0.2.0/dist/monsoon-arbitrary.js" defer></script>- On load it reads the class attributes of the page, and a
MutationObservercatches every element added later and every class change, so it works with any library that renders HTML. - Each new class is compiled once and cached. Rules go into one constructed stylesheet with
adoptedStyleSheets, or a<style id="monsoon-arbitrary">element in older browsers. - Rules are inserted in the order of the static stylesheet, so
w-[1px] md:w-[2px]behaves the way you expect whatever order the classes appear in. - Classes without brackets or parentheses are skipped with one quick check, so static classes cost nothing.
The engine has a small API on Monsoon.arbitrary (or MonsoonArbitrary for the standalone file): scan(root), add(className), css() to read the generated rules, stop() and reset().
The purge CLI finds every arbitrary value class in your files and writes static rules for it after the utilities. Production then needs no script at all.
npx monsooncss purge "src/**/*.{html,jsx,vue,php}" -o public/monsoon.cssmonsooncss purge: scanned 42 files
kept 512 of 16351 utility rules
compiled 18 arbitrary value classesClass names are found in any text: HTML attributes, JSX, template strings, arrays of classes. Every candidate goes through the same parser and the same safety checks as the runtime, so a stray arr[i] in your code is ignored. --safelist accepts arbitrary classes too, and --no-arbitrary turns compiling off.
Arbitrary rules come after every static utility. A value you wrote on purpose wins over a scale step on the same element: with p-[3px] md:p-4, the arbitrary padding wins at every width. Put both on the arbitrary side (md:p-[1rem]) when you need a breakpoint to override it.
Safety
Class attributes can come from user content, so the parser only accepts values that cannot leave their declaration. A class is ignored, and nothing is written, when its value:
- contains
;,{,},@,!,<or>, so it cannot end a declaration, open a rule or an at-rule, add!importantor close a<style>element; - contains a backslash other than
\_, so nothing can hide behind a CSS escape; - contains a comment, a control character,
expression(orjavascript:; - has unbalanced brackets, parentheses or quotes;
- is longer than 300 characters, or uses an unknown prefix or variant.
Selectors are escaped with the same rules as CSS.escape, and property names of arbitrary properties must be plain lowercase names or custom properties. The test suite feeds the parser a list of injection attempts and checks that none of them produces a rule.
bg-[red;color:blue] ignored: ";"
w-[1px}] ignored: "}" and unbalanced
content-['</style>'] ignored: "<" and ">"
bg-[\7d] ignored: escape
bg-[red_!important] ignored: "!"Every family
Each prefix maps to the same properties as the static classes with that prefix. The table is generated from the parser, so it is always complete.
| Group | Example | CSS | Takes |
|---|---|---|---|
| Layout | inset-[3px] | inset: 3px | negative |
inset-x-[3px] | left: 3px; right: 3px | negative | |
inset-y-[3px] | top: 3px; bottom: 3px | negative | |
top-[3px] | top: 3px | negative | |
right-[3px] | right: 3px | negative | |
bottom-[3px] | bottom: 3px | negative | |
left-[3px] | left: 3px | negative | |
start-[3px] | inset-inline-start: 3px | negative | |
end-[3px] | inset-inline-end: 3px | negative | |
z-[999] | z-index: 999 | negative | |
aspect-[4/3] | aspect-ratio: 4/3 | ||
columns-[18rem] | columns: 18rem | ||
object-[25%_75%] | object-position: 25% 75% | ||
| Flexbox and grid | basis-[37%] | flex-basis: 37% | |
flex-[2_1_0%] | flex: 2 1 0% | ||
grow-[2] | flex-grow: 2 | ||
shrink-[0] | flex-shrink: 0 | ||
order-[13] | order: 13 | negative | |
grid-cols-[200px_1fr] | grid-template-columns: 200px 1fr | ||
grid-rows-[auto_1fr] | grid-template-rows: auto 1fr | ||
col-[2/4] | grid-column: 2/4 | ||
col-span-[5] | grid-column: span 5 / span 5 | ||
col-start-[2] | grid-column-start: 2 | ||
col-end-[-1] | grid-column-end: -1 | ||
row-[1/3] | grid-row: 1/3 | ||
row-span-[3] | grid-row: span 3 / span 3 | ||
row-start-[2] | grid-row-start: 2 | ||
row-end-[4] | grid-row-end: 4 | ||
auto-cols-[12rem] | grid-auto-columns: 12rem | ||
auto-rows-[minmax(4rem,auto)] | grid-auto-rows: minmax(4rem,auto) | ||
gap-[3px] | gap: 3px | ||
gap-x-[3px] | column-gap: 3px | ||
gap-y-[3px] | row-gap: 3px | ||
| Spacing | p-[3px] | padding: 3px | |
px-[3px] | padding-inline: 3px | ||
py-[3px] | padding-block: 3px | ||
pt-[3px] | padding-top: 3px | ||
pr-[3px] | padding-right: 3px | ||
pb-[3px] | padding-bottom: 3px | ||
pl-[3px] | padding-left: 3px | ||
ps-[3px] | padding-inline-start: 3px | ||
pe-[3px] | padding-inline-end: 3px | ||
m-[3px] | margin: 3px | negative | |
mx-[3px] | margin-inline: 3px | negative | |
my-[3px] | margin-block: 3px | negative | |
mt-[3px] | margin-top: 3px | negative | |
mr-[3px] | margin-right: 3px | negative | |
mb-[3px] | margin-bottom: 3px | negative | |
ml-[3px] | margin-left: 3px | negative | |
ms-[3px] | margin-inline-start: 3px | negative | |
me-[3px] | margin-inline-end: 3px | negative | |
space-x-[3px] | margin-inline-end: 3px | negative | |
space-y-[3px] | margin-block-end: 3px | negative | |
scroll-m-[3px] | scroll-margin: 3px | negative | |
scroll-mt-[3px] | scroll-margin-top: 3px | negative | |
scroll-p-[3px] | scroll-padding: 3px | ||
scroll-pt-[3px] | scroll-padding-top: 3px | ||
| Sizing | w-[3px] | width: 3px | |
h-[3px] | height: 3px | ||
size-[3px] | width: 3px; height: 3px | ||
min-w-[3px] | min-width: 3px | ||
max-w-[3px] | max-width: 3px | ||
min-h-[3px] | min-height: 3px | ||
max-h-[3px] | max-height: 3px | ||
| Typography | text-[13px] | font-size: 13px | color |
font-[650] | font-weight: 650 | ||
leading-[1.1] | line-height: 1.1 | ||
tracking-[.02em] | letter-spacing: .02em | negative | |
indent-[3px] | text-indent: 3px | negative | |
decoration-[3px] | text-decoration-thickness: 3px | color | |
underline-offset-[6px] | text-underline-offset: 6px | ||
line-clamp-[7] | overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 7 | ||
list-[square] | list-style-type: square | ||
align-[-2px] | vertical-align: -2px | ||
content-['x'] | content: 'x' | ||
placeholder-[#0ea5e9] | color: #0ea5e9 | color | |
| Borders, outline and ring | border-[3px] | border-width: 3px | color |
border-x-[3px] | border-left-width: 3px; border-right-width: 3px | color | |
border-y-[3px] | border-top-width: 3px; border-bottom-width: 3px | color | |
border-t-[3px] | border-top-width: 3px | color | |
border-r-[3px] | border-right-width: 3px | color | |
border-b-[3px] | border-bottom-width: 3px | color | |
border-l-[3px] | border-left-width: 3px | color | |
border-s-[3px] | border-inline-start-width: 3px | color | |
border-e-[3px] | border-inline-end-width: 3px | color | |
rounded-[3px] | border-radius: 3px | ||
rounded-t-[3px] | border-top-left-radius: 3px; border-top-right-radius: 3px | ||
rounded-r-[3px] | border-top-right-radius: 3px; border-bottom-right-radius: 3px | ||
rounded-b-[3px] | border-bottom-right-radius: 3px; border-bottom-left-radius: 3px | ||
rounded-l-[3px] | border-top-left-radius: 3px; border-bottom-left-radius: 3px | ||
rounded-tl-[3px] | border-top-left-radius: 3px | ||
rounded-tr-[3px] | border-top-right-radius: 3px | ||
rounded-br-[3px] | border-bottom-right-radius: 3px | ||
rounded-bl-[3px] | border-bottom-left-radius: 3px | ||
divide-x-[3px] | border-inline-style: solid; border-inline-start-width: 0; border-inline-end-width: 3px | ||
divide-y-[3px] | border-top-width: 0; border-bottom-style: solid; border-bottom-width: 3px | ||
divide-[#0ea5e9] | border-color: #0ea5e9 | color | |
outline-[3px] | outline-style: solid; outline-width: 3px | color | |
outline-offset-[3px] | outline-offset: 3px | ||
ring-[3px] | --m-ring-offset-shadow: var(--m-ring-inset,) 0 0 0 var(--m-ring-offset-width) var(--m-ring-offset-color); --m-ring-shadow: var(--m-ring-inset,) 0 0 0 calc(3px + var(--m-ring-offset-width)) var(--m-ring-color); box-shadow: var(--m-ring-offset-shadow), var(--m-ring-shadow), var(--m-shadow) | color | |
ring-offset-[3px] | --m-ring-offset-width: 3px | color | |
| Colors and backgrounds | bg-[#0ea5e9] | background-color: #0ea5e9 | color |
from-[#0ea5e9] | --m-gradient-from: #0ea5e9; --m-gradient-stops: var(--m-gradient-from), var(--m-gradient-via, var(--m-gradient-to, transparent)), var(--m-gradient-to, transparent) | color | |
via-[#0ea5e9] | --m-gradient-via: #0ea5e9 | color | |
to-[#0ea5e9] | --m-gradient-to: #0ea5e9 | color | |
fill-[#0ea5e9] | fill: #0ea5e9 | color | |
stroke-[1.5] | stroke-width: 1.5 | color | |
caret-[#0ea5e9] | caret-color: #0ea5e9 | color | |
accent-[#0ea5e9] | accent-color: #0ea5e9 | color | |
| Effects and filters | shadow-[0_2px_8px_rgb(0_0_0/.2)] | --m-shadow: 0 2px 8px rgb(0 0 0/.2); box-shadow: var(--m-ring-offset-shadow), var(--m-ring-shadow), var(--m-shadow) | color |
opacity-[.35] | opacity: .35 | ||
mix-blend-[multiply] | mix-blend-mode: multiply | ||
blur-[3px] | filter: blur(3px) | ||
brightness-[1.2] | filter: brightness(1.2) | ||
contrast-[1.1] | filter: contrast(1.1) | ||
saturate-[1.4] | filter: saturate(1.4) | ||
grayscale-[.5] | filter: grayscale(.5) | ||
invert-[.8] | filter: invert(.8) | ||
sepia-[.3] | filter: sepia(.3) | ||
hue-rotate-[90deg] | filter: hue-rotate(90deg) | negative | |
drop-shadow-[0_2px_4px_red] | filter: drop-shadow(0 2px 4px red) | ||
backdrop-blur-[3px] | -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px) | ||
backdrop-brightness-[3px] | -webkit-backdrop-filter: brightness(3px); backdrop-filter: brightness(3px) | ||
| Transitions and transforms | transition-[width] | transition-property: width; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms | |
duration-[250] | transition-duration: 250ms | ||
delay-[75] | transition-delay: 75ms | ||
ease-[linear] | transition-timing-function: linear | ||
animate-[spin_2s_linear_infinite] | animation: spin 2s linear infinite | ||
scale-[1.15] | scale: 1.15 | ||
scale-x-[1.15] | scale: 1.15 1 | ||
scale-y-[1.15] | scale: 1 1.15 | ||
rotate-[17deg] | rotate: 17deg | negative | |
translate-x-[3px] | --m-tx: 3px; translate: var(--m-tx) var(--m-ty) | negative | |
translate-y-[3px] | --m-ty: 3px; translate: var(--m-tx) var(--m-ty) | negative | |
origin-[30%_70%] | transform-origin: 30% 70% | ||
| Interactivity | cursor-[grab] | cursor: grab | |
will-change-[scroll-position] | will-change: scroll-position | ||
| More | scroll-mx-[3px] | scroll-margin-inline: 3px | negative |
scroll-px-[3px] | scroll-padding-inline: 3px | ||
scroll-my-[3px] | scroll-margin-block: 3px | negative | |
scroll-py-[3px] | scroll-padding-block: 3px | ||
scroll-mr-[3px] | scroll-margin-right: 3px | negative | |
scroll-pr-[3px] | scroll-padding-right: 3px | ||
scroll-mb-[3px] | scroll-margin-bottom: 3px | negative | |
scroll-pb-[3px] | scroll-padding-bottom: 3px | ||
scroll-ml-[3px] | scroll-margin-left: 3px | negative | |
scroll-pl-[3px] | scroll-padding-left: 3px | ||
scroll-ms-[3px] | scroll-margin-inline-start: 3px | ||
scroll-ps-[3px] | scroll-padding-inline-start: 3px | ||
scroll-me-[3px] | scroll-margin-inline-end: 3px | ||
scroll-pe-[3px] | scroll-padding-inline-end: 3px | ||
backdrop-contrast-[3px] | -webkit-backdrop-filter: contrast(3px); backdrop-filter: contrast(3px) | ||
backdrop-saturate-[3px] | -webkit-backdrop-filter: saturate(3px); backdrop-filter: saturate(3px) | ||
backdrop-grayscale-[3px] | -webkit-backdrop-filter: grayscale(3px); backdrop-filter: grayscale(3px) | ||
backdrop-invert-[3px] | -webkit-backdrop-filter: invert(3px); backdrop-filter: invert(3px) | ||
backdrop-sepia-[3px] | -webkit-backdrop-filter: sepia(3px); backdrop-filter: sepia(3px) | ||
backdrop-hue-rotate-[3px] | -webkit-backdrop-filter: hue-rotate(3px); backdrop-filter: hue-rotate(3px) | ||
backdrop-drop-shadow-[3px] | -webkit-backdrop-filter: drop-shadow(3px); backdrop-filter: drop-shadow(3px) |
Bare color names
Every palette hue also works without a step: bg-red, text-blue, border-emerald, ring-violet, from-sky. These are static classes in the stylesheet, no script needed. They use step 500 in light mode and step 400 in dark mode, and follow the nearest themed ancestor like every semantic color.
Frequently asked questions
Do I need the script in production?
No. Run npx monsooncss purge and the arbitrary classes are compiled into the purged stylesheet. Keep the script for prototypes, CMS content and pages you never purge.
Does it slow the page down?
The engine only looks at class names that contain a bracket or a parenthesis and compiles each distinct class once, so the work grows with the number of different arbitrary classes, not with the size of the page. The whole interactive layer with the engine is 53.8 kB with brotli.
Can a user break my page with a class attribute?
Not through the engine: values that could end a declaration or open a rule are rejected before any CSS is written, see Safety above.