CSS Modules

CSS Modules es CSS tradicional con un superpoder: cada componente tiene su propio scope automático. Los nombres de clases no colisionan entre componentes.

¿Qué problema resuelven?

El problema con CSS global

/* Button.css */
.button {
  background: blue;
}
/* Card.css */
.button {
  background: red;  /* ¡Conflicto! ¿Cuál gana? */}

Los estilos globales pueden sobrescribirse accidentalmente.

La solución: CSS Modules

/* Button.module.css */
.button {
  background: blue;
}

NextJS convierte .button en algo como .Button_button__a3d2f, único para ese componente. No hay conflictos.

Setup

CSS Modules funciona out-of-the-box en NextJS. Solo necesitas:

  1. Crear un archivo .module.css
  2. Importarlo en tu componente

Eso es todo. No hay configuración adicional.

Uso básico

Tu primer CSS Module

// components/Button.tsx
import styles from './Button.module.css'

export default function Button({ children }) {
  return (
    <button className={styles.button}>
      {children}
    </button>
  )
}
/* components/Button.module.css */
.button {
  background-color: #3b82f6;
  color: white;
  padding: 10px 20px;
  border-radius: 5px;
  border: none;
  cursor: pointer;
  font-weight: 600;
}

.button:hover {
  background-color: #2563eb;
}

Múltiples clases

import styles from './Card.module.css'

