Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 136 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

**WindCtrl** is a next-generation styling utility that unifies static Tailwind classes and dynamic inline styles into a single, type-safe interface.

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.
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.
All of this is achieved with a minimal runtime footprint and full compatibility with Tailwind's JIT compiler.

## Features

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

Expand All @@ -24,7 +25,7 @@ npm install windctrl
## Quick Start

```typescript
import { windctrl } from "windctrl";
import { windctrl, dynamic as d } from "windctrl";

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

## Core Concepts

### Interpolated Variants (Dynamic Props)

Interpolated variants provide a **Unified API** that bridges static Tailwind classes and dynamic inline styles. A dynamic resolver can return either:

- a **Tailwind class string** (static utility), or
- an object containing **className and/or style** (inline styles and optional utilities)
### Variants

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

```typescript
const button = windctrl({
dynamic: {
// Recommended pattern:
// - Numbers -> inline styles (unbounded values)
// - Strings -> Tailwind utilities (must be statically enumerable for JIT)
w: (val) =>
typeof val === "number" ? { style: { width: `${val}px` } } : val,
variants: {
intent: {
primary: "bg-blue-500 text-white hover:bg-blue-600",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
},
size: {
sm: "text-sm h-8 px-3",
md: "text-base h-10 px-4",
lg: "text-lg h-12 px-6",
},
},
defaultVariants: {
intent: "primary",
size: "md",
},
});

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

> **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.

### Traits (Stackable States)

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`.
Expand All @@ -123,55 +122,141 @@ button({ traits: ["loading", "glass"] });
button({ traits: { loading: isLoading, glass: true } });
```

### Scopes (RSC Support)
### Slots (Compound Components)

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.
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.

Slots are completely optional and additive. You can start with a single-root component and introduce slots only when needed.
If a slot is never defined, it simply won't appear in the result.

```typescript
const button = windctrl({
scopes: {
header: "text-sm py-1",
footer: "text-xs text-gray-500",
base: {
root: "inline-flex items-center gap-2 rounded px-4 py-2",
slots: {
icon: "shrink-0",
label: "truncate",
},
},
variants: {
size: {
sm: {
root: "h-8 text-sm",
slots: { icon: "h-3 w-3" },
},
md: {
root: "h-10 text-base",
slots: { icon: "h-4 w-4" },
},
},
},
traits: {
loading: {
root: "opacity-70 pointer-events-none",
slots: { icon: "animate-spin" },
},
},
defaultVariants: { size: "md" },
});

// Usage
// 1. Wrap the parent with `data-windctrl-scope` and `group/windctrl-scope`
// 2. The button automatically adapts its style based on the parent
<div data-windctrl-scope="header" className="group/windctrl-scope">
<button className={button().className}>Header Button</button>
</div>
const { className, slots } = button({ size: "sm", traits: ["loading"] });

// Apply to elements
<button className={className}>
<Icon className={slots?.icon} />
<span className={slots?.label}>Click me</span>
</button>
```

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

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

Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
### Interpolated Variants (Dynamic Props)

Interpolated variants provide a **Unified API** that bridges static Tailwind classes and dynamic inline styles. A dynamic resolver can return either:

- a **Tailwind class string** (static utility), or
- an object containing **className and/or style** (inline styles and optional utilities)

This is **JIT-friendly by design**, as long as the class strings you return are statically enumerable (i.e. appear in your source code).
For truly unbounded values (e.g. pixel sizes), prefer returning style to avoid relying on arbitrary-value class generation.

#### Dynamic Presets

WindCtrl provides built-in presets for common dynamic patterns:

```typescript
import { windctrl, dynamic as d } from "windctrl";

const box = windctrl({
dynamic: {
// d.px() - pixel values (width, height, top, left, etc.)
w: d.px("width"),
h: d.px("height"),

// d.num() - unitless numbers (zIndex, flexGrow, order, etc.)
z: d.num("zIndex"),

// d.opacity() - opacity values
fade: d.opacity(),

// d.var() - CSS custom properties
x: d.var("--translate-x", { unit: "px" }),
},
});

// Usage
box({ w: 200 }); // -> style: { width: "200px" }
box({ w: "w-full" }); // -> className: "w-full"
box({ z: 50 }); // -> style: { zIndex: 50 }
box({ fade: 0.5 }); // -> style: { opacity: 0.5 }
box({ x: 10 }); // -> style: { "--translate-x": "10px" }
```

#### Custom Resolvers

You can also write custom resolvers for more complex logic:

```typescript
const button = windctrl({
variants: {
intent: {
primary: "bg-blue-500 text-white hover:bg-blue-600",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
},
size: {
sm: "text-sm h-8 px-3",
md: "text-base h-10 px-4",
lg: "text-lg h-12 px-6",
},
dynamic: {
// Custom resolver example
w: (val) =>
typeof val === "number" ? { style: { width: `${val}px` } } : val,
},
defaultVariants: {
intent: "primary",
size: "md",
});

// Usage
button({ w: "w-full" }); // -> className includes "w-full" (static utility)
button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
```

> **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.

### Scopes (RSC Support)

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.

```typescript
const button = windctrl({
scopes: {
header: "text-sm py-1",
footer: "text-xs text-gray-500",
},
});

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

The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute.

## Gotchas

- **Tailwind JIT:** Tailwind only generates CSS for class names it can statically detect. Avoid constructing class strings dynamically unless you safelist them.
Expand Down
8 changes: 3 additions & 5 deletions examples/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { windctrl } from "../src/index";
import { windctrl, dynamic as d } from "../src/index";
import type { ComponentPropsWithoutRef, ElementType } from "react";

const button = windctrl({
Expand All @@ -24,10 +24,8 @@ const button = windctrl({
disabled: "pointer-events-none opacity-50",
},
dynamic: {
w: (val) =>
typeof val === "number" ? { style: { width: `${val}px` } } : `w-${val}`,
h: (val) =>
typeof val === "number" ? { style: { height: `${val}px` } } : `h-${val}`,
w: d.px("width"),
h: d.px("height"),
},
defaultVariants: {
intent: "primary",
Expand Down
Loading