De utility-first aanpak van Tailwind CSS kan leiden tot opgezwollen JSX als het niet goed georganiseerd is.
class-variance-authority (cva): typeveilige varianten
cva is de standaard manier om componentvarianten in Tailwind te beheren.
// Using class-variance-authority (cva) for type-safe variants
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
// Base classes applied to every variant
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
// Usage:
// <Button variant="outline" size="sm">Click me</Button>
// <Button variant="destructive">Delete</Button>Wanneer @apply gebruiken
@apply extraheert herhaalde Tailwind-klassencombinaties in CSS-klassen.
/* globals.css — using @apply to extract reusable patterns */
@layer components {
/* Card component pattern */
.card {
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
}
.card-header {
@apply flex flex-col space-y-1.5 p-6;
}
.card-title {
@apply text-2xl font-semibold leading-none tracking-tight;
}
.card-content {
@apply p-6 pt-0;
}
/* Form field pattern */
.form-label {
@apply text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70;
}
.form-input {
@apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium
placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
/* Badge pattern */
.badge {
@apply inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold
transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2;
}
}
/* Use sparingly — prefer utility classes in JSX for maintainability */Het cn()-hulpprogramma: klassen veilig samenvoegen
clsx verwerkt voorwaardelijke klassen, tailwind-merge lost Tailwind-conflicten op.
// lib/utils.ts — the cn() helper (used everywhere in shadcn/ui)
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
// clsx: conditionally join class names
// twMerge: intelligently merge Tailwind classes (handles conflicts)
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Examples:
cn('px-4 py-2', 'px-6') // 'py-2 px-6' (px-4 overridden)
cn('text-red-500', isActive && 'text-blue-500') // conditional
cn({ 'opacity-50': disabled, 'cursor-not-allowed': disabled })Headless UI: toegankelijke interactieve componenten
Headless UI biedt volledig toegankelijke interactieve componenten zonder styling.
// Using Headless UI (Tailwind Labs) for accessible components
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
import { Fragment, useState } from 'react';
const people = [
{ id: 1, name: 'Durward Reynolds' },
{ id: 2, name: 'Kenton Towne' },
{ id: 3, name: 'Therese Wunsch' },
];
export function Select() {
const [selected, setSelected] = useState(people[0]);
return (
<Listbox value={selected} onChange={setSelected}>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
<span className="block truncate">{selected.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
<Listbox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{people.map((person) => (
<Listbox.Option key={person.id} value={person} className={({ active }) => cn('relative cursor-default select-none py-2 pl-10 pr-4', active ? 'bg-amber-100 text-amber-900' : 'text-gray-900')}>
{({ selected }) => (
<>
<span className={cn('block truncate', selected ? 'font-medium' : 'font-normal')}>{person.name}</span>
{selected ? <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-amber-600"><CheckIcon className="h-5 w-5" /></span> : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</Listbox>
);
}Tailwind-configuratie: designtokens en thema's
Tailwind uitbreiden met CSS-variabelen maakt themawisseling zonder JavaScript mogelijk.
// tailwind.config.ts — extend with design tokens
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// CSS variable-based colors for theming
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;Componentbibliotheekbenaderingen
| Library | Approach | Code Ownership | Theming | Accessibility |
|---|---|---|---|---|
| shadcn/ui | Copy-paste + Radix + CVA | You own the code | CSS variables | Radix primitives |
| Chakra UI | Component library | npm dependency | Theme object | Built-in |
| MUI (Material) | Component library | npm dependency | Theme provider | Built-in |
| Mantine | Component library | npm dependency | CSS variables | Built-in |
| DaisyUI | Tailwind plugin | Config only | CSS variables | Manual |
Best practices
- cva gebruiken voor elke component met 2+ varianten.
- Altijd cn() gebruiken om klassen samen te voegen.
- @apply vermijden voor componenten.
- Designtokens via CSS-variabelen voor dark mode.
- Voor dropdowns, modals — Headless UI of Radix UI gebruiken.
Veelgestelde vragen
Tailwind of CSS-in-JS?
In 2026 is Tailwind + CVA + shadcn/ui het dominante patroon voor nieuwe React-projecten.
Wat is shadcn/ui?
shadcn/ui is een verzameling copy-paste componenten gebouwd met Radix UI + Tailwind + CVA.
Hoe omgaan met dark mode in Tailwind?
darkMode: "class" toevoegen aan tailwind.config.ts en het dark:-voorvoegsel gebruiken.
Hoe Tailwind-klassen purgen in productie voorkomen?
De content-array in tailwind.config.ts configureren om alle bestanden met Tailwind-klassen op te nemen.
Is Tailwind goed voor ontwerpsystemen?
Ja, maar vereist discipline. Designtokens (CSS-variabelen) gebruiken.