export default function Card({ destacado, children }) {
  return (
    <div className={`${styles.card} ${destacado ? styles.destacado : ''}`}>
      {children}
    </div>
  )
}
/* Card.module.css */
.card {
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.destacado {
  border: 2px solid #3b82f6;
  box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}

Composición de estilos

Puedes componer clases con composes:

/* Button.module.css */
.base {
  padding: 10px 20px;
  border-radius: 5px;
  border: none;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s;
}

.primary {
  composes: base;
  background-color: #3b82f6;
  color: white;
}

.secondary {
  composes: base;
  background-color: #6b7280;
  color: white;
}

.outline {
  composes: base;
  background-color: transparent;
  border: 2px solid #3b82f6;
  color: #3b82f6;
}
import styles from './Button.module.css'

export default function Button({ variant = 'primary', children }) {
  return (
    <button className={styles[variant]}>
      {children}
    </button>
  )
}

Ejemplos prácticos

Componente Button completo

// components/Button/Button.tsx
import styles from './Button.module.css'

type ButtonProps = {
  children: React.ReactNode
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  fullWidth?: boolean
  disabled?: boolean
  onClick?: () => void
}

export default function Button({ 
  children, 
  variant = 'primary',
  size = 'md',
  fullWidth = false,
  disabled = false,
  onClick 
}: ButtonProps) {
  const classNames = [
    styles.button,
    styles[variant],
    styles[size],
    fullWidth && styles.fullWidth,
    disabled && styles.disabled,
  ].filter(Boolean).join(' ')
  
  return (
    <button 
      className={classNames}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  )
}
/* components/Button/Button.module.css */
.button {
  border: none;
  border-radius: 6px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s;
}

/* Variants */
.primary {
  background-color: #3b82f6;
  color: white;
}

.primary:hover {
  background-color: #2563eb;
}

.secondary {
  background-color: #6b7280;
  color: white;
}

.secondary:hover {
  background-color: #4b5563;
}

.danger {
  background-color: #ef4444;
  color: white;
}

.danger:hover {
  background-color: #dc2626;
}

/* Sizes */
.sm {
  padding: 6px 12px;
  font-size: 14px;
}

.md {
  padding: 10px 20px;
  font-size: 16px;
}

.lg {
  padding: 14px 28px;
  font-size: 18px;
}

/* Modifiers */
.fullWidth {
  width: 100%;
}

.disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.disabled:hover {
  background-color: inherit;
}

Card de producto

// components/ProductoCard/ProductoCard.tsx
import Image from 'next/image'
import styles from './ProductoCard.module.css'

export default function ProductoCard({ producto }) {
  return (
    <article className={styles.card}>
      <div className={styles.imageContainer}>
        <Image 
          src={producto.imagen}
          alt={producto.nombre}
          fill
          className={styles.image}
        />
        {producto.descuento && (
          <span className={styles.badge}>
            -{producto.descuento}%
          </span>
        )}
      </div>
      
      <div className={styles.content}>
        <h3 className={styles.title}>{producto.nombre}</h3>
        <p className={styles.description}>{producto.descripcion}</p>
        
        <div className={styles.footer}>
          <span className={styles.price}>${producto.precio}</span>
          <button className={styles.addButton}>Agregar</button>
        </div>
      </div>
    </article>
  )
}
/* components/ProductoCard/ProductoCard.module.css */
.card {
  background: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s, box-shadow 0.3s;
}

.card:hover {
  transform: translateY(-4px);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}

.imageContainer {
  position: relative;
  width: 100%;
  height: 200px;
  background-color: #f3f4f6;
}

.image {
  object-fit: cover;
}

.badge {
  position: absolute;
  top: 12px;
  right: 12px;
  background-color: #ef4444;
  color: white;
  padding: 6px 12px;
  border-radius: 6px;
  font-weight: 700;
  font-size: 14px;
}

.content {
  padding: 20px;
}

.title {
  font-size: 18px;
  font-weight: 700;
  color: #111827;
  margin-bottom: 8px;
}

.description {
  font-size: 14px;
  color: #6b7280;
  margin-bottom: 16px;
  line-height: 1.5;
}

.footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.price {
  font-size: 24px;
  font-weight: 700;
  color: #3b82f6;
}

.addButton {
  background-color: #3b82f6;
  color: white;
  padding: 8px 16px;
  border-radius: 6px;
  border: none;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.2s;
}

.addButton:hover {
  background-color: #2563eb;
}
// components/Navbar/Navbar.tsx
'use client'

import { useState } from 'react'
import Link from 'next/link'
import styles from './Navbar.module.css'

export default function Navbar() {
  const [isOpen, setIsOpen] = useState(false)
  
  return (
    <nav className={styles.navbar}>
      <div className={styles.container}>
        <Link href="/" className={styles.logo}>
          MiTienda
        </Link>
        
        <div className={styles.desktopMenu}>
          <Link href="/productos" className={styles.link}>Productos</Link>
          <Link href="/ofertas" className={styles.link}>Ofertas</Link>
          <Link href="/contacto" className={styles.link}>Contacto</Link>
        </div>
        
        <button 
          className={styles.menuButton}
          onClick={() => setIsOpen(!isOpen)}
          aria-label="Toggle menu"
        >
          <span className={styles.menuIcon}></span>
        </button>
      </div>
      
      {isOpen && (
        <div className={styles.mobileMenu}>
          <Link href="/productos" className={styles.mobileLink}>
            Productos
          </Link>
          <Link href="/ofertas" className={styles.mobileLink}>
            Ofertas
          </Link>
          <Link href="/contacto" className={styles.mobileLink}>
            Contacto
          </Link>
        </div>
      )}
    </nav>
  )
}
/* components/Navbar/Navbar.module.css */
.navbar {
  background-color: white;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 64px;
}

.logo {
  font-size: 24px;
  font-weight: 700;
  color: #3b82f6;
  text-decoration: none;
}

.desktopMenu {
  display: none;
  gap: 32px;
}

.link {
  color: #374151;
  text-decoration: none;
  font-weight: 500;
  transition: color 0.2s;
}

.link:hover {
  color: #3b82f6;
}

.menuButton {
  display: block;
  background: none;
  border: none;
  cursor: pointer;
  padding: 8px;
}

.menuIcon {
  display: block;
  width: 24px;
  height: 2px;
  background-color: #374151;
  position: relative;
}

.menuIcon::before,
.menuIcon::after {
  content: '';
  position: absolute;
  width: 24px;
  height: 2px;
  background-color: #374151;
  left: 0;
}

.menuIcon::before {
  top: -8px;
}

.menuIcon::after {
  top: 8px;
}

.mobileMenu {
  display: flex;
  flex-direction: column;
  padding: 20px;
  border-top: 1px solid #e5e7eb;
}

.mobileLink {
  padding: 12px 0;
  color: #374151;
  text-decoration: none;
  font-weight: 500;
}

.mobileLink:hover {
  color: #3b82f6;
}

/* Tablet y desktop */
@media (min-width: 768px) {
  .desktopMenu {
    display: flex;
  }
  
  .menuButton {
    display: none;
  }
  
  .mobileMenu {
    display: none;
  }
}

Formulario

// components/ContactForm/ContactForm.tsx
'use client'

import { useState } from 'react'
import styles from './ContactForm.module.css'

export default function ContactForm() {
  const [formData, setFormData] = useState({
    nombre: '',
    email: '',
    mensaje: ''
  })
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    console.log('Enviando:', formData)
  }
  
  return (
    <form className={styles.form} onSubmit={handleSubmit}>
      <h2 className={styles.title}>Contáctanos</h2>
      
      <div className={styles.field}>
        <label className={styles.label}>Nombre</label>
        <input 
          type="text"
          className={styles.input}
          value={formData.nombre}
          onChange={(e) => setFormData({...formData, nombre: e.target.value})}
          placeholder="Tu nombre"
          required
        />
      </div>
      
      <div className={styles.field}>
        <label className={styles.label}>Email</label>
        <input 
          type="email"
          className={styles.input}
          value={formData.email}
          onChange={(e) => setFormData({...formData, email: e.target.value})}
          placeholder="tu@email.com"
          required
        />
      </div>
      
      <div className={styles.field}>
        <label className={styles.label}>Mensaje</label>
        <textarea 
          className={styles.textarea}
          value={formData.mensaje}
          onChange={(e) => setFormData({...formData, mensaje: e.target.value})}
          placeholder="Tu mensaje..."
          rows={5}
          required
        />
      </div>
      
      <button type="submit" className={styles.submitButton}>
        Enviar
      </button>
    </form>
  )
}
/* components/ContactForm/ContactForm.module.css */
.form {
  max-width: 500px;
  margin: 0 auto;
  padding: 32px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.title {
  font-size: 28px;
  font-weight: 700;
  margin-bottom: 24px;
  color: #111827;
}

.field {
  margin-bottom: 20px;
}

.label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #374151;
}

