Skip to content

Commit 08264f3

Browse files
timphandevclaude
andcommitted
docs: correct and complete the guide to migrating from tailwind-variants
Show how to extend a variant that both recipes declare, which a spread would replace, and describe what the undeclared base slot holds. Add empty strings from untyped data, slot calls without the variants, cnMerge, theme keys of extendTailwindMerge, and the fixes for compound conditions on undefined and on props that are not boolean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 71a05c4 commit 08264f3

7 files changed

Lines changed: 175 additions & 41 deletions

File tree

‎docs/src/content/docs/class-recipe/migrating-from-tailwind-variants.mdx‎

Lines changed: 85 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,27 @@ description: "Move recipes from tailwind-variants to class-recipe: tv becomes cv
66
import CodeComparison from "../../../components/CodeComparison.astro";
77

88
A recipe of tailwind-variants without slots becomes a `cva` recipe, and
9-
one with slots becomes an `sva` recipe. Most of the config moves over
10-
with a few mechanical changes:
11-
12-
| tailwind-variants | class-recipe |
13-
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
14-
| `tv(config)` | `cva(config)`, or `sva(config)` with slots |
15-
| `tv({ base })`, without variants | `cva({ base, variants: {} })`, since `variants` [is required](#a-recipe-needs-variants) |
16-
| Arrays of classes, including shared arrays spread into them | One string wherever the config takes classes; `cx(...)` turns an array into one |
17-
| An option whose classes are `null` or `false` | An option whose classes are `""` |
18-
| `{ size: "sm", class: "..." }` | `{ variants: { size: "sm" }, className: "..." }` |
19-
| `class` or `className` prop | `className` prop |
20-
| `slots: { base: "...", title: "..." }` | `slots: ["root", "title"]` and `base: { root: "...", title: "..." }` |
21-
| In a slot recipe, an option or a compound variant's `class` given as a string | The same classes under the slot that tailwind-variants names `base`, such as `{ root: "..." }` |
22-
| `{ size: "sm", class: { title: "..." } }` | `{ variants: { size: "sm" }, classNames: { title: "..." } }` |
23-
| `compoundSlots` | A compound variant that gives the same classes to each slot in `classNames`, listed after the other compound variants |
24-
| `title({ class: "..." })` | `card({ classNames: { title: "..." } })` |
25-
| `extend` | A shared config object, spread into each recipe |
26-
| `VariantProps<typeof button>` | `VariantsOf<typeof button>`, in which [a required variant stays required](#a-variant-without-a-default-becomes-required) |
27-
| `tv`, `cn`, and `cnMerge`, which merge | `cva`, `sva`, and `cx` from `createRecipes({ join: twMerge })` |
28-
| `createTV({ twMergeConfig })` | `createRecipes` with `extendTailwindMerge`, as in [Merging classes](#merging-classes) |
29-
| `tailwind-variants/lite`, its `cn(...)()`, and `cx`, which do not merge | `cva`, `sva`, and `cx` from `@lynstack/class-recipe` |
9+
one with slots, or that extends a recipe with slots, becomes an `sva`
10+
recipe. Most of the config moves over with a few mechanical changes:
11+
12+
| tailwind-variants | class-recipe |
13+
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
14+
| `tv(config)` | `cva(config)`, or `sva(config)` with slots |
15+
| `tv({ base })`, without variants | `cva({ base, variants: {} })`, since `variants` [is required](#a-recipe-needs-variants) |
16+
| Arrays of classes, including shared arrays spread into them | One string wherever the config takes classes; `cx(...)` turns an array into one |
17+
| An option whose classes are `null` or `false` | An option whose classes are `""` |
18+
| `{ size: "sm", class: "..." }`, or `className` | `{ variants: { size: "sm" }, className: "..." }` |
19+
| `class` or `className` prop | `className` prop, which takes one string and ignores any other value; `cx(...)` turns an array into one |
20+
| `slots: { base: "...", title: "..." }` | `slots: ["root", "title"]` and `base: { root: "...", title: "..." }` |
21+
| In a slot recipe, `base`, an option, or a compound variant's `class` given as a string | The same classes under the slot that tailwind-variants names `base`, such as `{ root: "..." }` |
22+
| `{ size: "sm", class: { title: "..." } }` | `{ variants: { size: "sm" }, classNames: { title: "..." } }` |
23+
| `compoundSlots` | A compound variant that gives the same classes to each slot in `classNames`, listed after the other compound variants |
24+
| `title({ class: "..." })` | `card({ classNames: { title: "..." } })` |
25+
| `extend` | A shared config object, spread into each recipe |
26+
| `VariantProps<typeof button>` | `VariantsOf<typeof button>`, from `@lynstack/class-recipe`, in which [a required variant stays required](#a-variant-without-a-default-becomes-required) |
27+
| `tv`, `cn(...)`, and `cnMerge(...)(config)`, which merge | `cva`, `sva`, and `cx(...)` from `createRecipes({ join: twMerge })` |
28+
| `createTV({ twMergeConfig })`, or a `tv` of your own that passes one | `createRecipes` with `extendTailwindMerge`, as in [Merging classes](#merging-classes) |
29+
| `tailwind-variants/lite`, its `cn(...)()`, and `cx`, which do not merge | `cva`, `sva`, and `cx` from `@lynstack/class-recipe` |
3030

3131
## Recipes
3232

@@ -42,6 +42,11 @@ wins:
4242

4343
<CodeComparison name="tailwind-variants/merged-classes" />
4444

45+
The merge also drops duplicate classes, and both builds of
46+
tailwind-variants collapse extra spaces. The default join keeps the
47+
classes as the config writes them, which changes snapshots of class
48+
names, but not which classes an element has.
49+
4550
If you used `tailwind-variants/lite`, or `twMerge: false` everywhere,
4651
nothing was merged: import `cva`, `sva`, and `cx` from
4752
`@lynstack/class-recipe`. You can also design your recipes so that no
@@ -61,15 +66,17 @@ export const { cx, cva, sva } = createRecipes({ join: twMerge });
6166

6267
The `cx` it returns merges as `cn` does. For a recipe that passed
6368
`{ twMerge: false }` or its own `twMergeConfig` to `tv`, create another
64-
set of functions with `createRecipes`.
69+
set of functions with `createRecipes`, with the settings of both configs
70+
if your own `tv` combines them.
6571

6672
If you pass a `twMergeConfig`, build the join with `extendTailwindMerge`
6773
from the same settings:
6874

6975
- Give the names of the class groups that the config adds, and that
70-
tailwind-merge does not define, as its type argument, as `elevation`
71-
below. A group that tailwind-merge defines, such as `shadow`, needs
72-
none.
76+
tailwind-merge does not define, as its first type argument, as
77+
`elevation` below, and the names of the theme keys it adds as its
78+
second. A group or a key that tailwind-merge defines, such as `shadow`
79+
or `spacing`, needs none.
7380
- Move the settings of class groups that the config gives at its top
7481
level, such as `classGroups` or `theme`, under `extend`.
7582
- Leave out `prefix`: tailwind-variants 3 ignores it, and tailwind-merge
@@ -106,13 +113,20 @@ the `base` property.
106113

107114
A slot function of tailwind-variants can also take variants of its own,
108115
as in `title({ size: "md" })`, which override those of the call. Call the
109-
slot recipe again with those variants instead.
116+
slot recipe again with both instead: `card({ ...variants, size: "md" }).title`.
117+
A component that receives the slots without the variants, such as one
118+
that reads them from a context, joins its own classes with `cx`:
119+
`cx(slots.title, className)` gives the classes that `classNames` gives.
120+
tailwind-variants ignores `class` and `className` passed to the slot
121+
recipe itself, rather than to a slot, so drop them.
110122

111123
tailwind-variants also returns a `base` slot that the config does not
112-
declare. Its function returns `undefined` unless an option or a compound
113-
variant gives classes as a string. A slot recipe returns only the slots
114-
that `slots` lists, so give such classes to a slot you declare, and drop
115-
a `base` that you read from such a recipe.
124+
declare. It holds the classes of the config's top-level `base`, unless
125+
`slots.base` replaces them, and of each option or compound variant that
126+
gives classes as a string; without them, its function returns
127+
`undefined`. A slot recipe returns only the slots that `slots` lists, so
128+
give such classes to a slot you declare, and drop a `base` that you read
129+
from such a recipe.
116130

117131
A compound slot becomes a compound variant that gives the same classes to
118132
each of its slots. tailwind-variants adds the classes of compound slots
@@ -135,9 +149,16 @@ classes of an option given again to those of the recipe it extends,
135149
merges `defaultVariants` and `slots`, and concatenates `compoundVariants`
136150
and `compoundSlots`. It also lists the variants of the new recipe before
137151
those it extends, which decides the order of their classes. A spread
138-
replaces each property it gives again, so join `base` yourself, repeat
139-
the classes of an option you keep, spread the shared `compoundVariants`
140-
into the new array, and declare the new variants first, as above.
152+
replaces each property it gives again, so:
153+
154+
- Join `base` yourself, the classes of the recipe it extends first.
155+
- Spread the shared `compoundVariants` first into the new array.
156+
- Declare the new variants before the shared ones, as above.
157+
- Take a variant that both recipes declare out of the shared variants,
158+
which would replace it, and declare it with the new ones, joining the
159+
classes of each option that both give:
160+
161+
<CodeComparison name="tailwind-variants/extend-variant" />
141162

142163
A slot recipe extends the same way. Spread its `slots` into the new list,
143164
and join the `base` classes of each slot that the new recipe gives again.
@@ -161,12 +182,17 @@ omitted, as tailwind-variants does, default to an option without classes:
161182

162183
<CodeComparison name="tailwind-variants/required-variant" />
163184

164-
The new option becomes a value that the props of a component accept.
165-
Give it a name that the variant does not use yet, such as `none` or
166-
`unset`; in a slot recipe, its classes are `{}`. If every caller already
167-
passes the variant, as a component does that gives the prop a default
168-
when it destructures it, make that value the default of the recipe
169-
instead, and the component needs no default of its own.
185+
Give the new option a name that the variant does not use yet, such as
186+
`none` or `unset`; it becomes a value that the props of a component
187+
accept. In a slot recipe, its classes are `{}`. An option without classes
188+
that the variant declares already works too, unless a compound variant
189+
names it, since the condition would then match calls that omit the
190+
variant.
191+
192+
If every caller already passes the variant, such as a component that
193+
destructures the prop with a default, make that value the default of the
194+
recipe instead. Keep the component's default as well if it reads the
195+
prop for anything else, such as a `data-variant` attribute.
170196

171197
### Only a boolean variant falls back to its `false` option
172198

@@ -178,7 +204,8 @@ options is required instead, so make `"false"` its default:
178204

179205
### A recipe without classes returns `""`
180206

181-
tailwind-variants returns `undefined` when a call adds no classes:
207+
tailwind-variants returns `undefined` when a call adds no classes, and so
208+
do a slot without classes, `cn`, and `cx`:
182209

183210
<CodeComparison name="tailwind-variants/empty-result" />
184211

@@ -192,6 +219,10 @@ no classes, and the condition matches as before:
192219

193220
<CodeComparison name="tailwind-variants/undeclared-variant" />
194221

222+
For a prop that is not boolean, declare an option for each value that a
223+
condition names, and one more as its default, such as `none`, so that
224+
calls without the prop stay valid.
225+
195226
### A recipe needs `variants`
196227

197228
A recipe without them is a type error and throws a `TypeError` when it
@@ -201,7 +232,8 @@ is created. Pass `variants: {}`.
201232

202233
tailwind-variants also exposes the config of a recipe, such as
203234
`button.variants` and `button.slots`. Keep the config in an object if you
204-
read it.
235+
read it. `variantKeys` is a readonly array, so a function that takes it
236+
as `K[]` needs `readonly K[]`.
205237

206238
## Behaviors that change with untyped data
207239

@@ -217,6 +249,13 @@ one with a default:
217249

218250
<CodeComparison name="tailwind-variants/null-variant" />
219251

252+
### An empty string adds no classes for the variant
253+
254+
tailwind-variants uses the variant's `false` option instead, even when
255+
the variant has a default:
256+
257+
<CodeComparison name="tailwind-variants/empty-string" />
258+
220259
### A compound condition on `undefined` matches any option
221260

222261
A condition on `undefined` matches any option, as a variant left out of
@@ -226,6 +265,11 @@ or a default of `false`:
226265

227266
<CodeComparison name="tailwind-variants/undefined-condition" />
228267

268+
Name the option instead: the option that a variant without a default now
269+
defaults to, such as `none` or `false`, or `false` for a variant whose
270+
default is `false`. For a variant with another default, the condition
271+
matched only untyped values, so delete the compound variant.
272+
229273
### A compound condition matches an option by its name
230274

231275
A condition on `true` matches the prop `"true"`, and one on `2` matches
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { cva } from "@lynstack/class-recipe";
2+
3+
export const stack = cva({
4+
base: "flex",
5+
variants: {
6+
gap: { false: "gap-4", tight: "gap-1" },
7+
},
8+
defaultVariants: { gap: "false" },
9+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { tv } from "tailwind-variants";
2+
3+
export const stack = tv({
4+
base: "flex",
5+
variants: {
6+
gap: { false: "gap-4", tight: "gap-1" },
7+
},
8+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { stack as afterStack } from "./after.ts";
2+
import type { stack as beforeStack } from "./before.ts";
3+
4+
export const recipe = "stack";
5+
6+
export const before: Parameters<typeof beforeStack>[0][] = [
7+
// @ts-expect-error -- an empty string from untyped data
8+
{ gap: "" },
9+
];
10+
11+
export const after: Parameters<typeof afterStack>[0][] = [
12+
// @ts-expect-error -- an empty string from untyped data
13+
{ gap: "" },
14+
];
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { cva } from "@lynstack/class-recipe";
2+
3+
const buttonConfig = {
4+
base: "rounded-md font-medium",
5+
variants: {
6+
size: { sm: "h-8 px-3", md: "h-10 px-4" },
7+
width: { auto: "w-auto", full: "w-full" },
8+
},
9+
defaultVariants: { size: "md", width: "auto" },
10+
} as const;
11+
12+
export const button = cva(buttonConfig);
13+
14+
const { size, ...buttonVariants } = buttonConfig.variants;
15+
16+
export const iconButton = cva({
17+
...buttonConfig,
18+
variants: {
19+
tone: { neutral: "bg-gray-100", danger: "bg-red-600 text-white" },
20+
size: { ...size, sm: `${size.sm} text-xs`, lg: "h-12 px-6" },
21+
...buttonVariants,
22+
},
23+
defaultVariants: { ...buttonConfig.defaultVariants, tone: "neutral" },
24+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { tv } from "tailwind-variants";
2+
3+
export const button = tv({
4+
base: "rounded-md font-medium",
5+
variants: {
6+
size: { sm: "h-8 px-3", md: "h-10 px-4" },
7+
width: { auto: "w-auto", full: "w-full" },
8+
},
9+
defaultVariants: { size: "md", width: "auto" },
10+
});
11+
12+
export const iconButton = tv({
13+
extend: button,
14+
variants: {
15+
tone: { neutral: "bg-gray-100", danger: "bg-red-600 text-white" },
16+
size: { sm: "text-xs", lg: "h-12 px-6" },
17+
},
18+
defaultVariants: { tone: "neutral" },
19+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { iconButton as afterIconButton } from "./after.ts";
2+
import type { iconButton as beforeIconButton } from "./before.ts";
3+
4+
export const recipe = "iconButton";
5+
6+
export const before: Parameters<typeof beforeIconButton>[0][] = [
7+
{ size: "sm" },
8+
{ size: "md", tone: "danger" },
9+
{ size: "lg", width: "full" },
10+
];
11+
12+
export const after: Parameters<typeof afterIconButton>[0][] = [
13+
{ size: "sm" },
14+
{ size: "md", tone: "danger" },
15+
{ size: "lg", width: "full" },
16+
];

0 commit comments

Comments
 (0)