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:
- Crear un archivo
.module.css
- 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;
}
Navbar responsive
// 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:
- Crea
Component.module.css
- Importa:
import styles from './Component.module.css'
- 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. 🎨