CSS architecture for modern web apps: Tailwind conventions and when to break them, CSS Modules for complex components, responsive design system with container queries, fluid typography, and avoiding the most common Tailwind pitfalls.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
clamp() for smooth viewport-based scalingTailwind alone: Most components — utilities for layout, spacing, color
CSS Modules: Components with complex state variants or animations
CSS Custom Properties: Design tokens, theming, values that change at runtime
Inline styles: Only for truly dynamic values (computed from JS)
Never: Styled-components/Emotion (runtime cost, poor DX in 2025)
Default for new projects: Tailwind + CSS Custom Properties for tokens.
// Layout, spacing, color, typography — write directly
<div className="flex items-center gap-4 px-6 py-4 bg-surface rounded-lg">
<h2 className="text-lg font-semibold text-text-primary">Title</h2>
<p className="text-sm text-text-secondary">Subtitle</p>
</div>
@apply)// WRONG: @apply for reuse — defeats Tailwind's purpose, generates dead CSS
// .btn { @apply px-4 py-2 bg-blue-500 text-white rounded; }
// CORRECT: extract to a TypeScript component
// Reuse the component, not the CSS class
function Badge({ variant, children }: BadgeProps) {
return (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
variant === 'success' && 'bg-green-100 text-green-700',
variant === 'error' && 'bg-red-100 text-red-700',
variant === 'warning' && 'bg-yellow-100 text-yellow-700',
)}>
{children}
</span>
);
}
@apply IS acceptable/* Only for base HTML elements you can't add classes to */
/* (e.g., markdown content from a CMS, prose styling) */
.prose h1 { @apply text-3xl font-bold text-text-primary mb-4; }
.prose h2 { @apply text-2xl font-semibold text-text-primary mb-3; }
.prose p { @apply text-base leading-relaxed text-text-primary mb-4; }
.prose a { @apply text-text-brand underline hover:no-underline; }
.prose ul { @apply list-disc pl-5 space-y-1; }
/* Use @tailwindcss/typography plugin instead where possible */
// lib/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
// Merges Tailwind classes correctly — handles conflicts (p-4 + px-6 → px-6)
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Usage
<div className={cn(
'base-class',
isActive && 'text-brand',
size === 'lg' && 'text-lg',
className, // Always accept className prop for overrides
)} />
Use CSS Modules when Tailwind becomes unmanageable: complex keyframe animations, pseudo-elements (::before, ::after), :has() selectors, or more than ~20 conditional classes.
// components/RippleButton/RippleButton.tsx
import styles from './RippleButton.module.css';
import { cn } from '@/lib/cn';
export function RippleButton({ className, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className={cn(styles.root, 'px-4 py-2 font-medium', className)}
{...props}
>
{children}
</button>
);
}
/* components/RippleButton/RippleButton.module.css */
/* Complex interaction state impossible in Tailwind alone */
.root {
position: relative;
overflow: hidden;
}
.root::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle, rgb(255 255 255 / 0.3) 0%, transparent 70%);
transform: scale(0);
transition: transform 0.4s, opacity 0.4s;
opacity: 0;
}
.root:active::after {
transform: scale(4);
opacity: 1;
transition: 0s;
}
// tailwind.config.ts — use semantic breakpoint names
theme: {
screens: {
sm: '640px', // Large phones
md: '768px', // Tablets
lg: '1024px', // Small desktop
xl: '1280px', // Desktop
'2xl': '1536px', // Large desktop
},
}
// Usage: always mobile-first (no prefix = all sizes, then override up)
<div className="
grid
grid-cols-1 /* mobile */
sm:grid-cols-2 /* tablet */
lg:grid-cols-3 /* desktop */
gap-4
sm:gap-6
">
Container queries let a component respond to its container size, not viewport size. Better for reusable components.
/* In a .module.css or global.css */
.card-container {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card-body {
flex-direction: row;
}
}
// Or with Tailwind v4 container queries:
<div className="@container">
<div className="flex flex-col @md:flex-row gap-4">
{/* Responds to container, not viewport */}
</div>
</div>
Scale font sizes smoothly between viewport sizes without breakpoint jumps.
/* Fluid typography with clamp() */
:root {
/* Font scales from 16px at 320px viewport to 18px at 1280px */
--text-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
/* Headings scale more aggressively */
--text-h1: clamp(1.875rem, 1.5rem + 1.875vw, 3rem); /* 30px → 48px */
--text-h2: clamp(1.5rem, 1.25rem + 1.25vw, 2.25rem); /* 24px → 36px */
--text-h3: clamp(1.25rem, 1.1rem + 0.75vw, 1.75rem); /* 20px → 28px */
}
/* Formula: clamp(min, preferred, max)
preferred = minSize + (maxSize - minSize) * (100vw - minWidth) / (maxWidth - minWidth)
Use: https://clamp.font-size.app/ to calculate */
<div className="flex h-screen overflow-hidden">
{/* Sidebar: fixed width, scrollable */}
<aside className="w-64 flex-shrink-0 overflow-y-auto border-r border-border">
<Sidebar />
</aside>
{/* Main: fills remaining space, scrollable independently */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-8">
{children}
</div>
</main>
</div>
<div className="grid grid-rows-[auto_1fr_auto] min-h-screen">
<header className="border-b border-border px-6 h-16 flex items-center">
<Header />
</header>
<div className="grid grid-cols-[240px_1fr] overflow-hidden">
<aside className="overflow-y-auto border-r border-border"><Sidebar /></aside>
<main className="overflow-y-auto p-6"><Outlet /></main>
</div>
<footer className="border-t border-border px-6 py-4 text-sm text-text-secondary">
<Footer />
</footer>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-6">
{items.map(item => <Card key={item.id} item={item} />)}
</div>
| Pitfall | Problem | Fix |
|---|---|---|
text-[#3b82f6] (arbitrary color) | Bypasses token system, not themeable | Use text-brand from design token |
mt-[13px] (arbitrary spacing) | Off the spacing scale, inconsistent | Round to nearest scale value |
| Conditional classes with string template | tailwind-merge can't deduplicate | Use cn() + conditional objects |
| 40+ classes on one element | Unreadable, hard to override | Extract to component |
!important modifiers | Specificity battles | Fix the source of the conflict |
| Purge not configured | 10MB CSS bundle | Ensure content paths cover all files |
cn() (clsx + tailwind-merge) used for all conditional classes[]) used only when design token doesn't existsm:, md:, lg: prefixes)clamp() for headingscontainer-type set on components that need container queries@apply except for prose/markdown stylesclassName prop forwarded on all components (allows external overrides)content config covers all template paths (no missing purge)