Skip to main content
Technology & EngineeringCss Styling Services443 lines

CSS Modules

"CSS Modules: scoped CSS, composition, global styles, Next.js integration, TypeScript declarations, naming conventions"

Quick Summary29 lines
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 lines
Paste into your CLAUDE.md or agent config

CSS 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

  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.
  4. Use CSS custom properties for theming. Define a set of --color-*, --space-* custom properties on :root and reference them in modules. This enables dark mode and theme switching without build-time tooling.
  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.

Anti-Patterns

  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.
  3. Deeply nested selectors. CSS Modules scope by class name, not by selector specificity. Deep nesting like .root .wrapper .inner .item adds specificity for no benefit and makes styles harder to override.
  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.

Install this skill directly: skilldb add css-styling-services-skills

Get CLI access →