Docs/Core
Docs/Core
Dynamic classes
Why @recipe only expands in literal class attributes — and how to bind one at runtime.
Dynamic classes
Shortwind expands recipes by rewriting @recipe tokens it can statically see
in your source — before the value is ever evaluated. The rule: the recipe
text must appear as a literal string the build can see in a class/className
attribute. What counts as visible depends on the file type.
In JSX/TSX (.tsx, .jsx, .mdx) the transform parses the
className={…} expression, so literal strings inside it expand — including
both branches of a ternary, strings inside clsx(...)-style call arguments,
and the static parts of template literals:
<div className="@card-elevated p-6">…</div> {/* ✅ expands */}
<a className={active ? "@tab-active" : "@tab"}>Home</a> {/* ✅ expands */}
<button className={clsx("@btn-primary", on && "@btn-ghost")} /> {/* ✅ both expand */}
In HTML-shaped templates (.astro, .vue, .svelte, .html) only plain
class="..." attribute strings expand. A class={…} JS expression — including
the exact ternary that works in a .tsx island — and Astro’s class:list do
not:
<!-- ✅ expands -->
<div class="@card-elevated p-6">…</div>
<!-- ❌ ships dead tokens in .astro (would expand in .tsx) -->
<a class={active ? "@nav-link-active" : "@nav-link"}>Home</a>
<a class:list={[active ? "@nav-link-active" : "@nav-link"]}>Home</a>
In every mode, the silent failure is indirection. A recipe that reaches the attribute as a value — assigned to a variable first, passed as a component prop, looked up from an object, or composed into a string — is invisible to the build, ships as a dead token, and produces no warning:
const cfg = { recipe: "@badge-success" };
<span className={cfg.recipe}>shipped</span> // ❌ silent — no expansion, no warning
<Icon class="@icon-sm" /> // ❌ silent — a prop is just a string
An unexpanded @recipe is a class name the browser doesn’t know, so the element
simply renders without those styles — no error, just missing CSS. The build
warns when it can see the stranded token in a class / className / class:list
value ([shortwind] … unexpanded recipe @nav-link …). It cannot warn about the
indirect cases above, so don’t rely on the warning alone.
Because the same conditional behaves differently in .astro and .tsx, moving
a snippet between them can change whether it renders styled. Don’t lean on the
JSX leniency: write for the literal-only rule everywhere and it works in both.
First, check if you need a tone, not a recipe
The most common “dynamic” case is color chosen from data — a badge’s
severity, a status pill, a trend arrow. You don’t need a dynamic class name for
that. Tone-aware recipes read their color from a data-tone attribute, so the
class name stays a static literal and the value rides on data:
<span className="@badge" data-tone={incident.severity}>{incident.severity}</span>
No rc(), no build-time expansion — just an attribute. See Tones.
Reach for rc() below only when the choice isn’t a tone — a genuinely different
recipe selected at runtime.
The fix: expand at build time, bind the result
When you genuinely need to pick between recipes at runtime, resolve them to
plain Tailwind and bind that string. Drop a tiny rc() helper into your
project once. Everything it needs ships with the adapter init installed —
you never import @shortwind/core directly (it’s a transitive dependency and
won’t resolve from your project).
Vite and Astro
The plugin serves your resolved catalog as a virtual module,
virtual:shortwind/registry. It contains only the flattened registry —
plain Tailwind utilities — so importing it never plants @recipe tokens in
your client bundle:
// src/lib/rc.ts
import { expandClassList } from "@shortwind/vite"; // Astro: "@shortwind/astro"
import registry from "virtual:shortwind/registry";
// Resolve a recipe class list to its raw Tailwind. The `true` enables the
// last-wins conflict merge, same as a static `class="@recipe"` attribute.
export function rc(classList: string): string {
return expandClassList(classList, registry, true);
}
For the virtual module’s types, add one line to src/vite-env.d.ts
(Astro: src/env.d.ts):
/// <reference types="@shortwind/vite/client" />
(Astro projects reference @shortwind/astro/client instead.)
Then bind the expanded value — the build only ever sees plain Tailwind:
---
import { rc } from "../lib/rc";
---
<a class={isActive(href) ? rc("@nav-link-active") : rc("@nav-link")}>Home</a>
Next.js
The recipe catalog lives on disk, so build the registry server-side and pass expanded strings to client components as props:
// lib/rc.ts — import from server components / route handlers only
import path from "node:path";
import { expandClassList, loadRegistryFromDir } from "@shortwind/next";
const registry = loadRegistryFromDir(path.join(process.cwd(), "recipes"));
export function rc(classList: string): string {
return expandClassList(classList, registry, true);
}
Don’t glob the recipe sources into the client
An older version of this page suggested import.meta.glob("…/recipes/*.css", { query: "?raw" }). Don’t — that inlines the raw recipe sources, including
their @recipe definition tokens and cross-references, into the client
bundle. Those are exactly the tokens the build exists to eliminate, and they
fail the no-leftover-@recipe check. The virtual module (or the server-side
load in Next) gives you the same registry with none of the leakage.
Catching leaks: strict mode
The default build warns when it can see a stranded token in a class value,
and stays silent on the indirect cases. Every adapter also takes a
strict: true option that scans the entire transformed output for known
recipe tokens — catching the variable/prop indirection case too — and fails
the build instead of shipping unstyled UI:
// vite.config.ts
shortwind({ strict: true })
// next.config.ts
export default withShortwind({ strict: true })(nextConfig);
// astro.config.ts
integrations: [shortwind({ strict: true })]
Strict mode is opt-in because the detector is token-based: a file that legitimately names a recipe in prose (a docs page, a comment) would fail the build. For app code, turn it on — a leak the default warning can’t see is exactly the one you want to fail loudly.
When you don’t need rc()
- Static class lists — just write them literally.
class="@card p-6"is the whole feature; no helper required. - A handful of fixed variants — give each its own literal attribute and pick
the element, not the string: render the
@nav-link-activebranch or the@nav-linkbranch as separate literalclass="..."nodes.
Reach for rc() only when the class genuinely has to be computed in an
expression.