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.
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);
+ });
+ });
});
diff --git a/src/index.ts b/src/index.ts
index e2ad33f..04bce82 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -74,13 +74,19 @@ export const dynamic = {
var: cssVar,
};
+type SlotAwareObject = {
+ root?: ClassValue;
+ slots?: Record;
+};
+type SlotAwareValue = ClassValue | SlotAwareObject;
+
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 +99,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
@@ -110,34 +116,98 @@ 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?: Partial>;
};
function mergeStyles(...styles: (CSSProperties | undefined)[]): CSSProperties {
return Object.assign({}, ...styles.filter(Boolean));
}
-function processTraits>(
+function isSlotAwareValue(value: unknown): value is SlotAwareObject {
+ 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,13 +259,15 @@ function processScopes>(
}
export function windctrl<
- TVariants extends Record> = {},
- TTraits extends Record = {},
+ TVariants extends Record> = {},
+ TTraits extends Record = {},
TDynamic extends Record = {},
TScopes extends Record = {},
>(
config: Config,
-): (props?: Props) => Result {
+): (
+ props?: Props,
+) => Result> {
const {
base,
variants = {} as TVariants,
@@ -207,7 +279,7 @@ export function windctrl<
const resolvedVariants = Object.entries(variants) as [
string,
- Record,
+ Record,
][];
const resolvedDynamicEntries = Object.entries(dynamic) as [
string,
@@ -218,13 +290,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 +315,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,10 +353,26 @@ 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 }),
+ } as Result>;
};
}