Stijlen

Voorwaardelijke en samenstelbare styling met Tailwind-klassen.

Moderne componentbibliotheken hebben flexibele stylingsystemen nodig die complexe eisen aankunnen zonder de developer experience te schaden. De combinatie van Tailwind CSS met intelligente klasse-samenvoeging is uitgegroeid tot een krachtig patroon voor het bouwen van aanpasbare componenten.

Deze aanpak lost de fundamentele spanning op tussen het bieden van verstandige standaardwaarden en het toestaan van volledige aanpassing — een uitdaging die componentbibliotheken al jaren teistert.

Het probleem met traditionele styling

Traditionele CSS-benaderingen leiden vaak tot specificity-oorlogen, stijlconflicten en onvoorspelbare overrides. Wanneer je className="bg-blue-500" aan een component doorgeeft die al bg-red-500 heeft, welke wint dan?

Zonder juiste afhandeling gelden beide klassen en is het resultaat afhankelijk van veel factoren - de bronvolgorde van CSS, de specificiteit van de klassen, het class-merging-algoritme van de bundler, enz.

Klassen intelligent samenvoegen

De tailwind-merge-bibliotheek lost dit op door Tailwind's klasse-structuur te begrijpen en conflicten intelligent op te lossen. Wanneer twee klassen op dezelfde CSS-eigenschap mikken, houdt het alleen de laatste aan.

Without tailwind-merge
// Both bg-red-500 and bg-blue-500 apply - unpredictable result
<Button className="bg-blue-500" />
// Renders: className="bg-red-500 bg-blue-500"
With tailwind-merge
import { twMerge } from 'tailwind-merge';

// bg-blue-500 wins as it comes last
const className = twMerge('bg-red-500', 'bg-blue-500');
// Returns: "bg-blue-500"

Dit werkt voor alle Tailwind-utilities:

twMerge('px-4 py-2', 'px-8'); // Returns: "py-2 px-8"
twMerge('text-sm', 'text-lg'); // Returns: "text-lg"
twMerge('hover:bg-red-500', 'hover:bg-blue-500'); // Returns: "hover:bg-blue-500"

De bibliotheek begrijpt ook Tailwind's modifier-systeem:

// Modifiers are handled correctly
twMerge('hover:bg-red-500 focus:bg-red-500', 'hover:bg-blue-500');
// Returns: "focus:bg-red-500 hover:bg-blue-500"

Voorwaardelijke klassen

Vaak moet je klassen voorwaardelijk toepassen op basis van props of state. De clsx-bibliotheek biedt een nette API hiervoor:

Using clsx
import clsx from 'clsx';

// Basic conditionals
clsx('base', isActive && 'active');
// Returns: "base active" (if isActive is true)

// Object syntax
clsx('base', {
  'active': isActive,
  'disabled': isDisabled,
});

// Arrays
clsx(['base', isLarge ? 'text-lg' : 'text-sm']);

// Mixed
clsx(
  'base',
  ['array-item'],
  { 'object-conditional': true },
  isActive && 'conditional'
);

Een veelgebruikt patroon is het samenvoegen van een standaardset klassen met binnenkomende props, evenals eventuele eigen logica:

component.tsx
const Component = ({ className, ...props }: ComponentProps) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div
      className={cn(
        "rounded-lg border bg-white shadow-sm",
        isOpen && "bg-blue-500",
        className
      )}
      {...props}
    />
  );
};

De cn-hulpfunctie

De cn-functie, populair gemaakt door shadcn/ui, combineert clsx en tailwind-merge zodat je zowel voorwaardelijke logica als intelligente samenvoeging krijgt:

lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

De kracht zit in de volgorde - basisstijlen eerst, conditionals daarna, gebruiker overrides als laatste. Dit zorgt voor voorspelbaar gedrag terwijl volledige aanpassing behouden blijft.

Class Variance Authority (CVA)

Voor complexe componenten met veel varianten wordt het handmatig beheren van voorwaardelijke klassen onhoudbaar. Class Variance Authority (CVA) biedt een declaratieve API voor het definiëren van componentvarianten.

Bijvoorbeeld, hier is een extract van de Knop-component van shadcn/ui:

@/components/ui/button.tsx
const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost:
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        icon: "size-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

Beste praktijken

1. Volgorde is belangrijk

Pas altijd klassen in deze volgorde toe:

  1. Basisstijlen (altijd toegepast)
  2. Variantiestijlen (gebaseerd op props)
  3. Voorwaardelijke stijlen (gebaseerd op state)
  4. Gebruikersoverschrijvingen (className-prop)
className={cn(
  'base-styles',            // 1. Basis
  variant && variantStyles, // 2. Varianten
  isActive && 'active',     // 3. Conditionals
  className                 // 4. Gebruikersoverschrijvingen
)}

2. Documenteer je varianten

Gebruik TypeScript en JSDoc om te documenteren wat elke variant doet:

type ButtonProps = {
  /**
   * The visual style of the button
   * @default "primary"
   */
  variant?: 'primary' | 'secondary' | 'destructive' | 'ghost';

  /**
   * The size of the button
   * @default "md"
   */
  size?: 'sm' | 'md' | 'lg';
};

3. Haal herhaalde patronen eruit

Als je merkt dat je steeds dezelfde voorwaardelijke logica schrijft, extraheer die:

utils/styles.ts
export const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500';
export const disabled = 'disabled:pointer-events-none disabled:opacity-50';

// Use in components
className={cn(focusRing, disabled, className)}

Migratiegids

Als je migreert van een andere stylingaanpak, zo kun je veelvoorkomende patronen aanpassen:

Van CSS Modules

Before - CSS Modules
import styles from './Button.module.css';

<button className={`${styles.button} ${styles[variant]} ${className}`} />
After - cn + Tailwind
import { cn } from '@/lib/utils';

<button className={cn(
  'px-4 py-2 rounded-lg',
  variant === 'primary' && 'bg-blue-500 text-white',
  className
)} />

Van styled-components

Before - styled-components
const Button = styled.button<{ $primary?: boolean }>`
  padding: 8px 16px;
  background: ${props => props.$primary ? 'blue' : 'gray'};
`;
After - cn + Tailwind
function Button({ primary, className, ...props }) {
  return (
    <button
      className={cn(
        'px-4 py-2',
        primary ? 'bg-blue-500' : 'bg-gray-500',
        className
      )}
      {...props}
    />
  );
}

Prestatie-overwegingen

Zowel clsx als tailwind-merge zijn sterk geoptimaliseerd, maar houd deze tips in gedachten:

  1. Define variants outside components - CVA-varianten moeten buiten de component worden gedefinieerd om recreatie bij elke render te voorkomen.

  2. Memoize complex computations - Als je dure voorwaardelijke logica hebt, overweeg memoization:

const className = useMemo(
  () => cn(
    baseStyles,
    expensiveComputation(props),
    className
  ),
  [props, className]
);
  1. Use CSS variables for dynamic values - In plaats van dynamisch classes te genereren, gebruik CSS-variabelen:
Prefer CSS variables
// Good
<div
  className="bg-[var(--color)]"
  style={{ '--color': dynamicColor } as React.CSSProperties}
/>

// Avoid
<div className={`bg-[${dynamicColor}]`} />

De combinatie van Tailwind CSS, intelligente klasse-samenvoeging en variant-API's biedt een robuuste basis voor componentstyling. Deze aanpak schaalt van eenvoudige knoppen tot complexe designsystemen terwijl voorspelbaarheid en developer experience behouden blijven.