Skip to content

Class merging

Components that accept a class prop need to merge it with their own classes so the caller wins: p-4 passed in should replace the component’s p-2, not sit next to it. tailwind-merge does that for Tailwind’s built-in utilities. @static-bloom/tailwind/merge extends it with the Static Bloom utilities.

import { cn } from '@static-bloom/tailwind/merge'
cn('rounded-md bg-panel p-2', props.class)
cn('glow glow-ion', isAlert && 'glow-danger')
cn('bg-canvas', { 'bg-panel': raised }, [false, 'p-4'])

cn(...inputs) accepts strings, numbers, null, undefined, booleans, nested arrays and objects whose truthy keys are added. Falsy values are dropped, and it needs no clsx. When two classes set the same thing, the later one wins. Every result below was computed by the shipped cn when this page was built:

cn() calls and their merged class lists
CallResult
cn('glow glow-ion', 'glow-lg')'glow-ion glow-lg'
cn('shadow-md', 'glow-sm')'glow-sm'
cn('text-glow text-glow-plasma', 'text-glow-acid')'text-glow text-glow-acid'
cn('text-sm text-fg', 'text-fg-muted')'text-sm text-fg-muted'
cn('bg-canvas px-4', 'bg-panel')'px-4 bg-panel'
cn('bg-panel backdrop-blur-sm', 'glass')'glass'
cn('bg-bloom', 'scanlines')'scanlines'
cn('static', 'relative')'static relative'
cn('rounded-md shadow-sm', 'rounded-xl shadow-overlay')'rounded-xl shadow-overlay'
cn('font-sans', 'font-display')'font-display'
cn('animate-fade-in', 'animate-pulse-glow')'animate-pulse-glow'

What the configuration teaches tailwind-merge:

  • Glow sizes (glow, glow-sm, glow-lg) share the box-shadow group, so they replace each other and any shadow-*.
  • Glow colors have a group of their own, shared by glow-* and text-glow-*: the last color wins, and a size never removes a color.
  • text-glow belongs to the text-shadow group.
  • static, scanlines, brackets and bg-bloom all set background-image, so they replace each other.
  • glass replaces an earlier bg-* color or backdrop-blur-*. A color written after it still applies.
  • text-bloom sets several properties at once, so it only replaces another text-bloom.
  • static is left out of the position group, so cn('static', 'relative') keeps the grain.
  • Fonts, radius, shadows and animations extend Tailwind’s scales: font-display, rounded-xs, shadow-overlay and animate-pulse-glow merge like their built-in neighbors.

To use the configuration with your own tailwind-merge instance, pass it to extendTailwindMerge:

import { extendTailwindMerge } from 'tailwind-merge'
import { twMergeConfig } from '@static-bloom/tailwind/merge'
export const merge = extendTailwindMerge(twMergeConfig)

twMergeConfig is typed as TwMergeConfig. The module also exports the ClassValue, ClassDictionary and StaticBloomClassGroupIds types.

tailwind-variants builds class lists from variants. Create its tv with createTV from the tailwind-variants/config entry point, so every recipe merges with the Static Bloom configuration:

src/lib/tv.ts
import { createTV } from 'tailwind-variants/config'
import { twMergeConfig } from '@static-bloom/tailwind/merge'
export const tv = createTV({ twMergeConfig })
src/lib/chip.ts
import { tv } from './tv'
export const chip = tv({
base: 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 font-mono text-xs',
variants: {
intent: {
primary: 'bg-primary/15 text-primary',
danger: 'bg-danger/15 text-danger',
},
glowing: { true: 'glow-sm' },
},
compoundVariants: [{ intent: 'danger', glowing: true, class: 'glow-danger' }],
defaultVariants: { intent: 'primary' },
})

The same recipe, rendered when this page was built. In the last row the caller’s rounded-full replaces the base rounded-md:

Calls of the chip recipe, the rendered chip and the resulting class list
CallRenderedClasses
chip()wantedinline-flex items-center gap-1 rounded-md px-2 py-0.5 font-mono text-xs bg-primary/15 text-primary
chip({ glowing: true })wantedinline-flex items-center gap-1 rounded-md px-2 py-0.5 font-mono text-xs bg-primary/15 text-primary glow-sm
chip({ intent: 'danger', glowing: true })wantedinline-flex items-center gap-1 rounded-md px-2 py-0.5 font-mono text-xs bg-danger/15 text-danger glow-sm glow-danger
chip({ intent: 'danger', class: 'rounded-full' })wantedinline-flex items-center gap-1 px-2 py-0.5 font-mono text-xs bg-danger/15 text-danger rounded-full

@static-bloom/vue exports the same two helpers, already configured: cn is the function above and tv is created with createTV({ twMergeConfig }). Its components merge their class prop with them, so overrides behave the same everywhere.

import { cn, tv } from '@static-bloom/vue'