Skip to content

Commit 11f3e4e

Browse files
authored
Merge pull request #7 from morishxt/dev
v0.2
2 parents 7dd2788 + 770d72d commit 11f3e4e

4 files changed

Lines changed: 617 additions & 83 deletions

File tree

README.md

Lines changed: 136 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
55
**WindCtrl** is a next-generation styling utility that unifies static Tailwind classes and dynamic inline styles into a single, type-safe interface.
66

7-
It evolves the concept of Variant APIs (like [cva](https://cva.style/)) by introducing **Stackable Traits** to solve combinatorial explosion and **Interpolated Variants** for seamless dynamic value handling—all while maintaining a minimal runtime footprint optimized for Tailwind's JIT compiler.
7+
It builds on existing variant APIs (like [cva](https://cva.style/)) and introduces **Stackable Traits** to avoid combinatorial explosion, as well as **Interpolated Variants** for seamless dynamic styling.
8+
All of this is achieved with a minimal runtime footprint and full compatibility with Tailwind's JIT compiler.
89

910
## Features
1011

11-
- 🎨 **Unified API** - Seamlessly blends static Tailwind classes and dynamic inline styles into one cohesive interface.
1212
- 🧩 **Trait System** - Solves combinatorial explosion by treating states as stackable, non-exclusive layers.
13-
- 🎯 **Scoped Styling** - Context-aware styling using data attributes - no React Context required (RSC friendly).
13+
- 🎨 **Unified API** - Seamlessly blends static Tailwind classes and dynamic inline styles into one cohesive interface.
1414
-**JIT Conscious** - Designed for Tailwind JIT: utilities stay as class strings, while truly dynamic values can be expressed as inline styles.
15+
- 🎯 **Scoped Styling** - Context-aware styling using data attributes - no React Context required (RSC friendly).
1516
- 🔒 **Type-Safe** - Best-in-class TypeScript support with automatic prop inference.
1617
- 📦 **Minimal Overhead** - Ultra-lightweight runtime with only `clsx` and `tailwind-merge` as dependencies.
1718

@@ -24,7 +25,7 @@ npm install windctrl
2425
## Quick Start
2526

2627
```typescript
27-
import { windctrl } from "windctrl";
28+
import { windctrl, dynamic as d } from "windctrl";
2829

2930
const button = windctrl({
3031
base: "rounded px-4 py-2 font-medium transition duration-200",
@@ -44,8 +45,7 @@ const button = windctrl({
4445
glass: "backdrop-blur-md bg-white/10 border border-white/20 shadow-xl",
4546
},
4647
dynamic: {
47-
w: (val) =>
48-
typeof val === "number" ? { style: { width: `${val}px` } } : val,
48+
w: d.px("width"),
4949
},
5050
defaultVariants: {
5151
intent: "primary",
@@ -70,34 +70,33 @@ button({ w: "w-full" });
7070

7171
## Core Concepts
7272

73-
### Interpolated Variants (Dynamic Props)
74-
75-
Interpolated variants provide a **Unified API** that bridges static Tailwind classes and dynamic inline styles. A dynamic resolver can return either:
76-
77-
- a **Tailwind class string** (static utility), or
78-
- an object containing **className and/or style** (inline styles and optional utilities)
73+
### Variants
7974

80-
This is **JIT-friendly by design**, as long as the class strings you return are statically enumerable (i.e. appear in your source code).
81-
For truly unbounded values (e.g. pixel sizes), prefer returning style to avoid relying on arbitrary-value class generation.
75+
Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
8276

8377
```typescript
8478
const button = windctrl({
85-
dynamic: {
86-
// Recommended pattern:
87-
// - Numbers -> inline styles (unbounded values)
88-
// - Strings -> Tailwind utilities (must be statically enumerable for JIT)
89-
w: (val) =>
90-
typeof val === "number" ? { style: { width: `${val}px` } } : val,
79+
variants: {
80+
intent: {
81+
primary: "bg-blue-500 text-white hover:bg-blue-600",
82+
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
83+
},
84+
size: {
85+
sm: "text-sm h-8 px-3",
86+
md: "text-base h-10 px-4",
87+
lg: "text-lg h-12 px-6",
88+
},
89+
},
90+
defaultVariants: {
91+
intent: "primary",
92+
size: "md",
9193
},
9294
});
9395

9496
// Usage
95-
button({ w: "w-full" }); // -> className includes "w-full" (static utility)
96-
button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
97+
button({ intent: "primary", size: "lg" });
9798
```
9899

99-
> **Note on Tailwind JIT**: Tailwind only generates CSS for class names it can statically detect in your source. Avoid constructing class strings dynamically (e.g. "`w-`" + `size`) unless you safelist them in your Tailwind config.
100-
101100
### Traits (Stackable States)
102101

103102
Traits are non-exclusive, stackable layers of state. Unlike `variants` (which are mutually exclusive), multiple traits can be active simultaneously. This declarative approach solves the "combinatorial explosion" problem often seen with `compoundVariants`.
@@ -123,55 +122,141 @@ button({ traits: ["loading", "glass"] });
123122
button({ traits: { loading: isLoading, glass: true } });
124123
```
125124

126-
### Scopes (RSC Support)
125+
### Slots (Compound Components)
127126

128-
Scopes enable **context-aware styling** without relying on React Context or client-side JavaScript. This makes them fully compatible with React Server Components (RSC). They utilize Tailwind's group modifier logic under the hood.
127+
Slots allow you to define styles for **sub-elements** (e.g., icon, label) within a single component definition. Each slot returns its own class string, enabling clean compound component patterns.
128+
129+
Slots are completely optional and additive. You can start with a single-root component and introduce slots only when needed.
130+
If a slot is never defined, it simply won't appear in the result.
129131

130132
```typescript
131133
const button = windctrl({
132-
scopes: {
133-
header: "text-sm py-1",
134-
footer: "text-xs text-gray-500",
134+
base: {
135+
root: "inline-flex items-center gap-2 rounded px-4 py-2",
136+
slots: {
137+
icon: "shrink-0",
138+
label: "truncate",
139+
},
140+
},
141+
variants: {
142+
size: {
143+
sm: {
144+
root: "h-8 text-sm",
145+
slots: { icon: "h-3 w-3" },
146+
},
147+
md: {
148+
root: "h-10 text-base",
149+
slots: { icon: "h-4 w-4" },
150+
},
151+
},
152+
},
153+
traits: {
154+
loading: {
155+
root: "opacity-70 pointer-events-none",
156+
slots: { icon: "animate-spin" },
157+
},
135158
},
159+
defaultVariants: { size: "md" },
136160
});
137161

138162
// Usage
139-
// 1. Wrap the parent with `data-windctrl-scope` and `group/windctrl-scope`
140-
// 2. The button automatically adapts its style based on the parent
141-
<div data-windctrl-scope="header" className="group/windctrl-scope">
142-
<button className={button().className}>Header Button</button>
143-
</div>
163+
const { className, slots } = button({ size: "sm", traits: ["loading"] });
164+
165+
// Apply to elements
166+
<button className={className}>
167+
<Icon className={slots?.icon} />
168+
<span className={slots?.label}>Click me</span>
169+
</button>
144170
```
145171

146-
The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute.
172+
Slots follow the same priority rules as root classes: **Base < Variants < Traits**, with `tailwind-merge` handling conflicts.
147173

148-
### Variants
174+
Unlike slot-based APIs that require declaring all slots upfront, WindCtrl allows slots to emerge naturally from variants and traits.
149175

150-
Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
176+
### Interpolated Variants (Dynamic Props)
177+
178+
Interpolated variants provide a **Unified API** that bridges static Tailwind classes and dynamic inline styles. A dynamic resolver can return either:
179+
180+
- a **Tailwind class string** (static utility), or
181+
- an object containing **className and/or style** (inline styles and optional utilities)
182+
183+
This is **JIT-friendly by design**, as long as the class strings you return are statically enumerable (i.e. appear in your source code).
184+
For truly unbounded values (e.g. pixel sizes), prefer returning style to avoid relying on arbitrary-value class generation.
185+
186+
#### Dynamic Presets
187+
188+
WindCtrl provides built-in presets for common dynamic patterns:
189+
190+
```typescript
191+
import { windctrl, dynamic as d } from "windctrl";
192+
193+
const box = windctrl({
194+
dynamic: {
195+
// d.px() - pixel values (width, height, top, left, etc.)
196+
w: d.px("width"),
197+
h: d.px("height"),
198+
199+
// d.num() - unitless numbers (zIndex, flexGrow, order, etc.)
200+
z: d.num("zIndex"),
201+
202+
// d.opacity() - opacity values
203+
fade: d.opacity(),
204+
205+
// d.var() - CSS custom properties
206+
x: d.var("--translate-x", { unit: "px" }),
207+
},
208+
});
209+
210+
// Usage
211+
box({ w: 200 }); // -> style: { width: "200px" }
212+
box({ w: "w-full" }); // -> className: "w-full"
213+
box({ z: 50 }); // -> style: { zIndex: 50 }
214+
box({ fade: 0.5 }); // -> style: { opacity: 0.5 }
215+
box({ x: 10 }); // -> style: { "--translate-x": "10px" }
216+
```
217+
218+
#### Custom Resolvers
219+
220+
You can also write custom resolvers for more complex logic:
151221

152222
```typescript
153223
const button = windctrl({
154-
variants: {
155-
intent: {
156-
primary: "bg-blue-500 text-white hover:bg-blue-600",
157-
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
158-
},
159-
size: {
160-
sm: "text-sm h-8 px-3",
161-
md: "text-base h-10 px-4",
162-
lg: "text-lg h-12 px-6",
163-
},
224+
dynamic: {
225+
// Custom resolver example
226+
w: (val) =>
227+
typeof val === "number" ? { style: { width: `${val}px` } } : val,
164228
},
165-
defaultVariants: {
166-
intent: "primary",
167-
size: "md",
229+
});
230+
231+
// Usage
232+
button({ w: "w-full" }); // -> className includes "w-full" (static utility)
233+
button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
234+
```
235+
236+
> **Note on Tailwind JIT**: Tailwind only generates CSS for class names it can statically detect in your source. Avoid constructing class strings dynamically (e.g. "`w-`" + `size`) unless you safelist them in your Tailwind config.
237+
238+
### Scopes (RSC Support)
239+
240+
Scopes enable **context-aware styling** without relying on React Context or client-side JavaScript. This makes them fully compatible with React Server Components (RSC). They utilize Tailwind's group modifier logic under the hood.
241+
242+
```typescript
243+
const button = windctrl({
244+
scopes: {
245+
header: "text-sm py-1",
246+
footer: "text-xs text-gray-500",
168247
},
169248
});
170249

171250
// Usage
172-
button({ intent: "primary", size: "lg" });
251+
// 1. Wrap the parent with `data-windctrl-scope` and `group/windctrl-scope`
252+
// 2. The button automatically adapts its style based on the parent
253+
<div data-windctrl-scope="header" className="group/windctrl-scope">
254+
<button className={button().className}>Header Button</button>
255+
</div>
173256
```
174257

258+
The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute.
259+
175260
## Gotchas
176261

177262
- **Tailwind JIT:** Tailwind only generates CSS for class names it can statically detect. Avoid constructing class strings dynamically unless you safelist them.

examples/Button.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { windctrl } from "../src/index";
2+
import { windctrl, dynamic as d } from "../src/index";
33
import type { ComponentPropsWithoutRef, ElementType } from "react";
44

55
const button = windctrl({
@@ -24,10 +24,8 @@ const button = windctrl({
2424
disabled: "pointer-events-none opacity-50",
2525
},
2626
dynamic: {
27-
w: (val) =>
28-
typeof val === "number" ? { style: { width: `${val}px` } } : `w-${val}`,
29-
h: (val) =>
30-
typeof val === "number" ? { style: { height: `${val}px` } } : `h-${val}`,
27+
w: d.px("width"),
28+
h: d.px("height"),
3129
},
3230
defaultVariants: {
3331
intent: "primary",

0 commit comments

Comments
 (0)