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)?