- 
          
 - 
                Notifications
    
You must be signed in to change notification settings  - Fork 7.1k
 
Description
Feature description
The idea is to rewrite component that has extremely long className string
<Comp
  className={cn(
    "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    className
  )}
/>to a multi line with + for concat, with a minimal structure for the styles applied
<Comp
  className={cn(
     // base
    "w-fit px-3 py-2 flex items-center justify-between gap-2"
    + " rounded-md border border-input bg-transparent shadow-xs"
    + " text-sm whitespace-nowrap"
    + " transition-[colorbox-shadow]"
    + " dark:bg-input/30 dark:hover:bg-input/50"
    // state
    + " data-[placeholder]:text-muted-foreground"
    + " data-[size=default]:h-9 data-[size=sm]:h-8"
    + " aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
    + " disabled:cursor-not-allowed disabled:opacity-50"
    + " outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
    // children
    + " [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
    + " *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
    className
  )}
/>Affected component/components
all
Additional Context
Problem
This is the SelectTrigger of select component
function SelectTrigger({
  className,
  size = "default",
  children,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
  size?: "sm" | "default"
}) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      data-size={size}
      className={cn(
        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <SelectPrimitive.Icon asChild>
        <ChevronDownIcon className="size-4 opacity-50" />
      </SelectPrimitive.Icon>
    </SelectPrimitive.Trigger>
  )
}the className doesn't convey any structure and it's extremely long, making hard to edit the component once it's imported.
Idea
After several test, when I need to customize a component, i usually rewrite the className string like this
The order is not extremely rigid, it's just enough to have base -> overrides -> children.
function SelectTrigger({
  className,
  size = "default",
  children,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
  size?: "sm" | "default";
}) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      data-size={size}
      className={cn(
         // base
        "w-fit px-3 py-2 flex items-center justify-between gap-2"
        + " rounded-md border border-input bg-transparent shadow-xs"
        + " text-sm whitespace-nowrap"
        + " transition-[colorbox-shadow]"
        + " dark:bg-input/30 dark:hover:bg-input/50"
        // state
        + " data-[placeholder]:text-muted-foreground"
        + " data-[size=default]:h-9 data-[size=sm]:h-8"
        + " aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
        + " disabled:cursor-not-allowed disabled:opacity-50"
        + " outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
        // children
        + " [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
        + " *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
        className
      )}
      {...props}
    >
      {children}
      <SelectPrimitive.Icon asChild>
        <ChevronDownIcon className="size-4 opacity-50" />
      </SelectPrimitive.Icon>
    </SelectPrimitive.Trigger>
  );
}Performance
I landed on concat string with +, because it's faster compared to other two methods with similar DX.
plain (cn process everything as separate string)
cn(
  "w-fit px-3 py-2 flex items-center justify-between gap-2",
  "disabled:cursor-not-allowed disabled:opacity-50",
  "opacity-50",
   // ...
);Array.join (cn see only one string)
cn(
  [
  "w-fit px-3 py-2 flex items-center justify-between gap-2",
  "disabled:cursor-not-allowed disabled:opacity-50",
  "opacity-50",
   // ...
  ].join(" ")
);+ (cn see only one string)
cn(
  "w-fit px-3 py-2 flex items-center justify-between gap-2"
  + " disabled:cursor-not-allowed disabled:opacity-50"
  + " opacity-50"
   // ...
);Perf
The time difference is not night-n-day different, but considered that we usually customize the base components not so often, it's better the degrade the DX a little and save compute time in the app re-renders.
| Iterations | plain | Array.join | + | 
|---|---|---|---|
| 1_000 | 14ms | 3ms | 2ms | 
| 10_000 | 43ms | 22ms | 3ms | 
| 100_000 | 295ms | 215ms | 12ms | 
NOTE: The time is not the time taken for a single cn() run, but the sum of multiple identical cn() calls in sequence (for loop).
See test
import { describe, it } from "vitest";
import { cn } from "./cn";
import { repeatSyncFn } from "../utils/vitest.utils";
const ITERATIONS = 100_000;
describe(`shadcn - cn - multi tring patterns - ITERATIONS:${ITERATIONS}`, () => {
  it("plain", () => {
    repeatSyncFn(ITERATIONS, () => {
      const finalClasses = cn(
        "w-fit px-3 py-2 flex items-center justify-between gap-2",
        "rounded-md border border-input bg-transparent shadow-xs",
        "text-sm whitespace-nowrap",
        "data-[placeholder]:text-muted-foreground",
        "[&_svg:not([class*='text-'])]:text-muted-foreground",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
        "dark:bg-input/30 dark:hover:bg-input/50",
        "transition-[colorbox-shadow] outline-none",
        "",
        "disabled:cursor-not-allowed disabled:opacity-50",
        "data-[size=default]:h-9 data-[size=sm]:h-8",
        "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
      );
    });
  });
  it("concat with +", () => {
    repeatSyncFn(ITERATIONS, () => {
      const finalClasses = cn(
        "w-fit px-3 py-2 flex items-center justify-between gap-2"
        + " rounded-md border border-input bg-transparent shadow-xs"
        + " text-sm whitespace-nowrap"
        + " data-[placeholder]:text-muted-foreground"
        + " [&_svg:not([class*='text-'])]:text-muted-foreground"
        + " [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
        + " focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
        + " aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
        + " dark:bg-input/30 dark:hover:bg-input/50"
        + " transition-[colorbox-shadow] outline-none"
        + " "
        + " disabled:cursor-not-allowed disabled:opacity-50"
        + " data-[size=default]:h-9 data-[size=sm]:h-8"
        + " *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
      );
    });
  });
  it('concat with Array.join', () => {
    repeatSyncFn(ITERATIONS, () => {
      const finalClasses = cn(
        [
          "w-fit px-3 py-2 flex items-center justify-between gap-2",
          "rounded-md border border-input bg-transparent shadow-xs",
          "text-sm whitespace-nowrap",
          "data-[placeholder]:text-muted-foreground",
          "[&_svg:not([class*='text-'])]:text-muted-foreground",
          "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
          "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
          "dark:bg-input/30 dark:hover:bg-input/50",
          "transition-[colorbox-shadow] outline-none",
          "",
          "disabled:cursor-not-allowed disabled:opacity-50",
          "data-[size=default]:h-9 data-[size=sm]:h-8",
          "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
        ].join(" ")
      );
    });
  });
});Before submitting
- I've made research efforts and searched the documentation
 - I've searched for existing issues and PRs