Like 0

This is a react utility to concatenate class names, including the ability to support conditional class names

Revision e030d760f7884c477958137b1003268f6c9ba142

cn.mdx Raw
1import { Meta } from '@storybook/addon-docs/blocks';
2
3<Meta title="Utils/cn (ClassNames)" />
4
5# cn (ClassNames) Utility Function
6
7The `cn` utility function is a lightweight, performant solution for combining class names with conditional logic.
8
9## Installation
10
11```typescript
12import { cn } from 'motoko/utils'
13```
14
15## API
16
17```typescript
18type Argument = string | boolean | null | undefined | number
19
20cn(...args: Argument[]): string
21```
22
23## Examples
24
25### Basic Usage
26```typescript
27// Basic conditional classes
28cn('btn', true && 'active', false && 'disabled', 'large')
29// Result: "btn active large"
30
31// Falsy value filtering
32cn('btn', null, undefined, false, 'primary')
33// Result: "btn primary"
34```
35
36## Performance Notes
37
38- Uses traditional for loops instead of for-of for ~20-40% better performance
39- Single-pass algorithm with minimal string operations
40- Optimized for common React component patterns
41
42## Common Patterns
43
44### Conditional Classes
45```typescript
46const className = cn(
47 'base-class',
48 isActive && 'active',
49 isDisabled && 'disabled',
50 variant && `variant-${variant}`
51)
52```
53
54### CSS Modules
55```typescript
56import styles from './Component.module.scss'
57
58const className = cn(
59 styles.base,
60 isError && styles.error,
61 props.className
62)
63```
64
65### Component Props
66```typescript
67interface ButtonProps {
68 variant: 'primary' | 'secondary'
69 disabled?: boolean
70 className?: string
71}
72
73const Button = ({ variant, disabled, className, ...props }: ButtonProps) => (
74 <button
75 className={cn('btn', `btn--${variant}`, disabled && 'btn--disabled', className)}
76 {...props}
77 />
78)
79```
80
81## Migration from clsx/classnames
82
83Direct replacement for most use cases:
84
85```typescript
86// Before (clsx)
87import clsx from 'clsx'
88const className = clsx('btn', isActive && 'active')
89
90// After (cn)
91import { cn } from 'motoko/utils'
92const className = cn('btn', isActive && 'active')
93```
94
95## Best Practices
96
971. **Order matters**: Place base classes first, then conditional classes
982. **Use with CSS Modules**: Perfect for scoped styling
993. **Component composition**: Always accept `className` prop for extensibility
1004. **Performance**: Prefer `cn` over template literals for conditional classes
101
cn.test.ts Raw
1/** biome-ignore-all lint/correctness/noUnusedFunctionParameters: Required for Testing */
2/** biome-ignore-all lint/complexity/useSimplifiedLogicExpression: Required for Testing */
3import { describe, expect, it } from "vitest"
4import { cn } from "./"
5
6describe("bcn utility", () => {
7 describe("basic functionality", () => {
8 it("joins string arguments with spaces", () => {
9 expect(cn("button", "primary", "large")).toBe("button primary large")
10 })
11
12 it("handles single string argument", () => {
13 expect(cn("button")).toBe("button")
14 })
15
16 it("returns empty string when no arguments", () => {
17 expect(cn()).toBe("")
18 })
19
20 it("handles empty string arguments", () => {
21 expect(cn("", "button", "")).toBe("button")
22 })
23 })
24
25 describe("falsy value filtering", () => {
26 it("filters out false values", () => {
27 expect(cn("button", false, "active")).toBe("button active")
28 })
29
30 it("filters out null values", () => {
31 expect(cn("button", null, "active")).toBe("button active")
32 })
33
34 it("filters out undefined values", () => {
35 expect(cn("button", undefined, "active")).toBe("button active")
36 })
37
38 it("filters out all falsy values", () => {
39 expect(cn("button", false, null, undefined, "active")).toBe("button active")
40 })
41
42 it("returns empty string when all arguments are falsy", () => {
43 expect(cn(false, null, undefined)).toBe("")
44 })
45 })
46
47 describe("conditional expressions", () => {
48 it("handles boolean && string expressions (truthy)", () => {
49 const isActive = true
50 expect(cn("button", isActive && "active")).toBe("button active")
51 })
52
53 it("handles boolean && string expressions (falsy)", () => {
54 const isActive = false
55 expect(cn("button", isActive && "active")).toBe("button")
56 })
57
58 it("handles complex conditional logic", () => {
59 const isDisabled = false
60 const isPrimary = true
61 const size = "large"
62
63 expect(
64 cn(
65 "button",
66 isPrimary && "button-primary",
67 isDisabled && "button-disabled",
68 size === "large" && "button-large",
69 ),
70 ).toBe("button button-primary button-large")
71 })
72 })
73
74 describe("space-separated string flattening", () => {
75 it("flattens single space-separated string", () => {
76 expect(cn("button primary large")).toBe("button primary large")
77 })
78
79 it("flattens multiple space-separated strings", () => {
80 expect(cn("button primary", "large disabled")).toBe("button primary large disabled")
81 })
82
83 it("handles mixed single and space-separated strings", () => {
84 expect(cn("button", "primary large", "disabled")).toBe("button primary large disabled")
85 })
86
87 it("handles multiple spaces within strings", () => {
88 expect(cn("button primary", "large disabled")).toBe("button primary large disabled")
89 })
90
91 it("handles leading and trailing spaces", () => {
92 expect(cn(" button primary ", " large disabled ")).toBe("button primary large disabled")
93 })
94
95 it("handles tabs and other whitespace", () => {
96 expect(cn("button\tprimary", "large\ndisabled")).toBe("button primary large disabled")
97 })
98 })
99
100 describe("real-world usage patterns", () => {
101 it("works with React component className patterns", () => {
102 const isActive = true
103 const isDisabled = false
104 const variant = "primary"
105 const size = "large"
106
107 expect(
108 cn(
109 "button",
110 `button--${variant}`,
111 `button--${size}`,
112 isActive && "button--active",
113 isDisabled && "button--disabled",
114 ),
115 ).toBe("button button--primary button--large button--active")
116 })
117
118 it("works with CSS modules pattern", () => {
119 const styles = {
120 button: "button_abc123",
121 primary: "primary_def456",
122 active: "active_ghi789",
123 }
124 const isActive = true
125
126 expect(cn(styles.button, styles.primary, isActive && styles.active)).toBe(
127 "button_abc123 primary_def456 active_ghi789",
128 )
129 })
130
131 it("handles utility class combinations", () => {
132 expect(cn("flex items-center", "px-4 py-2", "bg-blue-500 text-white")).toBe(
133 "flex items-center px-4 py-2 bg-blue-500 text-white",
134 )
135 })
136 })
137
138 describe("edge cases", () => {
139 it("handles strings with only whitespace", () => {
140 expect(cn(" ", "button", " ")).toBe("button")
141 })
142
143 it("handles very long class lists", () => {
144 const longClassList = Array(100).fill("class").join(" ")
145 expect(cn(longClassList)).toBe(Array(100).fill("class").join(" "))
146 })
147
148 it("handles special characters in class names", () => {
149 expect(cn("button-primary_large", "icon:before")).toBe("button-primary_large icon:before")
150 })
151
152 it("handles numbers in class names", () => {
153 expect(cn("col-12", "mt-4", "z-10")).toBe("col-12 mt-4 z-10")
154 })
155
156 it("handles unicode characters", () => {
157 expect(cn("button-🚀", "icon-✨")).toBe("button-🚀 icon-✨")
158 })
159 })
160
161 describe("performance considerations", () => {
162 it("handles large number of arguments efficiently", () => {
163 const manyArgs = Array(1000)
164 .fill(null)
165 .map((_, i) => (i % 2 === 0 ? `class-${i}` : false))
166 const result = cn(...manyArgs)
167
168 // Should contain only even-indexed classes
169 expect(result).toContain("class-0")
170 expect(result).toContain("class-998")
171 expect(result).not.toContain("undefined")
172 })
173
174 it("handles empty arrays and complex nested conditionals", () => {
175 const conditions = [
176 true && "a", // "a" (truthy)
177 false && "b", // false (falsy)
178 null, // null (falsy)
179 undefined, // undefined (falsy)
180 "" as string, // "" (falsy - empty string)
181 0 as number, // 0 (falsy - zero)
182 "valid-class", // "valid-class" (truthy)
183 ]
184 expect(cn(...conditions)).toBe("a valid-class")
185 })
186 })
187
188 describe("type safety", () => {
189 it("maintains type safety with proper arguments", () => {
190 // These should not cause TypeScript errors
191 const validUsages = [
192 cn("string"),
193 cn(true && "conditional"),
194 cn(false && "wont-show"),
195 cn(null),
196 cn(undefined),
197 cn("multiple", "classes", true && "conditional"),
198 ]
199
200 validUsages.forEach(usage => {
201 expect(typeof usage).toBe("string")
202 })
203 })
204 })
205
206 describe("documentation examples", () => {
207 it("matches first documentation example", () => {
208 const isActive = true
209 expect(cn("button", isActive && "active", "large")).toBe("button active large")
210 })
211
212 it("matches second documentation example", () => {
213 expect(cn("button primary", false, null, "disabled")).toBe("button primary disabled")
214 })
215 })
216})
217
index.ts Raw
1type Argument = string | boolean | null | undefined | number
2
3/**
4 *
5 * Combines multiple class name arguments, automatically filtering out falsy values
6 * (false, null, undefined) and normalizing any extra whitespace within or between
7 * class names. Supports conditionally joining class names into a single string.
8 *
9 * @param args - Class name strings or conditional expressions that resolve to strings/booleans
10 * @returns A single normalized class name string with no extra whitespace
11 *
12 * @example
13 * Basic usage with conditional classes:
14 * ```typescript
15 * cn('btn', isActive && 'active', 'large')
16 * // Returns: "btn active large" (when isActive is true)
17 * // Returns: "btn large" (when isActive is false)
18 * ```
19 *
20 * @example
21 * Handling multiple spaces and falsy values:
22 * ```typescript
23 * cn('btn primary', false, null, 'disabled')
24 * // Returns: "btn primary disabled"
25 * ```
26 *
27 * @example
28 * Component usage pattern:
29 * ```typescript
30 * const Button = ({ variant, disabled, className }) => (
31 * <button className={cn('btn', `btn--${variant}`, disabled && 'btn--disabled', className)}>
32 * Click me
33 * </button>
34 * )
35 * ```
36 *
37 * @example
38 * CSS Modules usage:
39 * ```typescript
40 * import styles from './Component.module.scss'
41 *
42 * const className = cn(styles.base, isError && styles.error, props.className)
43 * ```
44 *
45 * @since 0.1.0
46 * @category Utilities
47 */
48export const cn = (...args: Argument[]): string => {
49 let result = ""
50
51 for (let i = 0; i < args.length; i++) {
52 const arg = args[i]
53 if (arg) {
54 if (result) result += " "
55 result += arg
56 }
57 }
58
59 return result.split(/\s+/).filter(Boolean).join(" ")
60}
61