WindRecipe
A tv() (tailwind-variants) equivalent for Wind. Composes a className from a base, a set of variants (one axis per key, each mapping a value to a className), optional compoundVariants (multi-condition rules), and defaultVariants (the selection applied when an axis is not passed by the caller).
WindSlotRecipe extends the same model to multi-part components: instead of a single string, each call returns a Map of slot-name to className.
Table of Contents
- Basic Usage
- Props: WindRecipe
- Props: WindSlotRecipe
- Constructor: WindRecipe
- Constructor: WindSlotRecipe
- Resolution Order
- Same-Granularity Override Contract
- Styling Examples
- Related Documentation
Basic Usage
final button = WindRecipe(
base: 'inline-flex items-center rounded-lg font-medium',
variants: {
'intent': {
'primary': 'bg-blue-600 dark:bg-blue-500 text-white',
'ghost': 'bg-transparent text-blue-600 dark:text-blue-400',
},
'size': {
'sm': 'px-3 py-1.5 text-sm',
'md': 'px-4 py-2 text-base',
},
},
defaultVariants: {'intent': 'primary', 'size': 'md'},
);
// Full defaults: "inline-flex items-center rounded-lg font-medium bg-blue-600 dark:bg-blue-500 text-white px-4 py-2 text-base"
button()
// Override intent, keep size default:
button(variants: {'intent': 'ghost'})
// Append extra classes:
button(className: 'w-full')
Props: WindRecipe
| Prop | Type | Default | Description |
|---|---|---|---|
base |
String |
'' |
Always-applied className. |
variants |
Map |
{} |
Variant axes: axis -> (value -> className). Iteration order fixes emission order. |
compoundVariants |
List |
[] |
Multi-condition rules applied in list order after variant classes. |
defaultVariants |
Map |
{} |
Default selection per axis. Overridden by the variants: argument at call site; a null value clears the default. |
Call signature (WindRecipe is callable):
| Param | Type | Default | Description |
|---|---|---|---|
variants |
Map |
null |
Caller's axis selections. null for an axis clears its default (no class emitted). Unknown axis throws ArgumentError. |
className |
String? |
null |
Extra caller className appended last. |
Props: WindSlotRecipe
| Prop | Type | Default | Description |
|---|---|---|---|
slots |
Map |
{} |
Base className per slot. Slot identity comes from these keys. |
variants |
Map |
{} |
Variant axes: axis -> (value -> (slot -> className)). |
compoundVariants |
List |
[] |
Multi-condition rules applied per slot in list order. |
defaultVariants |
Map |
{} |
Default selection per axis. |
Call signature (WindSlotRecipe is callable):
| Param | Type | Default | Description |
|---|---|---|---|
variants |
Map |
null |
Caller's axis selections. Unknown axis throws ArgumentError. |
classNames |
Map |
null |
Extra caller className per slot, appended last for that slot. |
Constructor: WindRecipe
WindRecipe({
String base = '',
Map> variants = const {},
List compoundVariants = const [],
Map defaultVariants = const {},
})
WindCompoundVariant constructor:
WindCompoundVariant({
required Map conditions,
required String className,
})
A condition value is a String (exact match) or List (OR match).
Constructor: WindSlotRecipe
WindSlotRecipe({
Map slots = const {},
Map>> variants = const {},
List compoundVariants = const [],
Map defaultVariants = const {},
})
WindSlotCompoundVariant constructor:
WindSlotCompoundVariant({
required Map conditions,
required Map classNames,
})
Resolution Order
Every call resolves in this strict order: no dedupe, no sort, no twMerge:
base ++ variant-classes(definition order) ++ matched-compound(array order) ++ caller
The caller's className / classNames always arrive last, so they override earlier tokens via Wind's per-family last-wins rule.
A null value in variants: clears the default for that axis (no class emitted for it), matching tv()'s undefined-clears semantics.
The Caller-Append Contract (No twMerge)
WindRecipe and WindSlotRecipe only APPEND the caller's className / classNames; they never dedupe, sort, or resolve a conflict themselves. A caller passing a className that conflicts with the base (e.g. WindRecipe(base: 'w-1/2')(className: 'w-full')) gets both tokens in the emitted string, in that order: 'w-1/2 w-full'.
The conflict is resolved one layer down, when the string reaches WindParser: WindParser.findAndGroupClasses groups same-family classes together WITHOUT deduping, and each parser (e.g. SizingParser.parse) resolves them with last-class-wins. So the appended w-full wins over the base w-1/2 at parse time, not at recipe-call time. There is intentionally no Dart port of tailwind-merge: wind's parse-time per-family last-wins already achieves the same outcome for every token family the parsers understand, which is why WindRecipe deliberately only appends (see the no-twMerge rationale in the WindRecipe doc comment in lib/src/recipe/wind_recipe.dart).
This is verified by test/recipe/wind_recipe_test.dart (proves the append, order preserved) and test/parser/parsers/sizing_parser_test.dart (proves the appended w-full resolves to the winning style).
Note: the className-REPLACE anti-pattern (a component treating an incoming className as a full recipe bypass instead of appending it) is a consumer-component bug, not a wind defect; wind's own recipes always append. When it appears, it lives in a downstream consumer's component layer, outside this package.
Same-Granularity Override Contract
Because Wind's parser resolves conflicts per-family with last-class-wins at parse time, a variant must override a base token at the same granularity. Mixing a shorthand and a longhand of the same family silently keeps the shorthand:
// Correct: override px-* with px-*
WindRecipe(
base: 'px-4',
variants: {'size': {'sm': 'px-2'}}, // overrides px-4 correctly
)
// Incorrect: base p-* vs variant px-* (same family, different granularity)
WindRecipe(
base: 'p-4',
variants: {'size': {'sm': 'px-2'}}, // px-2 does NOT override p-4; p-4 wins silently
)
In debug builds, WindRecipe logs a warning the first time it detects a shorthand/longhand mix.
Styling Examples
Button with Compound Variants
Compound variants contribute a className only when every condition is satisfied:
final button = WindRecipe(
base: 'inline-flex items-center rounded-lg font-medium',
variants: {
'intent': {
'primary': 'bg-blue-600 dark:bg-blue-500 text-white',
'destructive': 'bg-red-600 dark:bg-red-500 text-white',
},
'size': {'sm': 'px-3 py-1.5 text-sm', 'lg': 'px-6 py-3 text-lg'},
},
compoundVariants: [
WindCompoundVariant(
// Activates when intent is 'primary' OR 'destructive', AND size is 'lg'.
conditions: {'intent': ['primary', 'destructive'], 'size': 'lg'},
className: 'shadow-lg',
),
],
defaultVariants: {'intent': 'primary', 'size': 'sm'},
);
Multi-Slot Card Recipe
final card = WindSlotRecipe(
slots: {
'root': 'rounded-xl border overflow-hidden',
'header': 'px-5 py-4 border-b',
'body': 'px-5 py-4',
},
variants: {
'tone': {
'default': {
'root': 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700',
'header': 'border-gray-200 dark:border-gray-700',
'body': '',
},
'highlighted': {
'root': 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800',
'header': 'border-blue-200 dark:border-blue-800',
'body': '',
},
},
},
defaultVariants: {'tone': 'default'},
);
final classes = card(variants: {'tone': 'highlighted'});
WCard(
className: classes['root'],
header: WDiv(className: classes['header'], child: WText('Summary')),
child: WDiv(className: classes['body'], child: WText('Body')),
)