Skip to content

DOMAIN:FRONTEND:TAILWIND_SHADCN

OWNER: alexander (design system), floris (Team Alpha), floor (Team Beta)
UPDATED: 2026-03-24
SCOPE: all client projects
VERSION: Tailwind CSS v4, shadcn/ui (latest, Tailwind v4 compatible)


TAILWIND_CSS_V4

KEY_CHANGES_FROM_V3

BREAKING: tailwind.config.js replaced by CSS-first configuration via @theme directive.
BREAKING: bg-gradient-to-* renamed to bg-linear-to-*.
BREAKING: flex-shrink-0 renamed to shrink-0, flex-grow renamed to grow.
BREAKING: border-* default color changed from gray-200 to currentColor.
BREAKING: ring-* default width changed from 3px to 1px, default color from blue-500 to currentColor.
BREAKING: minimum browser targets: Safari 16.4, Chrome 111, Firefox 128.
NEW: all design tokens exposed as CSS variables automatically.
NEW: container queries (@container, @min-*, @max-*) in core.
NEW: @property registered custom properties for type-safe CSS variables.
NEW: OKLCH color space support.
NEW: @starting-style for entry animations.
PERFORMANCE: 5x faster full builds, 100x faster incremental builds (Rust-based engine).

CSS_FIRST_CONFIGURATION

/* styles/globals.css */
@import "tailwindcss";
@import "tw-animate-css";

@theme inline {
  /* Color tokens */
  --color-background: oklch(1 0 0);
  --color-foreground: oklch(0.145 0 0);
  --color-primary: oklch(0.205 0.064 270.94);
  --color-primary-foreground: oklch(0.985 0 0);
  --color-secondary: oklch(0.97 0 0);
  --color-secondary-foreground: oklch(0.205 0.064 270.94);
  --color-muted: oklch(0.97 0 0);
  --color-muted-foreground: oklch(0.556 0.016 286.07);
  --color-destructive: oklch(0.577 0.245 27.33);
  --color-destructive-foreground: oklch(0.577 0.245 27.33);
  --color-border: oklch(0.922 0 0);
  --color-ring: oklch(0.87 0 0);
  --color-accent: oklch(0.97 0 0);
  --color-accent-foreground: oklch(0.205 0.064 270.94);
  --color-card: oklch(1 0 0);
  --color-card-foreground: oklch(0.145 0 0);
  --color-popover: oklch(1 0 0);
  --color-popover-foreground: oklch(0.145 0 0);
  --color-input: oklch(0.922 0 0);

  /* Radius tokens */
  --radius-sm: 0.25rem;
  --radius-md: 0.375rem;
  --radius-lg: 0.5rem;
  --radius-xl: 0.75rem;

  /* Spacing tokens */
  --spacing-sidebar: 16rem;
  --spacing-sidebar-collapsed: 4rem;

  /* Font tokens */
  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, monospace;
}

NOTE: @theme inline means tokens are inlined rather than referencing an external theme. This is the shadcn/ui v4 standard.
NOTE: tailwindcss-animate plugin replaced by tw-animate-css import. Do not use old plugin.

DESIGN_TOKEN_MAPPING

GE_RULE: every client project defines tokens in @theme. Components reference tokens, not raw values.

/* GOOD: uses semantic token */
.card { background: var(--color-card); color: var(--color-card-foreground); }

