Skip to content

Commit dc81048

Browse files
timphandevclaude
andcommitted
docs(class-recipe): add a cookbook of conflict-free recipes
Recipes for a button, a badge, a card, an alert, a dialog, and a form field, each with a playground, and component code where it helps. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent f7d7f76 commit dc81048

9 files changed

Lines changed: 393 additions & 0 deletions

File tree

‎docs/astro.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export default defineConfig({
166166
},
167167
{
168168
items: [
169+
"class-recipe/cookbook",
169170
"class-recipe/editor-setup",
170171
"class-recipe/agent-skill",
171172
{ label: "Benchmarks", slug: "class-recipe/performance" },
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
---
2+
title: class-recipe cookbook
3+
description: "Ready-to-adapt class-recipe recipes for common components, written with Tailwind CSS and free of conflicting classes: button, badge, card, alert, dialog, and form field."
4+
sidebar:
5+
label: Cookbook
6+
---
7+
8+
import RecipePlayground from "../../../components/RecipePlayground.astro";
9+
10+
Recipes for common components, written with Tailwind CSS. Each one is
11+
conflict-free: it sets each CSS property of an element in one place, so it
12+
works with the default join, without tailwind-merge (see
13+
[Writing conflict-free recipes](/recipe/class-recipe/conflict-free-recipes/)).
14+
Copy one, then change its classes to match your design.
15+
16+
## Button
17+
18+
`state` replaces separate `loading` and `disabled` variants, which would
19+
both set the cursor and the opacity:
20+
21+
<RecipePlayground name="class-recipe/cookbook/button" />
22+
23+
The component derives `state` from its props:
24+
25+
```tsx
26+
import type { ComponentProps } from "react";
27+
import type { VariantsOf } from "@lynstack/class-recipe";
28+
29+
type ButtonProps = ComponentProps<"button"> &
30+
Omit<VariantsOf<typeof button>, "state"> & { readonly loading?: boolean };
31+
32+
export function Button({
33+
tone,
34+
size,
35+
loading = false,
36+
disabled = false,
37+
className,
38+
...props
39+
}: ButtonProps) {
40+
const state = disabled ? "disabled" : loading ? "loading" : "idle";
41+
return (
42+
<button
43+
className={button({ tone, size, state, className })}
44+
disabled={disabled || loading}
45+
aria-busy={loading}
46+
{...props}
47+
/>
48+
);
49+
}
50+
```
51+
52+
## Badge
53+
54+
The text color depends on both `tone` and `outlined`, so only the
55+
compound variants set it, one for each combination:
56+
57+
<RecipePlayground name="class-recipe/cookbook/badge" />
58+
59+
## Card
60+
61+
<RecipePlayground name="class-recipe/cookbook/card" />
62+
63+
```tsx
64+
const classes = card({ size: "sm" });
65+
66+
<section className={classes.root}>
67+
<header className={classes.header}>
68+
<h2 className={classes.title}>Usage</h2>
69+
<p className={classes.description}>This month</p>
70+
</header>
71+
<div className={classes.body}>…</div>
72+
<footer className={classes.footer}>…</footer>
73+
</section>;
74+
```
75+
76+
## Alert
77+
78+
One choice of `tone` styles the root, the icon, the title, and the
79+
description together:
80+
81+
<RecipePlayground name="class-recipe/cookbook/alert" />
82+
83+
## Dialog
84+
85+
The recipe styles the overlay and the content of a dialog. Use it with
86+
the dialog primitive of your choice, such as the `<dialog>` element or a
87+
headless component library:
88+
89+
<RecipePlayground name="class-recipe/cookbook/dialog" />
90+
91+
## Form field
92+
93+
A label, an input, and a message, whose colors follow `invalid`:
94+
95+
<RecipePlayground name="class-recipe/cookbook/field" />
96+
97+
```tsx
98+
import { useId, type ComponentProps } from "react";
99+
import type { VariantsOf } from "@lynstack/class-recipe";
100+
101+
type FieldProps = Omit<ComponentProps<"input">, "size"> &
102+
VariantsOf<typeof field> & {
103+
readonly label: string;
104+
readonly message?: string;
105+
};
106+
107+
export function Field({ label, message, size, invalid, ...props }: FieldProps) {
108+
const id = useId();
109+
const classes = field({ size, invalid });
110+
return (
111+
<div className={classes.root}>
112+
<label htmlFor={id} className={classes.label}>
113+
{label}
114+
</label>
115+
<input
116+
id={id}
117+
className={classes.input}
118+
aria-invalid={invalid}
119+
aria-describedby={message === undefined ? undefined : `${id}-message`}
120+
{...props}
121+
/>
122+
{message !== undefined && (
123+
<p id={`${id}-message`} className={classes.message}>
124+
{message}
125+
</p>
126+
)}
127+
</div>
128+
);
129+
}
130+
```
131+
132+
`size` is also an attribute of `<input>`, so the props omit it (see
133+
[Building components](/recipe/class-recipe/building-components/#variant-names-that-html-also-uses)).
134+
135+
## Next steps
136+
137+
- [Building components](/recipe/class-recipe/building-components/) shows
138+
how to type and split the props of these components.
139+
- [Composing recipes](/recipe/class-recipe/composing/) shares classes
140+
between recipes, such as the border of every form control.
141+
- [Using with shadcn/ui](/recipe/class-recipe/shadcn-ui/) converts the
142+
components of shadcn/ui.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { sva } from "@lynstack/class-recipe";
2+
3+
export const alert = sva({
4+
slots: ["root", "icon", "title", "description"],
5+
base: {
6+
root: "flex gap-3 rounded-lg border p-4",
7+
icon: "size-5 shrink-0",
8+
title: "font-semibold",
9+
description: "text-sm",
10+
},
11+
variants: {
12+
tone: {
13+
info: {
14+
root: "border-blue-200 bg-blue-50",
15+
icon: "text-blue-600",
16+
title: "text-blue-900",
17+
description: "text-blue-800",
18+
},
19+
success: {
20+
root: "border-green-200 bg-green-50",
21+
icon: "text-green-600",
22+
title: "text-green-900",
23+
description: "text-green-800",
24+
},
25+
danger: {
26+
root: "border-red-200 bg-red-50",
27+
icon: "text-red-600",
28+
title: "text-red-900",
29+
description: "text-red-800",
30+
},
31+
},
32+
},
33+
defaultVariants: { tone: "info" },
34+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { cva } from "@lynstack/class-recipe";
2+
3+
export const badge = cva({
4+
base: "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
5+
variants: {
6+
tone: {
7+
neutral: "",
8+
success: "",
9+
danger: "",
10+
},
11+
outlined: { true: "ring-1 ring-inset", false: "" },
12+
},
13+
compoundVariants: [
14+
{
15+
variants: { tone: "neutral", outlined: false },
16+
className: "bg-gray-100 text-gray-700",
17+
},
18+
{
19+
variants: { tone: "success", outlined: false },
20+
className: "bg-green-100 text-green-800",
21+
},
22+
{
23+
variants: { tone: "danger", outlined: false },
24+
className: "bg-red-100 text-red-800",
25+
},
26+
{
27+
variants: { tone: "neutral", outlined: true },
28+
className: "text-gray-700 ring-gray-300",
29+
},
30+
{
31+
variants: { tone: "success", outlined: true },
32+
className: "text-green-700 ring-green-600",
33+
},
34+
{
35+
variants: { tone: "danger", outlined: true },
36+
className: "text-red-700 ring-red-600",
37+
},
38+
],
39+
defaultVariants: { tone: "neutral" },
40+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { cva } from "@lynstack/class-recipe";
2+
3+
export const button = cva({
4+
base: "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2",
5+
variants: {
6+
tone: {
7+
primary:
8+
"bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline-blue-600",
9+
neutral:
10+
"bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:outline-gray-400",
11+
danger:
12+
"bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600",
13+
},
14+
size: {
15+
sm: "h-8 px-3 text-sm",
16+
md: "h-10 px-4 text-sm",
17+
lg: "h-12 px-6 text-base",
18+
},
19+
state: {
20+
idle: "cursor-pointer",
21+
loading: "cursor-wait opacity-75",
22+
disabled: "cursor-not-allowed opacity-50",
23+
},
24+
},
25+
defaultVariants: { tone: "primary", size: "md", state: "idle" },
26+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { sva } from "@lynstack/class-recipe";
2+
3+
export const card = sva({
4+
slots: ["root", "header", "title", "description", "body", "footer"],
5+
base: {
6+
root: "flex flex-col rounded-xl border border-gray-200 bg-white",
7+
header: "flex flex-col gap-1",
8+
title: "font-semibold text-gray-900",
9+
description: "text-sm text-gray-600",
10+
body: "text-sm text-gray-700",
11+
footer: "flex items-center justify-end gap-2",
12+
},
13+
variants: {
14+
size: {
15+
sm: {
16+
root: "gap-3 p-4",
17+
title: "text-base",
18+
},
19+
md: {
20+
root: "gap-4 p-6",
21+
title: "text-lg",
22+
},
23+
},
24+
elevated: {
25+
true: { root: "shadow-md" },
26+
false: { root: "shadow-none" },
27+
},
28+
},
29+
defaultVariants: { size: "md" },
30+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { sva } from "@lynstack/class-recipe";
2+
3+
export const dialog = sva({
4+
slots: ["overlay", "content", "header", "title", "description", "footer"],
5+
base: {
6+
overlay: "fixed inset-0 bg-black/50",
7+
content:
8+
"fixed top-1/2 left-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-xl bg-white p-6 shadow-lg",
9+
header: "flex flex-col gap-1",
10+
title: "text-lg font-semibold text-gray-900",
11+
description: "text-sm text-gray-600",
12+
footer: "flex justify-end gap-2",
13+
},
14+
variants: {
15+
size: {
16+
sm: { content: "max-w-sm" },
17+
md: { content: "max-w-lg" },
18+
lg: { content: "max-w-2xl" },
19+
},
20+
},
21+
defaultVariants: { size: "md" },
22+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { sva } from "@lynstack/class-recipe";
2+
3+
export const field = sva({
4+
slots: ["root", "label", "input", "message"],
5+
base: {
6+
root: "flex flex-col gap-1.5",
7+
label: "text-sm font-medium text-gray-900",
8+
input:
9+
"w-full rounded-md border bg-white outline-none focus-visible:ring-2",
10+
message: "text-xs",
11+
},
12+
variants: {
13+
size: {
14+
sm: { input: "h-8 px-2 text-sm" },
15+
md: { input: "h-10 px-3 text-base" },
16+
},
17+
invalid: {
18+
true: {
19+
input: "border-red-600 focus-visible:ring-red-200",
20+
message: "text-red-600",
21+
},
22+
false: {
23+
input: "border-gray-300 focus-visible:ring-blue-200",
24+
message: "text-gray-500",
25+
},
26+
},
27+
},
28+
defaultVariants: { size: "md" },
29+
});

0 commit comments

Comments
 (0)