.input,
.textarea {
  width: 100%;
  padding: 12px 16px;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  font-size: 16px;
  transition: border-color 0.2s;
}

.input:focus,
.textarea:focus {
  outline: none;
  border-color: #3b82f6;
}

.textarea {
  resize: vertical;
  min-height: 120px;
  font-family: inherit;
}

.submitButton {
  width: 100%;
  padding: 14px;
  background-color: #3b82f6;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.2s;
}

.submitButton:hover {
  background-color: #2563eb;
}

Variables CSS

Puedes usar variables CSS con modules:

/* Button.module.css */
.button {
  --button-bg: #3b82f6;
  --button-hover: #2563eb;
  
  background-color: var(--button-bg);
  color: white;
  padding: 10px 20px;
}

.button:hover {
  background-color: var(--button-hover);
}

Variables globales

/* app/globals.css */
:root {
  --color-primary: #3b82f6;
  --color-secondary: #6b7280;
  --color-danger: #ef4444;
  --color-success: #10b981;
  
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
  
  --radius: 8px;
}

Usar en modules:

/* Button.module.css */
.button {
  background-color: var(--color-primary);
  padding: var(--spacing-md);
  border-radius: var(--radius);
}

Media queries

/* Card.module.css */
.card {
  padding: 16px;
}

/* Tablet */
@media (min-width: 768px) {
  .card {
    padding: 24px;
  }
}

/* Desktop */
@media (min-width: 1024px) {
  .card {
    padding: 32px;
  }
}

Pseudo-clases y pseudo-elementos

/* Link.module.css */
.link {
  color: #3b82f6;
  text-decoration: none;
  position: relative;
}

.link:hover {
  color: #2563eb;
}

.link:active {
  color: #1e40af;
}

.link::after {
  content: '';
  position: absolute;
  bottom: -2px;
  left: 0;
  width: 0;
  height: 2px;
  background-color: #3b82f6;
  transition: width 0.3s;
}

.link:hover::after {
  width: 100%;
}

Helpers de clases

Crea un helper para manejar clases condicionales:

// lib/classnames.ts
export function cn(...classes: (string | false | undefined | null)[]) {
  return classes.filter(Boolean).join(' ')
}

Uso:

import styles from './Button.module.css'
import { cn } from '@/lib/classnames'

export default function Button({ primary, large, disabled }) {
  return (
    <button className={cn(
      styles.button,
      primary && styles.primary,
      large && styles.large,
      disabled && styles.disabled
    )}>
      Click
    </button>
  )
}

O usa la librería clsx:

npm install clsx
import clsx from 'clsx'
import styles from './Button.module.css'

export default function Button({ variant, size }) {
  return (
    <button className={clsx(
      styles.button,
      styles[variant],
      styles[size]
    )}>
      Click
    </button>
  )
}

Estilos globales

Puedes mezclar CSS Modules con estilos globales:

// app/layout.tsx
import './globals.css'

export default function RootLayout({ children }) {
  return (
    <html lang="es">
      <body>{children}</body>
    </html>
  )
}
/* app/globals.css */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: system-ui, -apple-system, sans-serif;
  line-height: 1.5;
  color: #111827;
  background-color: #f9fafb;
}

h1, h2, h3, h4, h5, h6 {
  font-weight: 700;
  line-height: 1.2;
}

a {
  color: inherit;
  text-decoration: none;
}

TypeScript con CSS Modules

Para types automáticos:

npm install -D typescript-plugin-css-modules
// tsconfig.json
{
  "compilerOptions": {
    "plugins": [
      { "name": "typescript-plugin-css-modules" }
    ]
  }
}

Ahora tienes autocompletado:

import styles from './Button.module.css'

// TypeScript sabe qué clases existen
<button className={styles.button}>  // ✅ Autocompletado
<button className={styles.boton}>   // ❌ Error: no existe

