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

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. 💅