CSS Modules
"CSS Modules: scoped CSS, composition, global styles, Next.js integration, TypeScript declarations, naming conventions"
CSS Modules are plain CSS files where every class name is locally scoped by default. The build tool (Vite, webpack, Next.js) transforms each class into a unique identifier at compile time, preventing style collisions across components without requiring a runtime library. CSS Modules embrace standard CSS syntax, making them accessible to anyone who knows CSS, while adding scoping and composition capabilities. They produce static CSS files with no JavaScript runtime cost, work with any framework, and integrate naturally with existing tooling like PostCSS, Sass, and CSS custom properties. The approach is intentionally minimal: scope class names, allow composition, and get out of the way.
## Key Points
1. **Use camelCase for class names.** CSS Modules map class names to JavaScript object keys. camelCase names (`cardTitle`) are ergonomic to access as `styles.cardTitle` without bracket notation.
2. **One module per component.** Co-locate `Component.module.css` next to `Component.tsx`. This keeps the relationship explicit and makes it easy to find and clean up styles.
3. **Use `composes` for shared patterns.** Extract common patterns (focus rings, truncation, visually hidden) into a shared module and compose them rather than duplicating CSS.
5. **Generate type declarations.** Use `typed-css-modules` or the Vite/webpack plugin options to generate `.d.ts` files for each module, catching typos at compile time.
6. **Use `clsx` for conditional class application.** It is the cleanest way to apply modifier classes based on props or state.
1. **Using `:global` excessively.** Wrapping large blocks in `:global` defeats the purpose of CSS Modules. Reserve it for styling third-party elements or HTML you cannot add classes to.
2. **String-concatenating class names.** Writing `className={styles.btn + " " + styles.large}` is fragile and will break if a class is undefined. Use `clsx` or array join with filter.
4. **Importing one module's classes into another module's CSS.** Cross-module `composes` works, but chaining it more than one level deep becomes hard to trace. Keep composition shallow.
5. **Mixing CSS Modules with a CSS-in-JS library.** Running styled-components or Emotion alongside CSS Modules creates two parallel styling systems with different scoping rules and conventions.
6. **Naming classes after elements instead of roles.** Using `.div` or `.span` as class names is meaningless. Name classes by purpose: `.root`, `.title`, `.action`, `.badge`.
## Quick Example
```tsx
// No configuration needed for Next.js
// Simply import .module.css files
import styles from "./Card.module.css";
```
```bash
npm install -D typed-css-modules
npx tcm src --watch
```skilldb get css-styling-services-skills/CSS ModulesFull skill: 443 linesCSS Modules
Core Philosophy
CSS Modules are plain CSS files where every class name is locally scoped by default. The build tool (Vite, webpack, Next.js) transforms each class into a unique identifier at compile time, preventing style collisions across components without requiring a runtime library. CSS Modules embrace standard CSS syntax, making them accessible to anyone who knows CSS, while adding scoping and composition capabilities. They produce static CSS files with no JavaScript runtime cost, work with any framework, and integrate naturally with existing tooling like PostCSS, Sass, and CSS custom properties. The approach is intentionally minimal: scope class names, allow composition, and get out of the way.
Setup
Next.js (Built-in Support)
Next.js supports CSS Modules out of the box. Any file ending in .module.css is treated as a CSS Module.
// No configuration needed for Next.js
// Simply import .module.css files
import styles from "./Card.module.css";
Vite (Built-in Support)
Vite handles .module.css files natively. No plugin is needed.
// vite.config.ts — CSS Modules work by default
// Optional: customize the class name pattern
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
css: {
modules: {
localsConvention: "camelCaseOnly",
generateScopedName: "[name]__[local]___[hash:base64:5]",
},
},
});
TypeScript Declarations
// src/types/css-modules.d.ts
declare module "*.module.css" {
const classes: Readonly<Record<string, string>>;
export default classes;
}
declare module "*.module.scss" {
const classes: Readonly<Record<string, string>>;
export default classes;
}
Typed CSS Modules (Optional, Strict Typing)
npm install -D typed-css-modules
npx tcm src --watch
This generates .module.css.d.ts files with exact class name exports:
// Card.module.css.d.ts (auto-generated)
declare const styles: {
readonly root: string;
readonly header: string;
readonly title: string;
readonly body: string;
readonly footer: string;
};
export default styles;
Key Techniques
Basic Component Styling
/* Card.module.css */
.root {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
transition: box-shadow 0.2s ease;
}
.root:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
}
.header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
font-weight: 600;
font-size: 1.125rem;
color: var(--color-text-primary);
}
.body {
padding: 1.5rem;
color: var(--color-text-secondary);
line-height: 1.6;
}
.footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
// Card.tsx
import styles from "./Card.module.css";
interface CardProps {
title?: string;
footer?: React.ReactNode;
children: React.ReactNode;
}
export function Card({ title, footer, children }: CardProps) {
return (
<div className={styles.root}>
{title && <div className={styles.header}>{title}</div>}
<div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
);
}
Composition with composes
/* shared.module.css */
.textTruncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.focusRing {
outline: none;
}
.focusRing:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Button.module.css */
.base {
composes: focusRing from "./shared.module.css";
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 500;
border-radius: 0.5rem;
cursor: pointer;
border: none;
transition: background-color 0.15s ease;
}
.base:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primary {
composes: base;
background-color: var(--color-brand);
color: #fff;
}
.primary:hover:not(:disabled) {
background-color: var(--color-brand-dark);
}
.secondary {
composes: base;
background-color: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.secondary:hover:not(:disabled) {
background-color: var(--color-surface-hover);
}
.sm {
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
}
.md {
font-size: 1rem;
padding: 0.5rem 1rem;
}
.lg {
font-size: 1.125rem;
padding: 0.75rem 1.5rem;
}
// Button.tsx
import styles from "./Button.module.css";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
intent?: "primary" | "secondary";
size?: "sm" | "md" | "lg";
}
export function Button({
intent = "primary",
size = "md",
className,
children,
...props
}: ButtonProps) {
const classes = [styles[intent], styles[size], className]
.filter(Boolean)
.join(" ");
return (
<button className={classes} {...props}>
{children}
</button>
);
}
Conditional Classes with clsx
import clsx from "clsx";
import styles from "./NavLink.module.css";
interface NavLinkProps {
href: string;
active?: boolean;
children: React.ReactNode;
}
export function NavLink({ href, active = false, children }: NavLinkProps) {
return (
<a
href={href}
className={clsx(styles.link, {
[styles.active]: active,
})}
aria-current={active ? "page" : undefined}
>
{children}
</a>
);
}
/* NavLink.module.css */
.link {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
text-decoration: none;
border-radius: 0.5rem;
transition: color 0.15s, background-color 0.15s;
}
.link:hover {
color: var(--color-text-primary);
background-color: var(--color-surface-hover);
}
.active {
color: var(--color-brand);
background-color: var(--color-brand-bg);
}
Global Styles and :global Selector
/* Table.module.css */
.table {
width: 100%;
border-collapse: collapse;
}
.table :global(th) {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
border-bottom: 2px solid var(--color-border);
}
.table :global(td) {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.table :global(tr:hover td) {
background-color: var(--color-surface-hover);
}
CSS Variables for Theming
/* globals.css (not a module — imported once at root) */
:root {
--color-brand: #3b82f6;
--color-brand-dark: #1d4ed8;
--color-brand-bg: #eff6ff;
--color-surface: #ffffff;
--color-surface-hover: #f9fafb;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--color-brand: #60a5fa;
--color-brand-dark: #3b82f6;
--color-brand-bg: rgba(59, 130, 246, 0.1);
--color-surface: #1f2937;
--color-surface-hover: #374151;
--color-text-primary: #f9fafb;
--color-text-secondary: #9ca3af;
--color-border: #374151;
}
}
Responsive Layout Module
/* Grid.module.css */
.grid {
display: grid;
gap: var(--grid-gap, 1.5rem);
grid-template-columns: repeat(var(--grid-cols, 1), minmax(0, 1fr));
}
@media (min-width: 640px) {
.cols2Sm { --grid-cols: 2; }
}
@media (min-width: 768px) {
.cols2Md { --grid-cols: 2; }
.cols3Md { --grid-cols: 3; }
}
@media (min-width: 1024px) {
.cols3Lg { --grid-cols: 3; }
.cols4Lg { --grid-cols: 4; }
}
// Grid.tsx
import clsx from "clsx";
import styles from "./Grid.module.css";
interface GridProps {
cols?: { sm?: 2; md?: 2 | 3; lg?: 3 | 4 };
gap?: string;
children: React.ReactNode;
}
export function Grid({ cols, gap, children }: GridProps) {
return (
<div
className={clsx(styles.grid, {
[styles.cols2Sm]: cols?.sm === 2,
[styles.cols2Md]: cols?.md === 2,
[styles.cols3Md]: cols?.md === 3,
[styles.cols3Lg]: cols?.lg === 3,
[styles.cols4Lg]: cols?.lg === 4,
})}
style={gap ? ({ "--grid-gap": gap } as React.CSSProperties) : undefined}
>
{children}
</div>
);
}
Best Practices
- Use camelCase for class names. CSS Modules map class names to JavaScript object keys. camelCase names (
cardTitle) are ergonomic to access asstyles.cardTitlewithout bracket notation. - One module per component. Co-locate
Component.module.cssnext toComponent.tsx. This keeps the relationship explicit and makes it easy to find and clean up styles. - Use
composesfor shared patterns. Extract common patterns (focus rings, truncation, visually hidden) into a shared module and compose them rather than duplicating CSS. - Use CSS custom properties for theming. Define a set of
--color-*,--space-*custom properties on:rootand reference them in modules. This enables dark mode and theme switching without build-time tooling. - Generate type declarations. Use
typed-css-modulesor the Vite/webpack plugin options to generate.d.tsfiles for each module, catching typos at compile time. - Use
clsxfor conditional class application. It is the cleanest way to apply modifier classes based on props or state.
Anti-Patterns
- Using
:globalexcessively. Wrapping large blocks in:globaldefeats the purpose of CSS Modules. Reserve it for styling third-party elements or HTML you cannot add classes to. - String-concatenating class names. Writing
className={styles.btn + " " + styles.large}is fragile and will break if a class is undefined. Useclsxor array join with filter. - Deeply nested selectors. CSS Modules scope by class name, not by selector specificity. Deep nesting like
.root .wrapper .inner .itemadds specificity for no benefit and makes styles harder to override. - Importing one module's classes into another module's CSS. Cross-module
composesworks, but chaining it more than one level deep becomes hard to trace. Keep composition shallow. - Mixing CSS Modules with a CSS-in-JS library. Running styled-components or Emotion alongside CSS Modules creates two parallel styling systems with different scoping rules and conventions.
- Naming classes after elements instead of roles. Using
.divor.spanas class names is meaningless. Name classes by purpose:.root,.title,.action,.badge.
Install this skill directly: skilldb add css-styling-services-skills
Related Skills
Lightning CSS
"Lightning CSS: ultra-fast CSS transformer, bundler, and minifier written in Rust, with modern syntax lowering, vendor prefixing, and CSS modules"
Panda CSS
"Panda CSS: build-time CSS-in-JS, atomic CSS, recipes, patterns, tokens, conditions, RSC compatible, type-safe styles"
Stitches
"Stitches: near-zero runtime CSS-in-JS, variants API, theme tokens, responsive styles, SSR support, polymorphic components"
Styled Components
"styled-components: CSS-in-JS, tagged template literals, theming, dynamic styles, SSR, global styles, extending"
Tailwind CSS
"Tailwind CSS: utility-first CSS, responsive design, dark mode, custom theme, plugins, @apply, JIT, content configuration, arbitrary values"
UnoCSS
"UnoCSS: instant on-demand atomic CSS engine, presets, shortcuts, rules, variants, attributify mode, icon support, inspector"