Organización de archivos

Opción 1: Junto al componente

components/
├── Button/
│   ├── Button.tsx
│   ├── Button.module.css
│   └── index.ts
├── Card/
│   ├── Card.tsx
│   ├── Card.module.css
│   └── index.ts

Opción 2: En carpeta styles

components/
├── Button.tsx
├── Card.tsx
└── styles/
    ├── Button.module.css
    └── Card.module.css

Recomendación: Opción 1. Mantiene todo relacionado junto.

Patrones comunes

Componente con variantes

// components/Alert/Alert.tsx
import styles from './Alert.module.css'

type AlertProps = {
  variant: 'info' | 'success' | 'warning' | 'error'
  children: React.ReactNode
}

export default function Alert({ variant, children }: AlertProps) {
  return (
    <div className={`${styles.alert} ${styles[variant]}`}>
      {children}
    </div>
  )
}
/* components/Alert/Alert.module.css */
.alert {
  padding: 16px;
  border-radius: 8px;
  border-left: 4px solid;
}

.info {
  background-color: #dbeafe;
  border-color: #3b82f6;
  color: #1e40af;
}

.success {
  background-color: #d1fae5;
  border-color: #10b981;
  color: #065f46;
}

.warning {
  background-color: #fef3c7;
  border-color: #f59e0b;
  color: #92400e;
}

.error {
  background-color: #fee2e2;
  border-color: #ef4444;
  color: #991b1b;
}

Layout con grid

// components/ProductGrid/ProductGrid.tsx
import styles from './ProductGrid.module.css'

export default function ProductGrid({ productos }) {
  return (
    <div className={styles.grid}>
      {productos.map(producto => (
        <ProductCard key={producto.id} producto={producto} />
      ))}
    </div>
  )
}
/* components/ProductGrid/ProductGrid.module.css */
.grid {
  display: grid;
  gap: 24px;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}

@media (max-width: 640px) {
  .grid {
    grid-template-columns: 1fr;
  }
}

Animaciones

/* Spinner.module.css */
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #e5e7eb;
  border-top-color: #3b82f6;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
import styles from './Spinner.module.css'

export default function Spinner() {
  return <div className={styles.spinner} />
}

Mejores prácticas

1. Nombres descriptivos

/* ❌ Mal */
.btn { }
.txt { }
.img { }

/* ✅ Bien */
.button { }
.title { }
.productImage { }

2. Evita dependencias de estructura

/* ❌ Mal - depende de HTML específico */
.card > div > p {
  color: gray;
}

/* ✅ Bien - usa clases */
.cardDescription {
  color: gray;
}

3. Un módulo por componente

/* ❌ Mal - Múltiples componentes en un CSS */
/* components.module.css */
.button { }
.card { }
.navbar { }

/* ✅ Bien - Un módulo por componente */
/* Button.module.css */
.button { }

/* Card.module.css */
.card { }

4. Prefiere composición sobre cascada

/* ❌ Mal */
.button {
  padding: 10px;
}

.button.large {
  padding: 20px;  /* Sobrescribe */}

/* ✅ Bien */
.button {
  padding: 10px;
}

.buttonLarge {
  composes: button;
  padding: 20px;
}

Ventajas

CSS tradicional - No aprendes nada nuevo ✅ Scope automático - Sin conflictos de nombres ✅ Zero runtime - No requiere JavaScript ✅ Performance - CSS estático, super rápido ✅ Sin configuración - Funciona out-of-the-box ✅ Compatibilidad - Funciona con Server Components ✅ TypeScript - Con plugin, tienes types automáticos

Desventajas

Más archivos - Un .css por componente ❌ Sin valores dinámicos - No puedes usar props directamente ❌ Sintaxis de clases - Más verboso que Tailwind ❌ Sin sistema de diseño - Tú defines todo

Cuándo usar CSS Modules

Elige CSS Modules si:

  • Tu equipo domina CSS
  • Necesitas máxima performance
  • Quieres mínimas dependencias
  • Migras de CSS tradicional
  • Bundle size es crítico
  • Trabajas con diseñadores que prefieren CSS

Proyectos ideales:

  • Blogs y sitios de contenido
  • Documentación
  • Landing pages
  • Apps donde cada KB cuenta

Resumen

CSS Modules:

  • CSS tradicional con scope automático
  • Funciona out-of-the-box en NextJS
  • Performance excelente (zero runtime)
  • Perfecto para CSS tradicional
  • Compatible con Server Components

Patrón básico:

  1. Crea Component.module.css
  2. Importa: import styles from './Component.module.css'
  3. Usa: className={styles.nombreClase}

CSS Modules es la opción más simple y con mejor performance. Si tu equipo domina CSS y no necesitas estilos dinámicos, es perfecta. 🎨