ss
The main helper: group Tailwind classes by breakpoint and state, compose many arguments in one call, and nest groups for compound variants.
Grouping by key
base holds the classes with no further prefix. Every other key is a breakpoint, a max-* range, or a state variant, and its value comes out with that key in front of it.
ss({ base: "text-xl flex", sm: "block", md: "text-2xl" });// → "text-xl flex sm:block md:text-2xl" ss({ base: "grid", "max-md": "gap-2", "group-hover": "underline" });// → "grid max-md:gap-2 group-hover:underline"ss accepts base plus Tailwind's own keys — 149 in total, and nothing else — so autocomplete is exhaustive and a typo cannot compile. The full list is on the Keys page, and the same 149 are available inside a nested group, which is how a compound variant is spelled.
Every prefix here is assembled at runtime, so Tailwind never sees the finished class in your source. That is why ss needs the build plugin from Setup.
At the size a real component reaches
The two-key example above is the mechanism. An ordinary component reaches six groups without trying — three breakpoints, two states and a dark mode with its own hover. Both spellings below emit exactly the same class string.
<button className="flex gap-2 rounded-lg border px-3 py-1.5 text-sm sm:px-4 md:gap-3 md:rounded-xl md:px-5 md:text-base lg:px-6 hover:bg-neutral-50 focus-visible:outline-2 dark:border-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-900"/><button className={ss({ base: "flex gap-2 rounded-lg border px-3 py-1.5 text-sm", sm: "px-4", md: "gap-3 rounded-xl px-5 text-base", lg: "px-6", hover: "bg-neutral-50", "focus-visible": "outline-2", dark: { base: "border-neutral-800 text-neutral-100", hover: "bg-neutral-900", }, })}/>Key order is fixed
Write the keys in whatever order reads best in the component. They are emitted in one fixed order regardless, and the finished string runs through cn.
| Order | Keys |
|---|---|
| 1 | base — classes with no further prefix |
| 2 | Breakpoints, mobile-first — sm, md, lg, xl, 2xl |
| 3 | Max-width ranges, largest first — max-2xl down to max-sm |
| 4 | State variants — hover, dark, group-hover and the rest |
Stable order is what keeps tailwind-merge's last-one-wins resolution predictable. Two components that spell the same groups in a different order produce the same class string, so which class survives a conflict never depends on how the object was typed.
Falsy values drop the group
Values are clsx-style, so a condition goes inline instead of around the call. A falsy value drops the whole group, prefix included — there is no orphaned md: left in the output.
ss({ base: "text-sm", md: isActive && "text-2xl" });// isActive === false → "text-sm"Many arguments, one call
ss is variadic, and an argument is anything a bucket accepts: another map, a class string, a clsx array, or a condition that produces one. This is what a className looks like in practice — and why nothing needs to wrap it.
ss( { base: "rounded-lg border p-4", md: "p-6" }, isDisabled && { base: "opacity-50", sm: "bg-red-500" }, match(tone, { info: "bg-blue-50", danger: "bg-red-50" }), className,);The lookup in the middle is match, and the caller's className is simply the last argument — no cn around the outside, no second ss(). Framework examples shows the same shape as a whole component in React, Vue and Svelte.
The last argument wins
Keys are sorted inside each map; the arguments themselves are never reordered. That is what makes the last argument win, exactly as it does in cn.
ss({ base: "p-4", md: "p-6" }, "md:p-10"); // → "p-4 md:p-10"ss({ base: "p-4" }, { base: "p-8" }); // → "p-8"Sorting a bare string into the base bucket instead would put a caller's md:p-10 ahead of your own md:p-6, where it would quietly lose. A string stays an argument, so an override passed in from outside is still the last thing merged.
Given only class values, ss is cn:
ss("px-2 py-1", isActive && "bg-blue-500", "px-4"); // → "py-1 bg-blue-500 px-4"Nested groups, for compound variants
A bucket's value can be another map, which stacks the prefixes. Each breakpoint gets its own group, with the same keys and the same rules.
ss({ base: "text-black p-4", md: { base: "p-6", // → md:p-6 hover: "p-8", // → md:hover:p-8 "max-lg": "grid", // → md:max-lg:grid }, dark: { base: "text-white", // → dark:text-white hover: "text-blue-300", // → dark:hover:text-blue-300 },});md: "p-6" and md: { base: "p-6" } mean the same thing, so nothing has to change to start nesting. A falsy nested bucket drops, prefix included, like any other.
Note
clsx classes. Nothing is decided by looking at your key names, so the same source always means the same thing.Put a clsx dictionary inside an array, where there is nothing to confuse it with.
// a clsx dictionary, inside an array — nothing left to confuse it withss({ base: "text-sm", md: [{ "text-lg": isLarge }] });