Skip to content

Commit ee83f93

Browse files
authored
refactor: split cn into cn and cnMerge functions (#286)
- Split cn function into two separate functions to avoid Proxy complexity - cn: simple function that returns string directly with default twMerge config - cnMerge: function that supports custom twMerge config via second call - Update core.js to use cnMerge for config-based calls - Update tests to reflect new API - Update README with utility functions documentation - Available from v3.2.2
1 parent 9c3a937 commit ee83f93

File tree

6 files changed

+219
-116
lines changed

6 files changed

+219
-116
lines changed

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,59 @@ const button = tv({
104104
return <button className={button({size: "sm", color: "secondary"})}>Click me</button>;
105105
```
106106
107+
## Utility Functions
108+
109+
Tailwind Variants provides several utility functions for combining and merging class names:
110+
111+
### `cx` - Simple Concatenation
112+
113+
Combines class names without merging conflicting classes (similar to `clsx`):
114+
115+
```js
116+
import {cx} from "tailwind-variants";
117+
118+
cx("text-xl", "font-bold"); // => "text-xl font-bold"
119+
cx("px-2", "px-4"); // => "px-2 px-4" (no merging)
120+
```
121+
122+
### `cn` - Merge with Default Config
123+
124+
> **Updated in v3.2.2** - Now returns a string directly (no function call needed)
125+
126+
Combines class names and merges conflicting Tailwind CSS classes using the default `tailwind-merge` config. Returns a string directly:
127+
128+
```js
129+
import {cn} from "tailwind-variants";
130+
131+
cn("bg-red-500", "bg-blue-500"); // => "bg-blue-500"
132+
cn("px-2", "px-4", "py-2"); // => "px-4 py-2"
133+
```
134+
135+
### `cnMerge` - Merge with Custom Config
136+
137+
> **Available from v3.2.2**
138+
139+
Combines class names and merges conflicting Tailwind CSS classes with support for custom `twMerge` configuration via a second function call:
140+
141+
```js
142+
import {cnMerge} from "tailwind-variants";
143+
144+
// Disable merging
145+
cnMerge("px-2", "px-4")({twMerge: false}) // => "px-2 px-4"
146+
147+
// Enable merging explicitly
148+
cnMerge("bg-red-500", "bg-blue-500")({twMerge: true}) // => "bg-blue-500"
149+
150+
// Use custom twMergeConfig
151+
cnMerge("px-2", "px-4")({twMergeConfig: {...}}) // => merged with custom config
152+
```
153+
154+
**When to use which:**
155+
156+
- Use `cx` when you want simple concatenation without any merging
157+
- Use `cn` for most cases when you want automatic conflict resolution with default settings
158+
- Use `cnMerge` when you need to customize the merge behavior (disable merging, custom config, etc.)
159+
107160
## Acknowledgements
108161

109162
- [**cva**](https://github.com/joe-bell/cva) ([Joe Bell](https://github.com/joe-bell))

src/__tests__/cn.test.ts

Lines changed: 103 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {expect, describe, test} from "@jest/globals";
22

3-
import {cn as cnWithMerge, cx as cxFull} from "../index";
3+
import {cn, cnMerge, cx as cxFull} from "../index";
44
import {cn as cnLite, cx as cxLite} from "../lite";
55
import {cx as cxUtils} from "../utils";
66

@@ -85,127 +85,183 @@ describe("cn function from lite (simple concatenation)", () => {
8585
});
8686

8787
describe("cn function with tailwind-merge (main index)", () => {
88-
test("should merge conflicting tailwind classes when twMerge is true", () => {
89-
const result = cnWithMerge("px-2", "px-4", "py-2")({twMerge: true});
88+
test("should merge conflicting tailwind classes by default", () => {
89+
const result = cn("px-2", "px-4", "py-2");
9090

9191
expect(result).toBe("px-4 py-2");
9292
});
9393

94-
test("should not merge classes when twMerge is false", () => {
95-
const result = cnWithMerge("px-2", "px-4", "py-2")({twMerge: false});
96-
97-
expect(result).toBe("px-2 px-4 py-2");
98-
});
99-
100-
test("should merge text color classes", () => {
101-
const result = cnWithMerge("text-red-500", "text-blue-500")({twMerge: true});
94+
test("should merge text color classes by default", () => {
95+
const result = cn("text-red-500", "text-blue-500");
10296

10397
expect(result).toBe("text-blue-500");
10498
});
10599

106-
test("should merge background color classes", () => {
107-
const result = cnWithMerge("bg-red-500", "bg-blue-500")({twMerge: true});
100+
test("should merge background color classes by default", () => {
101+
const result = cn("bg-red-500", "bg-blue-500");
108102

109103
expect(result).toBe("bg-blue-500");
110104
});
111105

112106
test("should merge multiple conflicting classes", () => {
113-
const result = cnWithMerge("px-2 py-1 text-sm", "px-4 py-2 text-lg")({twMerge: true});
107+
const result = cn("px-2 py-1 text-sm", "px-4 py-2 text-lg");
114108

115109
expect(result).toBe("px-4 py-2 text-lg");
116110
});
117111

118112
test("should handle non-conflicting classes", () => {
119-
const result = cnWithMerge("px-2", "py-2", "text-sm")({twMerge: true});
113+
const result = cn("px-2", "py-2", "text-sm");
120114

121115
expect(result).toBe("px-2 py-2 text-sm");
122116
});
123117

124118
test("should return undefined when no classes provided", () => {
125-
const result = cnWithMerge()({twMerge: true});
119+
const result = cn();
126120

127121
expect(result).toBeUndefined();
128122
});
129123

130124
test("should handle arrays with tailwind-merge", () => {
131-
const result = cnWithMerge(["px-2", "px-4"], "py-2")({twMerge: true});
125+
const result = cn(["px-2", "px-4"], "py-2");
132126

133127
expect(result).toBe("px-4 py-2");
134128
});
135129

136130
test("should handle objects with tailwind-merge", () => {
137-
const result = cnWithMerge({"px-2": true, "px-4": true, "py-2": true})({twMerge: true});
131+
const result = cn({"px-2": true, "px-4": true, "py-2": true});
138132

139133
expect(result).toBe("px-4 py-2");
140134
});
141135

142-
test("should merge when config is undefined (default behavior)", () => {
143-
const result = cnWithMerge("px-2", "px-4")({twMerge: true});
136+
test("should handle complex className with conditional object classes", () => {
137+
const selectedZoom: string = "a";
138+
const key: string = "b";
144139

145-
expect(result).toBe("px-4");
140+
const result = cn(
141+
"text-foreground ease-in-out-quad absolute left-1/2 top-1/2 origin-center -translate-x-1/2 -translate-y-1/2 scale-75 text-[21px] font-medium opacity-0 transition-[scale,opacity] duration-[300ms] ease-[cubic-bezier(0.33,1,0.68,1)] data-[selected=true]:scale-100 data-[selected=true]:opacity-100 data-[selected=true]:delay-200",
142+
{
143+
"sr-only": selectedZoom !== key,
144+
},
145+
);
146+
147+
expect(result).toContain("text-foreground");
148+
expect(result).toContain("sr-only");
149+
expect(typeof result).toBe("string");
146150
});
147151

148-
test("should merge classes by default when called directly without ()", () => {
149-
const result = cnWithMerge("px-2", "px-4", "py-2");
152+
test("should handle conditional object classes when condition is false", () => {
153+
const selectedZoom: string = "a";
154+
const key: string = "a";
150155

151-
// Should work as a string in template literals and string coercion
152-
expect(String(result)).toBe("px-4 py-2");
153-
expect(`${result}`).toBe("px-4 py-2");
156+
const result = cn("text-xl font-bold", {
157+
"sr-only": selectedZoom !== key,
158+
});
159+
160+
expect(result).toBe("text-xl font-bold");
161+
expect(result).not.toContain("sr-only");
154162
});
163+
});
155164

156-
test("should merge classes by default when no config is provided", () => {
157-
const result = cnWithMerge("px-2", "px-4", "py-2")();
165+
describe("cnMerge function with tailwind-merge config", () => {
166+
test("should merge conflicting tailwind classes when twMerge is true", () => {
167+
const result = cnMerge("px-2", "px-4", "py-2")({twMerge: true});
158168

159169
expect(result).toBe("px-4 py-2");
160170
});
161171

162-
test("should merge text color classes by default when called directly", () => {
163-
const result = cnWithMerge("text-red-500", "text-blue-500");
172+
test("should not merge classes when twMerge is false", () => {
173+
const result = cnMerge("px-2", "px-4", "py-2")({twMerge: false});
164174

165-
expect(String(result)).toBe("text-blue-500");
175+
expect(result).toBe("px-2 px-4 py-2");
166176
});
167177

168-
test("should merge text color classes by default when no config is provided", () => {
169-
const result = cnWithMerge("text-red-500", "text-blue-500")();
178+
test("should merge text color classes", () => {
179+
const result = cnMerge("text-red-500", "text-blue-500")({twMerge: true});
170180

171181
expect(result).toBe("text-blue-500");
172182
});
173183

174-
test("should merge background color classes by default when called directly", () => {
175-
const result = cnWithMerge("bg-red-500", "bg-blue-500");
184+
test("should merge background color classes", () => {
185+
const result = cnMerge("bg-red-500", "bg-blue-500")({twMerge: true});
176186

177-
expect(String(result)).toBe("bg-blue-500");
187+
expect(result).toBe("bg-blue-500");
178188
});
179189

180-
test("should merge background color classes by default when no config is provided", () => {
181-
const result = cnWithMerge("bg-red-500", "bg-blue-500")();
190+
test("should merge multiple conflicting classes", () => {
191+
const result = cnMerge("px-2 py-1 text-sm", "px-4 py-2 text-lg")({twMerge: true});
182192

183-
expect(result).toBe("bg-blue-500");
193+
expect(result).toBe("px-4 py-2 text-lg");
184194
});
185195

186-
test("should not merge classes when twMerge is explicitly false", () => {
187-
const result = cnWithMerge("px-2", "px-4", "py-2")({twMerge: false});
196+
test("should handle non-conflicting classes", () => {
197+
const result = cnMerge("px-2", "py-2", "text-sm")({twMerge: true});
188198

189-
expect(result).toBe("px-2 px-4 py-2");
199+
expect(result).toBe("px-2 py-2 text-sm");
190200
});
191201

192-
test("should merge classes when twMerge is explicitly true (backward compatibility)", () => {
193-
const result = cnWithMerge("px-2", "px-4", "py-2")({twMerge: true});
202+
test("should return undefined when no classes provided", () => {
203+
const result = cnMerge()({twMerge: true});
204+
205+
expect(result).toBeUndefined();
206+
});
207+
208+
test("should handle arrays with tailwind-merge", () => {
209+
const result = cnMerge(["px-2", "px-4"], "py-2")({twMerge: true});
194210

195211
expect(result).toBe("px-4 py-2");
196212
});
197213

198-
test("should merge classes when config is empty object (defaults to true)", () => {
199-
const result = cnWithMerge("px-2", "px-4", "py-2")({});
214+
test("should handle objects with tailwind-merge", () => {
215+
const result = cnMerge({"px-2": true, "px-4": true, "py-2": true})({twMerge: true});
216+
217+
expect(result).toBe("px-4 py-2");
218+
});
219+
220+
test("should merge classes by default when no config is provided", () => {
221+
const result = cnMerge("px-2", "px-4", "py-2")();
200222

201223
expect(result).toBe("px-4 py-2");
202224
});
203225

204226
test("should merge classes when config is undefined", () => {
205-
const result = cnWithMerge("px-2", "px-4", "py-2")(undefined);
227+
const result = cnMerge("px-2", "px-4", "py-2")(undefined);
228+
229+
expect(result).toBe("px-4 py-2");
230+
});
231+
232+
test("should merge classes when config is empty object (defaults to true)", () => {
233+
const result = cnMerge("px-2", "px-4", "py-2")({});
234+
235+
expect(result).toBe("px-4 py-2");
236+
});
237+
238+
test("should not merge classes when twMerge is explicitly false", () => {
239+
const result = cnMerge("px-2", "px-4", "py-2")({twMerge: false});
240+
241+
expect(result).toBe("px-2 px-4 py-2");
242+
});
243+
244+
test("should merge classes when twMerge is explicitly true", () => {
245+
const result = cnMerge("px-2", "px-4", "py-2")({twMerge: true});
206246

207247
expect(result).toBe("px-4 py-2");
208248
});
249+
250+
test("should handle complex className with conditional object classes", () => {
251+
const selectedZoom: string = "a";
252+
const key: string = "b";
253+
254+
const result = cnMerge(
255+
"text-foreground ease-in-out-quad absolute left-1/2 top-1/2 origin-center -translate-x-1/2 -translate-y-1/2 scale-75 text-[21px] font-medium opacity-0 transition-[scale,opacity] duration-[300ms] ease-[cubic-bezier(0.33,1,0.68,1)] data-[selected=true]:scale-100 data-[selected=true]:opacity-100 data-[selected=true]:delay-200",
256+
{
257+
"sr-only": selectedZoom !== key,
258+
},
259+
)();
260+
261+
expect(result).toContain("text-foreground");
262+
expect(result).toContain("sr-only");
263+
expect(typeof result).toBe("string");
264+
});
209265
});
210266

211267
describe.each(cxVariants)("cx function - $name", ({cx}) => {

src/__tests__/tv.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {expect, describe, test} from "@jest/globals";
22

3-
import {tv, cn} from "../index";
3+
import {tv, cnMerge} from "../index";
44

55
const COMMON_UNITS = ["small", "medium", "large"];
66

@@ -2475,10 +2475,10 @@ describe("Tailwind Variants (TV) - Extends", () => {
24752475
const tvResult = ["w-fit", "h-fit"];
24762476
const custom = ["w-full"];
24772477

2478-
const resultWithoutMerge = cn(tvResult.concat(custom))({twMerge: false});
2479-
const resultWithMerge = cn(tvResult.concat(custom))({twMerge: true});
2480-
const emptyResultWithoutMerge = cn([].concat([]))({twMerge: false});
2481-
const emptyResultWithMerge = cn([].concat([]))({twMerge: true});
2478+
const resultWithoutMerge = cnMerge(tvResult.concat(custom))({twMerge: false});
2479+
const resultWithMerge = cnMerge(tvResult.concat(custom))({twMerge: true});
2480+
const emptyResultWithoutMerge = cnMerge([].concat([]))({twMerge: false});
2481+
const emptyResultWithMerge = cnMerge([].concat([]))({twMerge: true});
24822482

24832483
expect(resultWithoutMerge).toBe("w-fit h-fit w-full");
24842484
expect(resultWithMerge).toBe("h-fit w-full");

src/index.d.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,34 +22,40 @@ export type * from "./types.d.ts";
2222
export declare const cx: <T extends CnOptions>(...classes: T) => CnReturn;
2323

2424
/**
25-
* Type representing a callable function that can also be coerced to a string.
26-
* This allows `cn` to work both as a function and directly in template literals.
25+
* Combines class names and merges conflicting Tailwind CSS classes using `tailwind-merge`.
26+
* Uses default twMerge config. For custom config, use `cnMerge` instead.
27+
* @param classes - Class names to combine (strings, arrays, objects, etc.)
28+
* @returns A merged class string, or `undefined` if no valid classes are provided
29+
* @example
30+
* ```ts
31+
* // Simple usage with default twMerge config
32+
* cn('bg-red-500', 'bg-blue-500') // => 'bg-blue-500'
33+
* cn('px-2', 'px-4', 'py-2') // => 'px-4 py-2'
34+
*
35+
* // For custom twMerge config, use cnMerge instead
36+
* cnMerge('px-2', 'px-4')({twMerge: false}) // => 'px-2 px-4'
37+
* ```
2738
*/
28-
type CnCallable = ((config?: TWMConfig) => CnReturn) & {
29-
toString(): string;
30-
valueOf(): CnReturn;
31-
[Symbol.toPrimitive](hint: "string" | "number" | "default"): string | CnReturn;
32-
} & string;
39+
export declare const cn: <T extends CnOptions>(...classes: T) => CnReturn;
3340

3441
/**
3542
* Combines class names and merges conflicting Tailwind CSS classes using `tailwind-merge`.
43+
* Supports custom twMerge config via the second function call.
3644
* @param classes - Class names to combine (strings, arrays, objects, etc.)
37-
* @returns A callable function that returns the merged class string. Works directly in template literals (coerces to string) or can be called with optional config.
45+
* @returns A function that accepts optional twMerge config and returns the merged class string
3846
* @example
3947
* ```ts
40-
* // twMerge defaults to true - works directly in template literals
41-
* `${cn('bg-red-500', 'bg-blue-500')}` // => 'bg-blue-500'
42-
* String(cn('bg-red-500', 'bg-blue-500')) // => 'bg-blue-500'
43-
*
44-
* // Can still be called with config for explicit control
45-
* cn('bg-red-500', 'bg-blue-500')() // => 'bg-blue-500'
48+
* // With custom config
49+
* cnMerge('bg-red-500', 'bg-blue-500')({twMerge: true}) // => 'bg-blue-500'
50+
* cnMerge('px-2', 'px-4')({twMerge: false}) // => 'px-2 px-4'
4651
*
47-
* // Note: If you need simple concatenation without merging, use `cx` instead:
48-
* // Instead of: cn('bg-red-500', 'bg-blue-500')({ twMerge: false })
49-
* // Use: cx('bg-red-500', 'bg-blue-500') // => 'bg-red-500 bg-blue-500'
52+
* // With twMergeConfig
53+
* cnMerge('px-2', 'px-4')({twMergeConfig: {...}}) // => merged with custom config
5054
* ```
5155
*/
52-
export declare const cn: <T extends CnOptions>(...classes: T) => CnCallable;
56+
export declare const cnMerge: <T extends CnOptions>(
57+
...classes: T
58+
) => (config?: TWMConfig) => CnReturn;
5359

5460
/**
5561
* Creates a variant-aware component function with Tailwind CSS classes.

src/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {getTailwindVariants} from "./core.js";
2-
import {cn} from "./tw-merge.js";
2+
import {cn, cnMerge} from "./tw-merge.js";
33
import {cx} from "./utils.js";
44
import {defaultConfig} from "./config.js";
55

6-
export const {createTV, tv} = getTailwindVariants(cn);
6+
export const {createTV, tv} = getTailwindVariants(cnMerge);
77

8-
export {cn, cx, defaultConfig};
8+
export {cn, cnMerge, cx, defaultConfig};

0 commit comments

Comments
 (0)