From 5fad24eac0d0cb02819759e99ffc1be85958f0ae Mon Sep 17 00:00:00 2001 From: Masaki Morishita Date: Tue, 23 Dec 2025 10:15:17 +0900 Subject: [PATCH 1/7] test,feat(dynamic): add tests for dynamic presets --- src/index.test.ts | 140 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/src/index.test.ts b/src/index.test.ts index 7887005..f117e5e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { windctrl, wc } from "./"; +import { windctrl, wc, dynamic as d } from "./"; describe("wc", () => { it("should be the same as windctrl", () => { @@ -321,6 +321,144 @@ describe("windctrl", () => { }); }); + describe("Dynamic presets", () => { + describe("d.px()", () => { + it("should output inline style for number (px) and keep className empty", () => { + const box = windctrl({ + dynamic: { + w: d.px("width"), + }, + }); + + const result = box({ w: 123 }); + expect(result.style).toEqual({ width: "123px" }); + expect(result.className).toBe(""); + }); + + it("should pass through className string for string input (Unified API)", () => { + const box = windctrl({ + dynamic: { + w: d.px("width"), + }, + }); + + const result = box({ w: "w-full" }); + expect(result.className).toContain("w-full"); + expect(result.style).toEqual(undefined); + }); + }); + + describe("d.num()", () => { + it("should output inline style for number (unitless) and keep className empty", () => { + const layer = windctrl({ + dynamic: { + z: d.num("zIndex"), + }, + }); + + const result = layer({ z: 999 }); + expect(result.style).toEqual({ zIndex: 999 }); + expect(result.className).toBe(""); + }); + + it("should pass through className string for string input (Unified API)", () => { + const layer = windctrl({ + dynamic: { + z: d.num("zIndex"), + }, + }); + + const result = layer({ z: "z-50" }); + expect(result.className).toContain("z-50"); + expect(result.style).toEqual(undefined); + }); + }); + + describe("d.opacity()", () => { + it("should output inline style for number and keep className empty", () => { + const fade = windctrl({ + dynamic: { + opacity: d.opacity(), + }, + }); + + const result = fade({ opacity: 0.4 }); + expect(result.style).toEqual({ opacity: 0.4 }); + expect(result.className).toBe(""); + }); + + it("should pass through className string for string input (Unified API)", () => { + const fade = windctrl({ + dynamic: { + opacity: d.opacity(), + }, + }); + + const result = fade({ opacity: "opacity-50" }); + expect(result.className).toContain("opacity-50"); + expect(result.style).toEqual(undefined); + }); + }); + + describe("d.var()", () => { + it("should set CSS variable as inline style for number with unit (no className output)", () => { + const card = windctrl({ + dynamic: { + x: d.var("--x", { unit: "px" }), + }, + }); + + const result = card({ x: 12 }); + + // NOTE: CSS custom properties are stored as object keys. + expect(result.style).toEqual({ "--x": "12px" }); + expect(result.className).toBe(""); + }); + + it("should set CSS variable as inline style for string value (no className output)", () => { + const card = windctrl({ + dynamic: { + x: d.var("--x"), + }, + }); + + const result = card({ x: "10%" }); + expect(result.style).toEqual({ "--x": "10%" }); + expect(result.className).toBe(""); + }); + + it("should merge multiple CSS variables via last-one-wins when same variable is set twice", () => { + const card = windctrl({ + dynamic: { + x1: d.var("--x", { unit: "px" }), + x2: d.var("--x", { unit: "px" }), + }, + }); + + const result = card({ x1: 10, x2: 20 }); + + // last one wins + expect(result.style).toEqual({ "--x": "20px" }); + }); + }); + + it("should coexist with other dynamic resolvers (className + style merge)", () => { + const box = windctrl({ + dynamic: { + w: d.px("width"), + opacity: d.opacity(), + // keep an existing custom resolver in the same config + custom: (v) => (v ? "ring-2" : ""), + }, + }); + + const result = box({ w: 100, opacity: 0.5, custom: true }); + + expect(result.style).toEqual({ width: "100px", opacity: 0.5 }); + expect(result.className).toContain("ring-2"); + }); + }); + describe("Scopes", () => { it("should apply scope classes with group-data selector", () => { const button = windctrl({ From ab60d20e6bf52b00d6d8800814ef7f86ac6fa114 Mon Sep 17 00:00:00 2001 From: Masaki Morishita Date: Tue, 23 Dec 2025 11:01:47 +0900 Subject: [PATCH 2/7] feat(dynamic): introduce preset functions --- examples/Button.tsx | 8 ++---- src/index.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/examples/Button.tsx b/examples/Button.tsx index df1fdfa..ae6d802 100644 --- a/examples/Button.tsx +++ b/examples/Button.tsx @@ -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({ @@ -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", diff --git a/src/index.ts b/src/index.ts index 1355be3..e2ad33f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,72 @@ type DynamicResolverResult = | string | { className?: string; style?: CSSProperties }; -type DynamicResolver = (value: any) => DynamicResolverResult; +type DynamicResolver = (value: T) => DynamicResolverResult; + +type PxProp = + | "width" + | "height" + | "minWidth" + | "maxWidth" + | "minHeight" + | "maxHeight" + | "top" + | "right" + | "bottom" + | "left"; + +type NumProp = "zIndex" | "flexGrow" | "flexShrink" | "order"; + +type VarUnit = "px" | "%" | "deg" | "ms"; + +function px(prop: PxProp): DynamicResolver { + return (value: number | string): DynamicResolverResult => { + if (typeof value === "number") { + return { style: { [prop]: `${value}px` } }; + } + return value; + }; +} + +function num(prop: NumProp): DynamicResolver { + return (value: number | string): DynamicResolverResult => { + if (typeof value === "number") { + return { style: { [prop]: value } }; + } + return value; + }; +} + +function opacity(): DynamicResolver { + return (value: number | string): DynamicResolverResult => { + if (typeof value === "number") { + return { style: { opacity: value } }; + } + return value; + }; +} + +function cssVar( + name: `--${string}`, + options?: { unit?: VarUnit }, +): DynamicResolver { + return (value: number | string): DynamicResolverResult => { + if (typeof value === "number") { + if (options?.unit) { + return { style: { [name]: `${value}${options.unit}` } }; + } + return { style: { [name]: String(value) } }; + } + return { style: { [name]: value } }; + }; +} + +export const dynamic = { + px, + num, + opacity, + var: cssVar, +}; type Config< TVariants extends Record> = {}, From 69b88f471b87d34bd5c4aaae209d2d85fbb2eda9 Mon Sep 17 00:00:00 2001 From: Masaki Morishita Date: Tue, 23 Dec 2025 11:02:43 +0900 Subject: [PATCH 3/7] docs: dynamic presets --- README.md | 45 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 31541bb..5c3487a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,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", @@ -44,8 +44,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", @@ -80,12 +79,46 @@ Interpolated variants provide a **Unified API** that bridges static Tailwind cla 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({ dynamic: { - // Recommended pattern: - // - Numbers -> inline styles (unbounded values) - // - Strings -> Tailwind utilities (must be statically enumerable for JIT) + // Custom resolver example w: (val) => typeof val === "number" ? { style: { width: `${val}px` } } : val, }, From b324c8a707b91498963c1816f071689adeb2a572 Mon Sep 17 00:00:00 2001 From: Masaki Morishita Date: Sun, 21 Dec 2025 13:04:27 +0900 Subject: [PATCH 4/7] feat,test: add tests for slots --- src/index.test.ts | 140 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index f117e5e..7e7a825 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -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); + }); + }); }); From 61657b248f5e090e6cb00f55aad13ae8add17882 Mon Sep 17 00:00:00 2001 From: Masaki Morishita Date: Sun, 21 Dec 2025 13:41:06 +0900 Subject: [PATCH 5/7] feat: introduce `slots` --- src/index.ts | 128 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 106 insertions(+), 22 deletions(-) diff --git a/src/index.ts b/src/index.ts index e2ad33f..a5f1850 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,13 +74,17 @@ export const dynamic = { var: cssVar, }; +type SlotAwareValue = + | ClassValue + | { root?: ClassValue; slots?: Record }; + type Config< - TVariants extends Record> = {}, - TTraits extends Record = {}, + TVariants extends Record> = {}, + TTraits extends Record = {}, TDynamic extends Record = {}, TScopes extends Record = {}, > = { - base?: ClassValue; + base?: SlotAwareValue; variants?: TVariants; traits?: TTraits; dynamic?: TDynamic; @@ -93,8 +97,8 @@ type Config< }; type Props< - TVariants extends Record> = {}, - TTraits extends Record = {}, + TVariants extends Record> = {}, + TTraits extends Record = {}, TDynamic extends Record = {}, > = { [K in keyof TVariants]?: keyof TVariants[K] extends string @@ -113,31 +117,75 @@ type Props< type Result = { className: string; style?: CSSProperties; + slots?: Record; }; function mergeStyles(...styles: (CSSProperties | undefined)[]): CSSProperties { return Object.assign({}, ...styles.filter(Boolean)); } -function processTraits>( +function isSlotAwareValue( + value: unknown, +): value is { root?: ClassValue; slots?: Record } { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const obj = value as Record; + const hasRoot = "root" in obj; + const hasSlots = + "slots" in obj && typeof obj.slots === "object" && obj.slots !== null; + return hasRoot || hasSlots; +} + +function addSlotClasses( + slotParts: Record, + slots: Record, +): void { + for (const [slotName, slotClasses] of Object.entries(slots)) { + if (!slotParts[slotName]) { + slotParts[slotName] = []; + } + slotParts[slotName].push(slotClasses); + } +} + +function processTraits>( traits: TTraits, propsTraits?: Props<{}, TTraits>["traits"], + slotParts?: Record, ): ClassValue[] { if (!propsTraits) return []; - if (Array.isArray(propsTraits)) { - return propsTraits - .filter((key) => key in traits) - .map((key) => traits[key as keyof TTraits]); - } + const rootClasses: ClassValue[] = []; + + const processTraitKey = (key: string) => { + if (!(key in traits)) return; + const traitValue = traits[key as keyof TTraits]; + if (isSlotAwareValue(traitValue)) { + if (traitValue.root) { + rootClasses.push(traitValue.root); + } + if (traitValue.slots && slotParts) { + addSlotClasses(slotParts, traitValue.slots); + } + } else { + rootClasses.push(traitValue); + } + }; - if (typeof propsTraits === "object") { - return Object.entries(propsTraits) - .filter(([key, value]) => value && key in traits) - .map(([key]) => traits[key as keyof TTraits]); + if (Array.isArray(propsTraits)) { + for (const key of propsTraits) { + processTraitKey(key); + } + } else if (typeof propsTraits === "object") { + for (const [key, value] of Object.entries(propsTraits)) { + if (value) { + processTraitKey(key); + } + } } - return []; + return rootClasses; } function processDynamicEntries( @@ -189,8 +237,8 @@ function processScopes>( } export function windctrl< - TVariants extends Record> = {}, - TTraits extends Record = {}, + TVariants extends Record> = {}, + TTraits extends Record = {}, TDynamic extends Record = {}, TScopes extends Record = {}, >( @@ -207,7 +255,7 @@ export function windctrl< const resolvedVariants = Object.entries(variants) as [ string, - Record, + Record, ][]; const resolvedDynamicEntries = Object.entries(dynamic) as [ string, @@ -218,13 +266,23 @@ export function windctrl< return (props = {} as Props) => { const classNameParts: ClassValue[] = []; let mergedStyle: CSSProperties = {}; + const slotParts: Record = {}; // Priority order: Base < Variants < Traits < Dynamic // (Higher priority classes are added later, so tailwind-merge will keep them) // 1. Base classes (lowest priority) if (base) { - classNameParts.push(base); + if (isSlotAwareValue(base)) { + if (base.root) { + classNameParts.push(base.root); + } + if (base.slots) { + addSlotClasses(slotParts, base.slots); + } + } else { + classNameParts.push(base); + } } // 2. Variants (with defaultVariants fallback) @@ -233,13 +291,23 @@ export function windctrl< props[variantKey as keyof typeof props] ?? defaultVariants[variantKey as keyof typeof defaultVariants]; if (propValue && variantOptions[propValue as string]) { - classNameParts.push(variantOptions[propValue as string]); + const optionValue = variantOptions[propValue as string]; + if (isSlotAwareValue(optionValue)) { + if (optionValue.root) { + classNameParts.push(optionValue.root); + } + if (optionValue.slots) { + addSlotClasses(slotParts, optionValue.slots); + } + } else { + classNameParts.push(optionValue); + } } } // 3. Traits (higher priority than variants) if (props.traits) { - classNameParts.push(...processTraits(traits, props.traits)); + classNameParts.push(...processTraits(traits, props.traits, slotParts)); } // 4. Dynamic (highest priority for className) @@ -261,9 +329,25 @@ export function windctrl< const hasStyle = Object.keys(mergedStyle).length > 0; + let finalSlots: Record | undefined; + const slotNames = Object.keys(slotParts); + if (slotNames.length > 0) { + finalSlots = {}; + for (const slotName of slotNames) { + const merged = twMerge(clsx(slotParts[slotName])); + if (merged) { + finalSlots[slotName] = merged; + } + } + if (Object.keys(finalSlots).length === 0) { + finalSlots = undefined; + } + } + return { className: finalClassName, ...(hasStyle && { style: mergedStyle }), + ...(finalSlots && { slots: finalSlots }), }; }; } From 8f33dc27bb8846ccd6038b6175786d7e8fd535b7 Mon Sep 17 00:00:00 2001 From: Masaki Morishita Date: Sun, 21 Dec 2025 16:18:55 +0900 Subject: [PATCH 6/7] add types for `slots` DX --- src/index.ts | 44 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index a5f1850..04bce82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,9 +74,11 @@ export const dynamic = { var: cssVar, }; -type SlotAwareValue = - | ClassValue - | { root?: ClassValue; slots?: Record }; +type SlotAwareObject = { + root?: ClassValue; + slots?: Record; +}; +type SlotAwareValue = ClassValue | SlotAwareObject; type Config< TVariants extends Record> = {}, @@ -114,19 +116,39 @@ type Props< [K in keyof TDynamic]?: Parameters[0]; }; -type Result = { +type SlotsOfValue = V extends { slots?: infer S } + ? S extends Record + ? keyof S + : never + : never; + +type VariantOptionValues = + T extends Record> ? V : never; + +type TraitValues = T extends Record ? V : never; + +type SlotKeys< + TBase, + TVariants extends Record>, + TTraits extends Record, +> = Extract< + | SlotsOfValue + | SlotsOfValue> + | SlotsOfValue>, + string +>; + +type Result = { className: string; style?: CSSProperties; - slots?: Record; + slots?: Partial>; }; function mergeStyles(...styles: (CSSProperties | undefined)[]): CSSProperties { return Object.assign({}, ...styles.filter(Boolean)); } -function isSlotAwareValue( - value: unknown, -): value is { root?: ClassValue; slots?: Record } { +function isSlotAwareValue(value: unknown): value is SlotAwareObject { if (typeof value !== "object" || value === null || Array.isArray(value)) { return false; } @@ -243,7 +265,9 @@ export function windctrl< TScopes extends Record = {}, >( config: Config, -): (props?: Props) => Result { +): ( + props?: Props, +) => Result> { const { base, variants = {} as TVariants, @@ -348,7 +372,7 @@ export function windctrl< className: finalClassName, ...(hasStyle && { style: mergedStyle }), ...(finalSlots && { slots: finalSlots }), - }; + } as Result>; }; } From a5c095418589de3406daf8d008c09b12faf2cc8b Mon Sep 17 00:00:00 2001 From: Masaki Morishita Date: Tue, 23 Dec 2025 11:47:31 +0900 Subject: [PATCH 7/7] docs: slots --- README.md | 162 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 107 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 5c3487a..f7b5697 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 + +``` + +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: @@ -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. @@ -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.