CSS Modules scope styles to the component file — import styles from "./Button.module.css" makes styles.button a unique class name like Button_button__abc12, preventing collisions. composes: base from "./base.module.css" inherits styles from another module. :global(.someClass) creates unscoped selectors. TypeScript type generation with typescript-plugin-css-modules or @types/css-modules provides autocomplete. CSS custom properties (--color-primary) work normally inside modules and can be combined with design tokens. clsx or cn combines module class names conditionally: clsx(styles.button, isActive && styles.active). Keyframe animations defined in modules are also scoped. Vite and Next.js support CSS Modules out of the box for .module.css files. Claude Code generates CSS Module files, TypeScript-typed imports, responsive component styles, and animation patterns for zero-runtime scoped styling.
CLAUDE.md for CSS Modules
## CSS Modules Stack
- Vite/Next.js: .module.css files auto-processed — no config needed
- TS types: add "typescript-plugin-css-modules" to tsconfig plugins for autocomplete
- Import: import styles from "./Component.module.css" — styles.className
- Conditional: className={clsx(styles.base, isActive && styles.active, className)}
- Compose: composes: base from "./shared.module.css" — style inheritance
- Global: :global(.slick-slide) { } — target third-party class names
- Custom props: var(--spacing-md) — CSS variables work normally
- Keyframes: @keyframes fadeIn defined in module — auto-scoped name
Component Example
/* components/ui/Button.module.css */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2, 0.5rem);
padding: var(--spacing-2, 0.5rem) var(--spacing-4, 1rem);
border-radius: var(--radius-md, 0.375rem);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
border: 1px solid transparent;
cursor: pointer;
transition: background-color 150ms ease, box-shadow 150ms ease, transform 100ms ease;
white-space: nowrap;
user-select: none;
}
.button:focus-visible {
outline: 2px solid var(--color-ring, #3b82f6);
outline-offset: 2px;
}
.button:active {
transform: scale(0.98);
}
.button:disabled {
opacity: 0.5;
pointer-events: none;
}
/* Variants */
.primary {
background-color: var(--color-primary, #3b82f6);
color: var(--color-primary-foreground, #ffffff);
}
.primary:hover {
background-color: color-mix(in srgb, var(--color-primary, #3b82f6) 85%, black);
}
.secondary {
background-color: var(--color-secondary, #f1f5f9);
color: var(--color-secondary-foreground, #0f172a);
border-color: var(--color-border, #e2e8f0);
}
.secondary:hover {
background-color: var(--color-secondary-hover, #e2e8f0);
}
.ghost {
background-color: transparent;
color: var(--color-foreground, #0f172a);
}
.ghost:hover {
background-color: var(--color-accent, #f1f5f9);
}
.danger {
background-color: var(--color-destructive, #ef4444);
color: #ffffff;
}
/* Sizes */
.sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
height: 2rem;
}
.md {
height: 2.5rem;
}
.lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
height: 3rem;
}
/* States */
.loading {
pointer-events: none;
}
.fullWidth {
width: 100%;
}
/* Loading spinner */
.spinner {
animation: spin 0.75s linear infinite;
width: 1em;
height: 1em;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// components/ui/Button.tsx — typed CSS module usage
import { clsx } from "clsx"
import styles from "./Button.module.css"
import type { ButtonHTMLAttributes } from "react"
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger"
size?: "sm" | "md" | "lg"
isLoading?: boolean
fullWidth?: boolean
}
export function Button({
variant = "primary",
size = "md",
isLoading = false,
fullWidth = false,
disabled,
className,
children,
...props
}: ButtonProps) {
return (
<button
className={clsx(
styles.button,
styles[variant],
styles[size],
isLoading && styles.loading,
fullWidth && styles.fullWidth,
className // Allow external className override
)}
disabled={disabled || isLoading}
aria-busy={isLoading || undefined}
{...props}
>
{isLoading && (
<svg
className={styles.spinner}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity={0.25} />
<path d="M4 12a8 8 0 018-8" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
</svg>
)}
{children}
</button>
)
}
Card Component with Composes
/* components/ui/Card.module.css */
/* Base styles shared via composes */
.base {
border-radius: var(--radius-lg, 0.5rem);
border: 1px solid var(--color-border, #e2e8f0);
background-color: var(--color-card, #ffffff);
overflow: hidden;
}
.card {
composes: base; /* Inherit base styles */
padding: 1.5rem;
transition: box-shadow 150ms ease, transform 150ms ease;
}
.interactive {
composes: card;
cursor: pointer;
}
.interactive:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.body {
padding: 1.5rem;
}
.footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border, #e2e8f0);
background-color: var(--color-muted, #f8fafc);
}
/* Media queries within modules */
@media (max-width: 640px) {
.card {
border-radius: 0;
border-left: none;
border-right: none;
}
}
TypeScript Configuration
// tsconfig.json — CSS Modules autocomplete plugin
{
"compilerOptions": {
"plugins": [
{
"name": "typescript-plugin-css-modules",
"options": {
"classnameTransform": "camelCaseOnly"
}
}
]
}
}
// vite.config.ts — CSS Modules in Vite (works out of the box)
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig({
plugins: [react()],
css: {
modules: {
// Enable camelCase transformation: my-class → myClass
localsConvention: "camelCaseOnly",
// Include file hash in generated class names
generateScopedName: "[name]__[local]__[hash:base64:5]",
},
},
})
Animation Patterns
/* components/animations/FadeIn.module.css — scoped animations */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.fadeUp {
animation: fadeUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.slideIn {
animation: slideIn 0.3s ease-out forwards;
}
.skeleton {
animation: pulse 1.5s ease-in-out infinite;
background: linear-gradient(90deg,
var(--color-muted, #f1f5f9) 25%,
var(--color-muted-hover, #e2e8f0) 50%,
var(--color-muted, #f1f5f9) 75%
);
background-size: 200% 100%;
}
/* Stagger children: nth-child delays */
.staggerChildren > :nth-child(1) { animation-delay: 0ms; }
.staggerChildren > :nth-child(2) { animation-delay: 60ms; }
.staggerChildren > :nth-child(3) { animation-delay: 120ms; }
.staggerChildren > :nth-child(4) { animation-delay: 180ms; }
.staggerChildren > :nth-child(5) { animation-delay: 240ms; }
/* Respect user's motion preference */
@media (prefers-reduced-motion: reduce) {
.fadeUp, .slideIn { animation: none; opacity: 1; transform: none; }
}
For the Tailwind CSS alternative when utility-first styling with design system tokens, a purge-enabled production build, and no per-component CSS files are preferred — Tailwind co-locates styles in JSX as className strings and eliminates the need for .module.css files entirely, see the Tailwind guide. For the vanilla-extract alternative when CSS-in-TypeScript with full type safety, design token themes (createGlobalTheme), and sprinkles for atomic CSS generation are needed — vanilla-extract statically extracts to plain CSS at build time like CSS Modules but with TypeScript-authored styles, see the type-safe CSS guide. The Claude Skills 360 bundle includes CSS Modules skill sets covering variants, animations, and TypeScript integration. Start with the free tier to try scoped styling generation.