CSS-in-JS
CSS-in-JS es escribir CSS dentro de JavaScript. Los estilos se crean dinámicamente en tiempo de ejecución y pueden basarse en props, estado, y lógica de tu aplicación.
¿Qué es CSS-in-JS?
En lugar de archivos CSS separados:
// Con CSS-in-JS
const Button = styled.button`
background-color: ${props => props.primary ? 'blue' : 'gray'};
color: white;
padding: 10px 20px;
&:hover {
opacity: 0.8;
}
`
<Button primary>Click</Button>
Los estilos se escriben directamente en JavaScript y pueden cambiar basado en props.
Importante en NextJS 15
CSS-in-JS requiere que tus componentes sean Client Components ('use client'
). No funciona directamente en Server Components.
Esto significa:
- ❌ No puedes usar CSS-in-JS en Server Components
- ✅ Funciona perfecto en Client Components
- ⚠️ Impacto en performance (runtime overhead)
Si necesitas estilos dinámicos en Server Components, usa Tailwind con clases condicionales o CSS Modules con variables CSS.
Opciones principales
1. styled-components (más popular)
npm install styled-components
npm install -D @types/styled-components
2. Emotion
npm install @emotion/react @emotion/styled
En esta guía usaremos styled-components por ser el más popular.
Setup en NextJS 15
1. Instalar
npm install styled-components
npm install -D @types/styled-components babel-plugin-styled-components
2. Configurar Babel (opcional pero recomendado)
// .babelrc
{
"presets": ["next/babel"],
"plugins": [
[
"babel-plugin-styled-components",
{
"ssr": true,
"displayName": true
}
]
]
}
3. Configurar Next.js
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
compiler: {
styledComponents: true
}
}
module.exports = nextConfig
4. Registry Provider (para SSR)
// app/lib/registry.tsx
'use client'
import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
export default function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode
}) {
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement()
styledComponentsStyleSheet.instance.clearTag()
return <>{styles}</>
})
if (typeof window !== 'undefined') return <>{children}</>
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
)
}
// app/layout.tsx
import StyledComponentsRegistry from './lib/registry'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="es">
<body>
<StyledComponentsRegistry>
{children}
</StyledComponentsRegistry>
</body>
</html>
)
}
Uso básico
Tu primer componente styled
'use client'
import styled from 'styled-components'
const Button = styled.button`
background-color: #3b82f6;
color: white;
padding: 10px 20px;
border-radius: 5px;
border: none;
cursor: pointer;
font-weight: 600;
&:hover {
background-color: #2563eb;
}
`
export default function MyComponent() {
return <Button>Click me</Button>
}
Props dinámicos
'use client'
import styled from 'styled-components'
const Button = styled.button<{ $primary?: boolean }>`
background-color: ${props => props.$primary ? '#3b82f6' : '#6b7280'};
color: white;
padding: 10px 20px;
border-radius: 5px;
border: none;
cursor: pointer;
&:hover {
opacity: 0.8;
}
`
export default function MyComponent() {
return (
<>
<Button $primary>Primario</Button>
<Button>Secundario</Button>
</>
)
}
Props transitorios
Usa el prefijo $
para props que solo quieres usar en los estilos y no pasar al DOM:
// ✅ Bien: $primary no se pasa al botón HTML
<Button $primary>
// ❌ Mal: primary aparece como atributo HTML
<Button primary>
Extender estilos
const Button = styled.button`
padding: 10px 20px;
border-radius: 5px;
border: none;
cursor: pointer;
font-weight: 600;
`
const PrimaryButton = styled(Button)`
background-color: #3b82f6;
color: white;
`
const DangerButton = styled(Button)`
background-color: #ef4444;
color: white;
`
Ejemplos prácticos
Botón completo con variantes
'use client'
import styled from 'styled-components'
type ButtonVariant = 'primary' | 'secondary' | 'danger'
type ButtonSize = 'sm' | 'md' | 'lg'
const StyledButton = styled.button<{
$variant?: ButtonVariant
$size?: ButtonSize
$fullWidth?: boolean
}>`
/* Base styles */
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
/* Variant styles */
${props => {
switch (props.$variant) {
case 'primary':
return `
background-color: #3b82f6;
color: white;
&:hover { background-color: #2563eb; }
`
case 'secondary':
return `
background-color: #6b7280;
color: white;
&:hover { background-color: #4b5563; }
`
case 'danger':
return `
background-color: #ef4444;
color: white;
&:hover { background-color: #dc2626; }
`
default:
return `
background-color: #3b82f6;
color: white;
`
}
}}
/* Size styles */
${props => {
switch (props.$size) {
case 'sm':
return `
padding: 6px 12px;
font-size: 14px;
`
case 'lg':
return `
padding: 14px 28px;
font-size: 18px;
`
default:
return `
padding: 10px 20px;
font-size: 16px;
`
}
}}
/* Full width */
${props => props.$fullWidth && `
width: 100%;
`}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
type ButtonProps = {
variant?: ButtonVariant
size?: ButtonSize
fullWidth?: boolean
children: React.ReactNode
} & React.ButtonHTMLAttributes<HTMLButtonElement>
export default function Button({
variant = 'primary',
size = 'md',
fullWidth = false,
children,
...props
}: ButtonProps) {
return (
<StyledButton
$variant={variant}
$size={size}
$fullWidth={fullWidth}
{...props}
>
{children}
</StyledButton>
)
}
Card de producto
'use client'
import styled from 'styled-components'
import Image from 'next/image'
const Card = styled.article`
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;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
`
const ImageContainer = styled.div`
position: relative;
width: 100%;
height: 200px;
background-color: #f3f4f6;
`
const Badge = styled.span<{ $discount: number }>`
position: absolute;
top: 12px;
right: 12px;
background-color: ${props => props.$discount > 50 ? '#dc2626' : '#ef4444'};
color: white;
padding: 6px 12px;
border-radius: 6px;
font-weight: 700;
font-size: 14px;
`
const Content = styled.div`
padding: 20px;
`
const Title = styled.h3`
font-size: 18px;
font-weight: 700;
color: #111827;
margin-bottom: 8px;
`
const Description = styled.p`
font-size: 14px;
color: #6b7280;
margin-bottom: 16px;
line-height: 1.5;
`
const Footer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`
const Price = styled.span<{ $descuento?: number }>`
font-size: 24px;
font-weight: 700;
color: ${props => props.$descuento ? '#ef4444' : '#3b82f6'};
`
const OldPrice = styled.span`
font-size: 16px;
color: #9ca3af;
text-decoration: line-through;
margin-left: 8px;
`
const AddButton = styled.button`
background-color: #3b82f6;
color: white;
padding: 8px 16px;
border-radius: 6px;
border: none;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #2563eb;
}
`
type ProductoCardProps = {
producto: {
id: string
nombre: string
descripcion: string
precio: number
precioAnterior?: number
descuento?: number
imagen: string
}
}
export default function ProductoCard({ producto }: ProductoCardProps) {
return (
<Card>
<ImageContainer>
<Image
src={producto.imagen}
alt={producto.nombre}
fill
style={{ objectFit: 'cover' }}
/>
{producto.descuento && (
<Badge $discount={producto.descuento}>
-{producto.descuento}%
</Badge>
)}
</ImageContainer>
<Content>
<Title>{producto.nombre}</Title>
<Description>{producto.descripcion}</Description>
<Footer>
<div>
<Price $descuento={producto.descuento}>
${producto.precio}
</Price>
{producto.precioAnterior && (
<OldPrice>${producto.precioAnterior}</OldPrice>
)}
</div>
<AddButton>Agregar</AddButton>
</Footer>
</Content>
</Card>
)
}
Formulario con validación
'use client'
import { useState } from 'react'
import styled from 'styled-components'
const Form = styled.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);
`
const Title = styled.h2`
font-size: 28px;
font-weight: 700;
margin-bottom: 24px;
color: #111827;
`
const Field = styled.div`
margin-bottom: 20px;
`
const Label = styled.label`
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #374151;
`
const Input = styled.input<{ $error?: boolean }>`
width: 100%;
padding: 12px 16px;
border: 2px solid ${props => props.$error ? '#ef4444' : '#e5e7eb'};
border-radius: 8px;
font-size: 16px;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: ${props => props.$error ? '#ef4444' : '#3b82f6'};
}
`
const ErrorMessage = styled.span`
display: block;
margin-top: 4px;
font-size: 14px;
color: #ef4444;
`
const SubmitButton = styled.button`
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;
&:hover {
background-color: #2563eb;
}
&:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
`
export default function ContactForm() {
const [formData, setFormData] = useState({
nombre: '',
email: '',
mensaje: ''
})
const [errors, setErrors] = useState({
nombre: '',
email: '',
mensaje: ''
})
const validate = () => {
const newErrors = { nombre: '', email: '', mensaje: '' }
if (!formData.nombre) {
newErrors.nombre = 'El nombre es requerido'
}
if (!formData.email) {
newErrors.email = 'El email es requerido'
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email inválido'
}
if (!formData.mensaje) {
newErrors.mensaje = 'El mensaje es requerido'
}
setErrors(newErrors)
return !newErrors.nombre && !newErrors.email && !newErrors.mensaje
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
console.log('Enviando:', formData)
}
}
return (
<Form onSubmit={handleSubmit}>
<Title>Contáctanos</Title>
<Field>
<Label>Nombre</Label>
<Input
type="text"
value={formData.nombre}
onChange={(e) => setFormData({...formData, nombre: e.target.value})}
$error={!!errors.nombre}
placeholder="Tu nombre"
/>
{errors.nombre && <ErrorMessage>{errors.nombre}</ErrorMessage>}
</Field>
<Field>
<Label>Email</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => setFormData({...formData, email: e.target.value})}
$error={!!errors.email}
placeholder="tu@email.com"
/>
{errors.email && <ErrorMessage>{errors.email}</ErrorMessage>}
</Field>
<Field>
<Label>Mensaje</Label>
<Input
as="textarea"
rows={5}
value={formData.mensaje}
onChange={(e) => setFormData({...formData, mensaje: e.target.value})}
$error={!!errors.mensaje}
placeholder="Tu mensaje..."
/>
{errors.mensaje && <ErrorMessage>{errors.mensaje}</ErrorMessage>}
</Field>
<SubmitButton type="submit">Enviar</SubmitButton>
</Form>
)
}
Temas (Theming)
Crear un tema
// app/lib/theme.ts
export const theme = {
colors: {
primary: '#3b82f6',
secondary: '#6b7280',
danger: '#ef4444',
success: '#10b981',
text: {
primary: '#111827',
secondary: '#6b7280',
muted: '#9ca3af',
},
background: {
primary: '#ffffff',
secondary: '#f9fafb',
},
},
spacing: {
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
},
borderRadius: {
sm: '4px',
md: '8px',
lg: '12px',
},
breakpoints: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
}
export type Theme = typeof theme
Provider de tema
// app/providers/ThemeProvider.tsx
'use client'
import { ThemeProvider as StyledThemeProvider } from 'styled-components'
import { theme } from '../lib/theme'
export default function ThemeProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<StyledThemeProvider theme={theme}>
{children}
</StyledThemeProvider>
)
}
// app/layout.tsx
import ThemeProvider from './providers/ThemeProvider'
import StyledComponentsRegistry from './lib/registry'
export default function RootLayout({ children }) {
return (
<html lang="es">
<body>
<StyledComponentsRegistry>
<ThemeProvider>
{children}
</ThemeProvider>
</StyledComponentsRegistry>
</body>
</html>
)
}
Usar el tema
'use client'
import styled from 'styled-components'
const Button = styled.button`
background-color: ${props => props.theme.colors.primary};
color: white;
padding: ${props => props.theme.spacing.md};
border-radius: ${props => props.theme.borderRadius.md};
border: none;
cursor: pointer;
&:hover {
opacity: 0.8;
}
@media (min-width: ${props => props.theme.breakpoints.md}) {
padding: ${props => props.theme.spacing.lg};
}
`
export default function MyButton() {
return <Button>Click</Button>
}
Dark mode
// app/lib/theme.ts
export const lightTheme = {
colors: {
background: '#ffffff',
text: '#111827',
primary: '#3b82f6',
}
}
export const darkTheme = {
colors: {
background: '#111827',
text: '#f9fafb',
primary: '#60a5fa',
}
}
// app/providers/ThemeProvider.tsx
'use client'
import { useState } from 'react'
import { ThemeProvider as StyledThemeProvider } from 'styled-components'
import { lightTheme, darkTheme } from '../lib/theme'
export default function ThemeProvider({ children }) {
const [isDark, setIsDark] = useState(false)
return (
<StyledThemeProvider theme={isDark ? darkTheme : lightTheme}>
<button onClick={() => setIsDark(!isDark)}>
Toggle Dark Mode
</button>
{children}
</StyledThemeProvider>
)
}
Estilos globales
// app/lib/globalStyles.ts
'use client'
import { createGlobalStyle } from 'styled-components'
const GlobalStyles = createGlobalStyle`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: ${props => props.theme.colors.text.primary};
background-color: ${props => props.theme.colors.background.primary};
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
line-height: 1.2;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: inherit;
}
`
export default GlobalStyles
// app/layout.tsx
import GlobalStyles from './lib/globalStyles'
export default function RootLayout({ children }) {
return (
<html lang="es">
<body>
<StyledComponentsRegistry>
<ThemeProvider>
<GlobalStyles />
{children}
</ThemeProvider>
</StyledComponentsRegistry>
</body>
</html>
)
}
Helper: css
Para reutilizar bloques de CSS:
import styled, { css } from 'styled-components'
const buttonBase = css`
padding: 10px 20px;
border-radius: 6px;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
`
const PrimaryButton = styled.button`
${buttonBase}
background-color: #3b82f6;
color: white;
`
const SecondaryButton = styled.button`
${buttonBase}
background-color: #6b7280;
color: white;
`
Animaciones
'use client'
import styled, { keyframes } from 'styled-components'
const spin = keyframes`
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
`
const Spinner = styled.div`
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: ${spin} 1s linear infinite;
`
export default function LoadingSpinner() {
return <Spinner />
}
Patrones avanzados
attrs para props estáticas
const Input = styled.input.attrs({ type: 'text' })`
padding: 12px;
border: 2px solid #e5e7eb;
border-radius: 8px;
`
// Uso: type="text" ya está incluido
<Input placeholder="Escribe aquí" />
Componentes polimórficos
const Text = styled.p`
color: #111827;
font-size: 16px;
`
// Renderizar como h1
<Text as="h1">Título</Text>
// Renderizar como span
<Text as="span">Pequeño</Text>
withComponent
const Button = styled.button`
padding: 10px 20px;
background: #3b82f6;
color: white;
`
// Mismo estilo, diferente elemento
const LinkButton = Button.withComponent('a')
<LinkButton href="/productos">Ver productos</LinkButton>
TypeScript
Tipado del tema
// styled.d.ts
import 'styled-components'
import { Theme } from './lib/theme'
declare module 'styled-components' {
export interface DefaultTheme extends Theme {}
}
Ahora tienes autocompletado:
const Button = styled.button`
color: ${props => props.theme.colors.primary}; // ✅ Autocompletado
`
Props tipadas
type ButtonProps = {
$variant: 'primary' | 'secondary'
$size: 'sm' | 'md' | 'lg'
}
const Button = styled.button<ButtonProps>`
background: ${props =>
props.$variant === 'primary' ? '#3b82f6' : '#6b7280'
};
padding: ${props => {
switch (props.$size) {
case 'sm': return '6px 12px'
case 'lg': return '14px 28px'
default: return '10px 20px'
}
}};
`
Mejores prácticas
1. Usa props transitorios
// ✅ Bien: $primary no se pasa al DOM
<Button $primary>
// ❌ Mal: primary aparece como atributo HTML
<Button primary>
2. Componentes pequeños y enfocados
// ❌ Mal: Componente gigante
const Card = styled.div`
/* 200 líneas de CSS... */
`
// ✅ Bien: Componentes pequeños
const Card = styled.div`...`
const CardHeader = styled.div`...`
const CardBody = styled.div`...`
const CardFooter = styled.div`...`
3. Extrae valores repetidos al tema
// ❌ Mal: Colores hardcodeados
background: #3b82f6;
// ✅ Bien: Usa el tema
background: ${props => props.theme.colors.primary};
4. Nombres descriptivos
// ❌ Mal
const Div = styled.div`...`
const Btn = styled.button`...`
// ✅ Bien
const Container = styled.div`...`
const PrimaryButton = styled.button`...`
Performance
1. Memoiza componentes styled
import { memo } from 'react'
const ExpensiveCard = memo(styled.div`
/* Estilos complejos */
`)
2. Evita styled dentro de renders
// ❌ Mal: Se crea en cada render
function MyComponent() {
const Button = styled.button`...`
return <Button>Click</Button>
}
// ✅ Bien: Se crea una vez
const Button = styled.button`...`
function MyComponent() {
return <Button>Click</Button>
}
Ventajas
✅ Estilos dinámicos - Basados en props y estado ✅ Todo en JavaScript - No cambias de archivos ✅ Temas poderosos - Sistema de temas completo ✅ TypeScript - Types perfectos ✅ Scoped automático - Sin conflictos ✅ CSS completo - Pseudo-clases, media queries, etc
Desventajas
❌ Runtime overhead - JavaScript genera CSS en runtime ❌ Bundle size - Más grande que CSS Modules ❌ Requiere 'use client' - No funciona en Server Components ❌ Curva de aprendizaje - Sintaxis nueva ❌ Performance - Más lento que CSS estático ❌ Debugging - Más complejo
Cuándo usar CSS-in-JS
Elige CSS-in-JS si:
- Necesitas estilos muy dinámicos
- Tienes sistema de temas complejo
- Todo tu equipo prefiere JavaScript
- Los estilos dependen mucho del estado
- Migras desde React tradicional
Proyectos ideales:
- Apps con temas personalizables
- Component libraries
- Dashboards con estados complejos
- Apps donde UI cambia mucho según datos
Alternativas modernas
Vanilla Extract
CSS-in-TS con zero runtime:
npm install @vanilla-extract/css
Panda CSS
Estilo de Tailwind pero con mejor tipos:
npm install @pandacss/dev
Resumen
CSS-in-JS:
- Estilos en JavaScript con props dinámicos
- Requiere 'use client' en NextJS 15
- Excelente para estilos dinámicos
- Runtime overhead (impacto en performance)
- Sistema de temas poderoso
Cuándo usar:
- Componentes altamente dinámicos
- Temas complejos
- Estilos basados en estado/props
Cuándo NO usar:
- Server Components
- Performance crítica
- SEO crítico
- Bundle size limitado
CSS-in-JS es poderoso para componentes dinámicos, pero en NextJS 15 considera usar Tailwind o CSS Modules para Server Components. 💅