/* BAD: hardcoded value bypasses theming */
.card { background: #ffffff; color: #1a1a1a; }
// GOOD: Tailwind utility with semantic token
<div className="bg-card text-card-foreground rounded-lg border p-6" />

// BAD: raw color value
<div className="bg-white text-gray-900 rounded-lg border p-6" />

RESPONSIVE_PATTERNS

APPROACH: mobile-first. Base classes are mobile. Add breakpoint prefixes for larger screens.

// Mobile: stack. Tablet: 2 columns. Desktop: 3 columns.
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3" />

// Mobile: full width. Desktop: max width centered.
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8" />

// Mobile: hidden. Desktop: visible.
<nav className="hidden lg:block" />

BREAKPOINTS (Tailwind v4 defaults):
- sm: 640px
- md: 768px
- lg: 1024px
- xl: 1280px
- 2xl: 1536px

CONTAINER_QUERIES

NEW_IN_V4: built into core. No plugin needed.
USE_WHEN: component should respond to its container size, not viewport.

// Parent defines container
<div className="@container">
  {/* Child responds to container width */}
  <div className="flex flex-col @md:flex-row @lg:grid @lg:grid-cols-3">
    <Card />
    <Card />
    <Card />
  </div>
</div>

USE_CASE: reusable card components that work in sidebars (narrow) and main content (wide).
BENEFIT: components are self-responsive, work in any layout context without viewport-based hacks.


DARK_MODE

STRATEGY: CSS class-based dark mode with next-themes.

/* styles/globals.css — dark theme overrides */
.dark {
  --color-background: oklch(0.145 0 0);
  --color-foreground: oklch(0.985 0 0);
  --color-card: oklch(0.205 0.015 286);
  --color-card-foreground: oklch(0.985 0 0);
  --color-border: oklch(0.3 0.015 286);
  --color-muted: oklch(0.269 0.015 286);
  --color-muted-foreground: oklch(0.7 0.015 286);
  /* ... all tokens overridden */
}
// app/layout.tsx
import { ThemeProvider } from "next-themes";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

NOTE: suppressHydrationWarning on <html> is required to prevent hydration mismatch from theme class injection.
NOTE: ThemeProvider is a Client Component — that is fine, it wraps at root level.

DARK_MODE_IN_COMPONENTS

// Components automatically adapt via CSS variables — no conditional classes needed
<div className="bg-background text-foreground border" />
// In light mode: white bg, dark text
// In dark mode: dark bg, light text (CSS variables switch automatically)

RULE: use semantic tokens (bg-background, text-foreground, bg-card) not raw colors (bg-white, text-black).
REASON: semantic tokens auto-switch in dark mode. Raw colors do not.


TAILWIND_MERGE

PURPOSE: safely merge Tailwind classes, resolving conflicts (last wins).
LIBRARY: tailwind-merge via the cn() utility.

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

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
// Usage: component default + override from prop
type ButtonProps = { className?: string; children: React.ReactNode };

export function Button({ className, children }: ButtonProps) {
  return (
    <button className={cn("rounded-md bg-primary px-4 py-2 text-primary-foreground", className)}>
      {children}
    </button>
  );
}

// Consumer override wins for conflicting classes:
<Button className="bg-destructive">Delete</Button>
// Result: "rounded-md bg-destructive px-4 py-2 text-primary-foreground"
// bg-primary removed, bg-destructive applied

RULE: every GE component that accepts className must use cn() to merge.
ANTI_PATTERN: string concatenation ("base-class " + className) — causes duplicate/conflicting classes.


SHADCN_UI

ARCHITECTURE

NOT_A_DEPENDENCY: shadcn/ui components are copied into your project via CLI.
LOCATION: components/ui/ directory.
OWNERSHIP: once copied, you own the code. Customize freely.
FOUNDATION: built on Radix UI primitives for accessibility + Tailwind CSS for styling.
DATA_SLOT: every shadcn primitive now has data-slot attribute for targeted styling.

ADDING_COMPONENTS

# Add a specific component
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add data-table

# Add multiple at once
npx shadcn@latest add button card input label

# Initialize shadcn in a new project
npx shadcn@latest init

CUSTOMIZATION

LEVEL_1: override via className prop (consumer-side).
LEVEL_2: edit the component file directly in components/ui/.
LEVEL_3: change CSS variables in @theme for global theme changes.

// Level 1: className override
<Button className="rounded-full" variant="outline">
  Rounded Button
</Button>

// Level 2: edit components/ui/button.tsx directly
// Change the variant definitions, add new variants, modify base styles

THEMING

PATTERN: CSS variables in @theme control the entire look.
CHANGE_THEME: modify CSS variables in globals.css. All shadcn components auto-update.
PER_CLIENT: each client project has its own @theme block with brand colors.

/* Client A: blue brand */
@theme inline {
  --color-primary: oklch(0.55 0.2 250);
  --color-primary-foreground: oklch(0.98 0 0);
}

/* Client B: green brand */
@theme inline {
  --color-primary: oklch(0.6 0.2 145);
  --color-primary-foreground: oklch(0.98 0 0);
}

EXTENDING_COMPONENTS

PATTERN: wrap shadcn components to add domain-specific behavior.

// components/forms/currency-input.tsx
"use client";

import { Input } from "@/components/ui/input";
import { forwardRef, type ComponentPropsWithoutRef } from "react";

type CurrencyInputProps = Omit<ComponentPropsWithoutRef<typeof Input>, "type"> & {
  currency?: string;
};

export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
  ({ currency = "EUR", className, ...props }, ref) => {
    return (
      <div className="relative">
        <span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
          {currency === "EUR" ? "\u20ac" : "$"}
        </span>
        <Input ref={ref} className={cn("pl-8", className)} type="number" step="0.01" {...props} />
      </div>
    );
  },
);
CurrencyInput.displayName = "CurrencyInput";

NOTE: React 19 removes the need for forwardRef in many cases. For new components, you can accept ref directly as a prop. shadcn components are being updated to remove forwardRef.

ACCESSIBILITY_BUILT_IN

FACT: shadcn/ui inherits Radix UI accessibility — keyboard navigation, focus management, ARIA attributes, screen reader announcements.
RULE: do NOT add redundant ARIA attributes to shadcn components — they already have them.
RULE: DO add aria-label or visible labels where Radix cannot infer them (e.g., icon-only buttons).

