← back

Frivolous Concatenation

3 min read ·

I see this pattern in a lot of Tailwind code.

tsx
className={cn(
"flex items-center gap-1.5",
"px-4 py-2.5",
"typography-body-sm text-neu-700 dark:text-neu-600 whitespace-pre",
)}
tsx
className={cn(
"flex items-center gap-1.5",
"px-4 py-2.5",
"typography-body-sm text-neu-700 dark:text-neu-600 whitespace-pre",
)}

Please don’t do this.

I get it. Human minds yearn for order. The Soulless imitate our weakness.

Just write it together. One string.

tsx
className="typography-body-sm flex items-center gap-1.5 whitespace-pre px-4 py-2.5 text-neu-700 dark:text-neu-600"
tsx
className="typography-body-sm flex items-center gap-1.5 whitespace-pre px-4 py-2.5 text-neu-700 dark:text-neu-600"

I understand the motivation to split it, especially in more complex production scenarios, like in shadcn:

shadcn-ui/ui/apps/v4/registry/new-york-v4/ui/sidebar.tsx#L293-L300
tsx
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
shadcn-ui/ui/apps/v4/registry/new-york-v4/ui/sidebar.tsx#L293-L300
tsx
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}

There is some readability gain, but it’s not worth the things we lose. I’m not even talking about the performance cost. Even if we use the most basic cn function like xs.filter(Boolean).join(' '), no tailwind-merge. We lose predictability and greppability.

Predictability: The Prettier plugin for Tailwind CSS can order classes in long strings, so we always get the same order. Just trust the formatter. Predictable is nice.

Greppability: If there’s no concatenation, even for mapping from props to style, we can copy a class from the browser and immediately jump to the code.

I always rebind project search, so it’s just:

ctrlcalttabctrlectrlv

And I’m right where I wanna be.

But what if…

But what if the concatenation isn’t needless? If we actually have some state or props, such as variant or size, that we’re mapping to styles.

shadcn-ui/ui/apps/v4/registry/new-york-v4/ui/sidebar.tsx#L231-L241
tsx
className={cn(
"w-(--sidebar-width) fixed inset-y-0 z-10 hidden h-svh transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
shadcn-ui/ui/apps/v4/registry/new-york-v4/ui/sidebar.tsx#L231-L241
tsx
className={cn(
"w-(--sidebar-width) fixed inset-y-0 z-10 hidden h-svh transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}

I’m not a radical here. I like using aria- attributes for state and I’ve used data- attributes for props like variant.

graphql/graphql.github.io/src/app/conf/_design-system/button.tsx#L61
tsx
"relative flex items-center justify-center gap-2.5 font-normal text-base/none text-neu-0 bg-neu-900 hover:bg-neu-800 active:bg-neu-700 font-sans h-14 px-8 data-[size=md]:h-12 data-[variant=secondary]:bg-neu-100 dark:data-[variant=secondary]:bg-neu-100/80 dark:data-[variant=secondary]:hover:bg-neu-200/50 data-[variant=secondary]:text-neu-900 data-[variant=secondary]:hover:bg-neu-200/75 data-[variant=secondary]:active:bg-neu-200/90 data-[variant=tertiary]:bg-neu-100 data-[variant=tertiary]:text-neu-900 data-[variant=tertiary]:hover:bg-neu-200 data-[variant=tertiary]:active:bg-neu-300 gql-focus-visible [aria-disabled]:bg-neu-800 aria-disabled:pointer-events-none dark:data-[variant=tertiary]:bg-neu-900/10 dark:data-[variant=tertiary]:text-neu-900 dark:data-[variant=tertiary]:hover:bg-neu-900/[.125] dark:data-[variant=tertiary]:active:bg-neu-800/20 dark:data-[variant=tertiary]:ring-1 dark:data-[variant=tertiary]:ring-inset dark:data-[variant=tertiary]:ring-neu-900/20",
graphql/graphql.github.io/src/app/conf/_design-system/button.tsx#L61
tsx
"relative flex items-center justify-center gap-2.5 font-normal text-base/none text-neu-0 bg-neu-900 hover:bg-neu-800 active:bg-neu-700 font-sans h-14 px-8 data-[size=md]:h-12 data-[variant=secondary]:bg-neu-100 dark:data-[variant=secondary]:bg-neu-100/80 dark:data-[variant=secondary]:hover:bg-neu-200/50 data-[variant=secondary]:text-neu-900 data-[variant=secondary]:hover:bg-neu-200/75 data-[variant=secondary]:active:bg-neu-200/90 data-[variant=tertiary]:bg-neu-100 data-[variant=tertiary]:text-neu-900 data-[variant=tertiary]:hover:bg-neu-200 data-[variant=tertiary]:active:bg-neu-300 gql-focus-visible [aria-disabled]:bg-neu-800 aria-disabled:pointer-events-none dark:data-[variant=tertiary]:bg-neu-900/10 dark:data-[variant=tertiary]:text-neu-900 dark:data-[variant=tertiary]:hover:bg-neu-900/[.125] dark:data-[variant=tertiary]:active:bg-neu-800/20 dark:data-[variant=tertiary]:ring-1 dark:data-[variant=tertiary]:ring-inset dark:data-[variant=tertiary]:ring-neu-900/20",

In the code above, I obvi started with two variants, and only later needed a tertiary. I can acknowledge that this is getting to the point where I should use cva. We don’t really need the greppability on the class either, because looking at the component, you can guess it’s called Button.

Still, it’s fine. It’s not even close to my ugliest Tailwind code. I’m not gonna refactor it until it grows to the point of being unbearable.

We still put too much focus on readability and not enough on perf, ease of finding, and ease of working with the code.

Instead, we can embrace ugly and leverage the tools we have to make it more bearable.

Augment yourself. We live in Cyberpunk, right?

Ask the clanker to parse it if you really can’t look at it. Set "editor.wordWrap": "on" or equivalent in your text editor, grab some extensions like Tailwind Fold or Tailwind CSS Highlight.

Don’t change the runtime just for something as subjective as readability or aesthetics.

Don’t go overboard and write everything in one huge string always: cn is a great function. class-variance-authority is amazing. We should respect them and not reach for them instantly and everywhere.

The AI reviewing this wants me to add a rule of thumb, something constructive to finish the post with.

Nah. Trust your gut. And git gud, man up or woman up, or both.

Also don’t panic, we’ve been frivolous and sloppy for a long time. Phil Pearl wrote a parallel post about needless calls of Golang fmt.Sprintf in 2019.
There’s a pattern.