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
162 changes: 107 additions & 55 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 Down Expand Up @@ -69,6 +70,109 @@ button({ w: "w-full" });

## Core Concepts

### Variants

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({
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({ intent: "primary", size: "lg" });
```

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

Traits are **non-exclusive, stackable modifiers**. Unlike variants (mutually exclusive design choices), multiple traits can be active at the same time. This is a practical way to model boolean-like component states (e.g. `loading`, `disabled`, `glass`) without exploding compoundVariants.

When multiple traits generate conflicting utilities, Tailwind’s “last one wins” rule applies (via `tailwind-merge`).
If ordering matters, prefer the **array form** to make precedence explicit.

```typescript
const button = windctrl({
traits: {
loading: "opacity-50 cursor-wait",
glass: "backdrop-blur-md bg-white/10 border border-white/20",
disabled: "pointer-events-none grayscale",
},
});

// Usage - Array form (explicit precedence; recommended when conflicts are possible)
button({ traits: ["loading", "glass"] });

// Usage - Object form (convenient for boolean props; order is not intended to be meaningful)
button({ traits: { loading: isLoading, glass: true } });
```

### Slots (Compound Components)

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({
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
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>
```

Slots follow the same priority rules as root classes: **Base < Variants < Traits**, with `tailwind-merge` handling conflicts.

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

### 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:
Expand Down Expand Up @@ -131,31 +235,6 @@ 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.

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

Traits are **non-exclusive, stackable modifiers**. Unlike variants (mutually exclusive design choices), multiple traits can be active at the same time. This is a practical way to model boolean-like component states (e.g. `loading`, `disabled`, `glass`) without exploding compoundVariants.

When multiple traits generate conflicting utilities, Tailwind’s “last one wins” rule applies (via `tailwind-merge`).
If ordering matters, prefer the **array form** to make precedence explicit.

```typescript
const button = windctrl({
traits: {
loading: "opacity-50 cursor-wait",
glass: "backdrop-blur-md bg-white/10 border border-white/20",
disabled: "pointer-events-none grayscale",
},
});

// Usage - Array form (explicit precedence; recommended when conflicts are possible)
button({ traits: ["loading", "glass"] });

// Usage - Object form (convenient for boolean props; order is not intended to be meaningful)
button({ traits: { loading: isLoading, glass: true } });
```

### 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.
Expand All @@ -178,33 +257,6 @@ const button = windctrl({

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

### Variants

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({
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({ intent: "primary", size: "lg" });
```

## 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
140 changes: 140 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,4 +744,144 @@ describe("windctrl", () => {
expect(result2.style).toEqual({ width: "100px" });
});
});

describe("Slots", () => {
it("should return slots as class strings and keep root as className/style", () => {
const button = windctrl({
base: {
root: "rounded",
slots: {
icon: "shrink-0",
label: "truncate",
},
},
});

const result = button();

// root stays on className/style
expect(result.className).toContain("rounded");
expect(result.style).toEqual(undefined);

// slots exist as strings
expect(result.slots?.icon).toContain("shrink-0");
expect(result.slots?.label).toContain("truncate");
});

it("should apply variant slot classes based on prop value (and keep root variants working)", () => {
const button = windctrl({
base: {
root: "inline-flex",
slots: { icon: "shrink-0" },
},
variants: {
size: {
sm: {
root: "h-8",
slots: { icon: "h-3 w-3" },
},
md: {
root: "h-10",
slots: { icon: "h-4 w-4" },
},
},
},
defaultVariants: { size: "md" },
});

const sm = button({ size: "sm" });
expect(sm.className).toContain("inline-flex");
expect(sm.className).toContain("h-8");
expect(sm.slots?.icon).toContain("h-3");
expect(sm.slots?.icon).toContain("w-3");

const fallback = button({});
expect(fallback.className).toContain("h-10");
expect(fallback.slots?.icon).toContain("h-4");
expect(fallback.slots?.icon).toContain("w-4");
});

it("should apply trait slot classes (array form) and merge with base/variants", () => {
const button = windctrl({
base: {
root: "inline-flex",
slots: { icon: "shrink-0" },
},
variants: {
size: {
sm: { slots: { icon: "h-3 w-3" } },
},
},
traits: {
loading: {
root: "opacity-70",
slots: { icon: "animate-spin" },
},
},
});

const result = button({ size: "sm", traits: ["loading"] });

// root gets trait too
expect(result.className).toContain("opacity-70");

// icon gets base + variant + trait
expect(result.slots?.icon).toContain("shrink-0");
expect(result.slots?.icon).toContain("h-3");
expect(result.slots?.icon).toContain("w-3");
expect(result.slots?.icon).toContain("animate-spin");
});

it("should let Traits override Variants on slots when Tailwind classes conflict (via twMerge)", () => {
const button = windctrl({
variants: {
intent: {
primary: { slots: { icon: "text-blue-500" } },
},
},
traits: {
dangerIcon: { slots: { icon: "text-red-500" } },
},
});

const result = button({ intent: "primary", traits: ["dangerIcon"] });

// last one wins: Traits > Variants
expect(result.slots?.icon).toContain("text-red-500");
expect(result.slots?.icon).not.toContain("text-blue-500");
});

it("should ignore invalid trait keys for slots gracefully (same behavior as root traits)", () => {
const button = windctrl({
traits: {
loading: { slots: { icon: "animate-spin" } },
},
});

const result = button({ traits: ["invalid-trait" as any] });

expect(result.slots?.icon).toBe(undefined);
});

it("should not include slots when slots are not configured", () => {
const button = windctrl({
base: "rounded",
variants: {
size: {
sm: "text-sm",
},
},
traits: {
loading: "opacity-50",
},
});

const result = button({ size: "sm", traits: ["loading"] });

expect(result.className).toContain("rounded");
expect(result.className).toContain("text-sm");
expect(result.className).toContain("opacity-50");
expect((result as any).slots).toBe(undefined);
});
});
});
Loading