// GOOD: Radix Dialog handles aria-modal, aria-labelledby, focus trap
<Dialog>
  <DialogTrigger asChild>
    <Button>Open Settings</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Settings</DialogTitle>
      <DialogDescription>Configure your preferences</DialogDescription>
    </DialogHeader>
    {/* content */}
  </DialogContent>
</Dialog>

// GOOD: icon-only button needs aria-label
<Button variant="ghost" size="icon" aria-label="Close menu">
  <XIcon className="h-4 w-4" />
</Button>

RADIX_UI_DIRECTLY

WHEN_TO_USE_RADIX_DIRECTLY

IF shadcn/ui has the component THEN use shadcn (it wraps Radix with styling).
IF shadcn/ui does NOT have the component THEN use Radix primitive directly.
IF shadcn styling conflicts with custom design system needs THEN use Radix directly + custom Tailwind.
IF building a highly custom component (e.g., command palette, virtual list) THEN build from Radix primitives.

RADIX_PRIMITIVES_NOT_IN_SHADCN

EXAMPLES: @radix-ui/react-toolbar, @radix-ui/react-navigation-menu (complex), @radix-ui/react-toggle-group (extended), @radix-ui/react-roving-focus

// Direct Radix usage when shadcn doesn't have it
import * as Toolbar from "@radix-ui/react-toolbar";

export function EditorToolbar() {
  return (
    <Toolbar.Root className="flex gap-1 rounded-md border bg-card p-1">
      <Toolbar.Button className="rounded px-2 py-1 hover:bg-accent" aria-label="Bold">
        <BoldIcon className="h-4 w-4" />
      </Toolbar.Button>
      <Toolbar.Separator className="mx-1 w-px bg-border" />
      <Toolbar.ToggleGroup type="single" aria-label="Text alignment">
        <Toolbar.ToggleItem value="left" className="rounded px-2 py-1 data-[state=on]:bg-accent">
          <AlignLeftIcon className="h-4 w-4" />
        </Toolbar.ToggleItem>
        <Toolbar.ToggleItem value="center" className="rounded px-2 py-1 data-[state=on]:bg-accent">
          <AlignCenterIcon className="h-4 w-4" />
        </Toolbar.ToggleItem>
      </Toolbar.ToggleGroup>
    </Toolbar.Root>
  );
}

RADIX_DATA_ATTRIBUTES

PATTERN: Radix exposes data-state, data-disabled, data-orientation etc. Use these for styling.

/* Style based on Radix state */
[data-state="open"] { --tw-rotate: 180deg; }
[data-state="checked"] { background: var(--color-primary); }
[data-disabled] { opacity: 0.5; pointer-events: none; }
// Tailwind classes using data attributes
<AccordionTrigger className="data-[state=open]:rotate-180" />
<CheckboxIndicator className="data-[state=checked]:bg-primary" />

COMMON_PATTERNS

LOADING_BUTTON

"use client";

import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import type { ComponentPropsWithoutRef } from "react";

type LoadingButtonProps = ComponentPropsWithoutRef<typeof Button> & {
  loading?: boolean;
};

export function LoadingButton({ loading, children, disabled, ...props }: LoadingButtonProps) {
  return (
    <Button disabled={loading || disabled} {...props}>
      {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      {children}
    </Button>
  );
}

RESPONSIVE_DIALOG

// Mobile: sheet (bottom drawer). Desktop: dialog.
"use client";

import { useMediaQuery } from "@/lib/hooks/use-media-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";

type ResponsiveDialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: string;
  children: React.ReactNode;
};

export function ResponsiveDialog({ open, onOpenChange, title, children }: ResponsiveDialogProps) {
  const isDesktop = useMediaQuery("(min-width: 768px)");

  if (isDesktop) {
    return (
      <Dialog open={open} onOpenChange={onOpenChange}>
        <DialogContent>
          <DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
          {children}
        </DialogContent>
      </Dialog>
    );
  }

  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent side="bottom">
        <SheetHeader><SheetTitle>{title}</SheetTitle></SheetHeader>
        {children}
      </SheetContent>
    </Sheet>
  );
}

SELF_CHECK

BEFORE_WRITING_STYLES:
- [ ] am I using semantic tokens (bg-background) not raw colors (bg-white)?
- [ ] am I using cn() for class merging, not string concatenation?
- [ ] is the component mobile-first with breakpoint overrides?
- [ ] does dark mode work via CSS variable switching?
- [ ] if extending shadcn, am I wrapping not forking?
- [ ] if using Radix directly, does shadcn really not have this component?
- [ ] are icon-only buttons labeled with aria-label?
- [ ] am I using @container queries for reusable components?
- [ ] is tw-animate-css imported (not tailwindcss-animate plugin)?