Next.js

Partículas atmosféricas en Next.js sin Canvas: performante con CSS puro

Autorangel cruz
Publicado
Lectura13 min de lectura
Partículas atmosféricas en Next.js sin Canvas: performante con CSS puro

Hace unas semanas trabajaba en una ficha de personaje para una campaña de D&D y quería que el avatar tuviera atmósfera: chispas de forja flotando alrededor, como si saliera directo del taller del herrero. La imagen del avatar venía con el fondo removido (transparente), así que necesitaba una capa visual detrás para darle contexto.

La primera reacción de cualquier desarrollador es: Canvas + requestAnimationFrame. Es la solución por defecto para sistemas de partículas. Y es exactamente la que descarté después de pensarlo bien.

En este artículo te muestro cómo construir un sistema de partículas atmosféricas usando solo CSS: sin Canvas, sin loops de JavaScript en runtime, SSR-safe, configurable por palette de colores y dirección de movimiento. El resultado es performante, accesible, y se compone limpio con otros elementos de la página.

¿Por qué CSS y no Canvas?

Cuando empecé a planificar el efecto, archivé la idea de Canvas en un documento de decisiones. Las razones que tuve:

1. Costo de mantenimiento permanente. Un requestAnimationFrame corre cada frame mientras el componente está montado. Aunque pauses cuando la pestaña está oculta o cuando el elemento está fuera del viewport (con IntersectionObserver), sigues pagando ese costo en cada frame visible. Para un avatar de 360px que probablemente esté en pantalla mientras el usuario lee el resto del contenido, eso son ~60 frames por segundo de cálculos de física más reflows.

2. Riesgo de "AI-slop estético". Las partículas ambient sobre fondos oscuros son un cliché de landings de SaaS futuristas. Si no se ejecuta perfectamente, lee como template genérico en lugar de diseño intencional. El criterio mental: si alguien puede mirar el efecto y decir "esto lo hizo AI" sin dudarlo, fallaste. Las partículas Canvas mal calibradas caen en esa categoría rápido.

3. SSR y hidratación. Canvas necesita JavaScript en el cliente para arrancar. Eso significa que durante el primer paint el avatar no tiene ningún efecto, y al hidratarse aparece la animación de golpe. Hay un flash visual que rompe la continuidad.

4. Para 10-20 partículas, CSS es más performante que Canvas. Esto sorprende a la mayoría. El navegador composita transforms en GPU; un Canvas requiere repintar cada frame en CPU y luego transferirlo. Para conteos pequeños, la balanza se inclina hacia CSS.

5. CSS sobrevive prefers-reduced-motion con un media query. En Canvas, tienes que escribir lógica JS para detectar la preferencia y apagar el loop. En CSS es una sola regla.

La conclusión: Canvas tiene sentido para sistemas de partículas con física compleja, miles de partículas, o efectos que dependen de interacción mouse/touch. Para "partícula flotando hacia arriba con un fade al final", CSS es la herramienta correcta.

La arquitectura del sistema

Para mantenerlo reutilizable, conviene separar la lógica en tres piezas:

  1. Un módulo de lógica pura que genere los datos de las partículas (posiciones, tamaños, duraciones, hues). Esto es testeable y reutilizable.
  2. Un componente atómico que renderice una capa de partículas (las anima, aplica la palette, controla la dirección). Es el primitivo del sistema.
  3. Composiciones específicas que usen ese componente atómico para casos concretos (un avatar con partículas detrás y delante, un hero con partículas cayendo, etc.).

Esta separación es importante: el componente de partículas sirve para cualquier contexto, y las composiciones construyen patrones específicos sin tocar la lógica de animación.

Pieza 1: generación determinista de partículas

La pregunta clave para SSR: ¿cómo generar posiciones aleatorias para las partículas sin que el server y el cliente computen valores diferentes?

Si usas Math.random(), el server genera unos valores, el cliente genera otros, React detecta el mismatch durante la hidratación y dispara un warning (o peor: re-renderiza el árbol entero). Inaceptable.

La solución: un LCG (Linear Congruential Generator) determinista. Misma seed, misma secuencia, siempre.

export function seededRandom(seed: number): () => number {
  let state = seed;
  return () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
}

Los números 1664525 y 1013904223 son las constantes del Numerical Recipes LCG: no son arbitrarias, están elegidas para tener buen período. El módulo 4294967296 (2³²) acota el state a 32 bits sin overflow en JS.

Con esto, server y cliente computan exactamente la misma secuencia de partículas. Cero hydration mismatch.

Ahora la generación del array de partículas:

interface Particle {
  left: number;       // % horizontal donde empieza
  size: number;       // px de diámetro
  duration: number;   // s de animación
  delay: number;      // s antes de arrancar
  hue: number;        // OKLCH hue
}
 
interface Palette {
  hot: number;        // hue principal
  cool: number;       // hue secundario
  accent: number;     // hue de acento
}
 
export function buildParticles(
  count: number,
  baseDuration: number,
  palette: Palette,
  seed: number = 42
): Particle[] {
  const rand = seededRandom(seed);
  const particles: Particle[] = [];
 
  for (let i = 0; i < count; i++) {
    const r1 = rand();
    const r2 = rand();
    const r3 = rand();
    const r4 = rand();
 
    // ~10% accent, ~20% cool, resto hot
    const isAccent = i % 9 === 1;
    const isCool = i % 5 === 0 && !isAccent;
 
    particles.push({
      left: 6 + r1 * 88,                              // 6-94% del ancho
      size: 2 + r2 * 3,                               // 2-5px
      duration: baseDuration + (r3 - 0.5) * 1.5,      // baseDuration ± 0.75s
      delay: (i / count) * baseDuration + r4 * 0.6,   // distribuido en el ciclo
      hue: isAccent ? palette.accent : isCool ? palette.cool : palette.hot,
    });
  }
 
  return particles;
}

Dos detalles clave:

Delays distribuidos, no random. Si todos los delays fueran random, podrías tener clumping: varias partículas arrancando juntas y dejando gaps. Aquí cada partícula arranca en una fracción del ciclo (i / count), con un pequeño jitter random. Resultado: flujo continuo, nunca un gap visible.

Distribución de colores por turnos. El i % 9 === 1 y i % 5 === 0 aseguran proporciones consistentes (~10% accent, ~20% cool, ~70% hot) sin depender del random. Esto hace el efecto visual predecible independientemente de la seed.

Pieza 2: container query units (cqh) para el movimiento vertical

Aquí viene la parte más interesante (y la que tiene un gotcha que te puede costar un rato).

Lo intuitivo es animar las partículas con translateY(-100%):

@keyframes particle-rise {
  0%   { transform: translateY(0); }
  100% { transform: translateY(-100%); }
}

No funciona. Las partículas vibran en la base sin moverse. ¿Por qué?

Porque translateY(%) en CSS refiere a la altura del propio elemento, no del contenedor. Como cada partícula mide 2-5px, -100% solo la mueve 2-5px hacia arriba. Inútil.

La solución: container query units. Con cqh (container query height), 1cqh = 1% de la altura del contenedor con container-type definido.

.particle-container {
  container-type: size;
}
 
@keyframes particle-rise {
  0%   { opacity: 0;    transform: translate3d(0, 0, 0) scale(0.5); }
  6%   { opacity: 1;    transform: translate3d(1px, -6cqh, 0) scale(1); }
  75%  { opacity: 1;    transform: translate3d(-3px, -75cqh, 0) scale(0.95); }
  90%  { opacity: 0.35; transform: translate3d(2px, -90cqh, 0) scale(0.7); }
  100% { opacity: 0;    transform: translate3d(-1px, -100cqh, 0) scale(0.4); }
}

Ahora -100cqh mueve la partícula 100% de la altura del contenedor padre: recorre todo el frame sin importar si el contenedor mide 200px o 800px.

Soporte de browsers: Chrome 105+, Safari 16+, Firefox 110+. Para 2026 esto es seguro.

La curva de opacidad

La curva de opacidad está calibrada para que las partículas estén brillantes durante el primer 75% del recorrido y se apaguen en el último 25%. Eso simula brasas reales: viven con fuerza mientras suben, pierden energía al llegar arriba.

0%   ─────  invisible (en la base)
6%   ─────  100% brillo (encendida)
75%  ─────  100% brillo (sigue brillante)  ←─ empieza a apagarse
90%  ─────  35% brillo (apagándose)
100% ────  invisible (en el tope)

Si inviertes el rango (apagar al principio, brillar al final) o lo haces simétrico, el efecto pierde el carácter "brasa real". La asimetría es la clave.

Pieza 3: dirección configurable (up / down)

El sistema soporta dos direcciones: partículas que suben desde la base (forja) o que caen desde arriba (cenizas, nieve, lluvia mágica).

La implementación es un par de keyframes espejados más un atributo data-direction por partícula:

.particle[data-direction="up"] {
  bottom: 0;
  animation-name: particle-rise;
}
 
.particle[data-direction="down"] {
  top: 0;
  animation-name: particle-fall;
}
 
@keyframes particle-fall {
  0%   { opacity: 0;    transform: translate3d(0, 0, 0) scale(0.5); }
  6%   { opacity: 1;    transform: translate3d(1px, 6cqh, 0) scale(1); }
  75%  { opacity: 1;    transform: translate3d(-3px, 75cqh, 0) scale(0.95); }
  90%  { opacity: 0.35; transform: translate3d(2px, 90cqh, 0) scale(0.7); }
  100% { opacity: 0;    transform: translate3d(-1px, 100cqh, 0) scale(0.4); }
}

La diferencia entre rise y fall son:

  • Anchor: bottom: 0 vs top: 0
  • Signo del translateY: negativo (sube) vs positivo (cae)

Mismo timing, misma curva de opacidad. Misma "vida" de la partícula.

En React, el componente decide qué dirección renderizar:

<span
  className="particle absolute rounded-full"
  data-direction={direction}  // "up" o "down"
  style={{ ... }}
/>

Pieza 4: sistema de palettes configurable

Para que el sistema sirva más allá del personaje herrero específico, las partículas son configurables por palette. Algunos presets útiles:

export const PALETTES = {
  forge:  { hot: 50,  cool: 35,  accent: 305 },   // smith / fuego
  arcane: { hot: 270, cool: 240, accent: 180 },   // wizard
  frost:  { hot: 210, cool: 230, accent: 180 },   // ice / arctic
  shadow: { hot: 20,  cool: 290, accent: 130 },   // necromancer
  snow:   {
    hot: 220, cool: 200, accent: 250,
    lightness: 0.92, chroma: 0.04,                // copos de nieve
  },
} as const;

Cada palette define tres hues en OKLCH:

  • hot: el color de la mayoría de las partículas (~70%)
  • cool: variante secundaria (~20%)
  • accent: el color de acento raro (~10%), sirve para incorporar un detalle visual de marca

El palette snow muestra un detalle extra: además de los hues, override de lightness (0.92 en lugar del default 0.68) y chroma (0.04 en lugar de 0.22). Eso convierte las "brasas brillantes" en "copos de nieve casi blancos con tinte azul sutil". La misma estructura, diferente carácter visual.

Por qué OKLCH y no HSL/RGB

OKLCH te da control perceptualmente uniforme. Cuando subes lightness de 0.65 a 0.85 en OKLCH, el color realmente se ve "más claro" la misma cantidad para el ojo humano. En HSL eso no pasa: tonos amarillos y azules con la misma "lightness" se ven con brillo muy distinto.

Para un sistema donde quieres que snow (azul-blanco) se vea igual de brillante que forge (naranja), OKLCH es la única opción.

El componente de capa de partículas

Con todas las piezas en lugar, el componente que las junta queda pequeño:

import type { CSSProperties } from "react";
 
interface ParticleLayerProps {
  direction?: "up" | "down";
  palette?: PaletteName;
  intensity?: "quiet" | "medium" | "energetic";
  glow?: boolean;
  seed?: number;
  count?: number;
  className?: string;
}
 
const INTENSITY_CONFIG = {
  quiet:     { count: 10, baseDuration: 8,   glowOpacity: 0.2  },
  medium:    { count: 18, baseDuration: 5,   glowOpacity: 0.35 },
  energetic: { count: 26, baseDuration: 3.5, glowOpacity: 0.5  },
} as const;
 
export default function ParticleLayer({
  direction = "up",
  palette = "forge",
  intensity = "quiet",
  glow = true,
  seed = 42,
  count,
  className = "",
}: ParticleLayerProps) {
  const config = INTENSITY_CONFIG[intensity];
  const paletteHues = PALETTES[palette];
  const actualCount = count ?? config.count;
  const particles = buildParticles(actualCount, config.baseDuration, paletteHues, seed);
  const lightness = paletteHues.lightness ?? 0.68;
  const chroma = paletteHues.chroma ?? 0.22;
 
  return (
    <div
      aria-hidden
      className={`particle-container absolute inset-0 pointer-events-none ${className}`}
      style={
        {
          "--particle-glow-opacity": config.glowOpacity,
          "--particle-glow-hue": paletteHues.hot,
        } as CSSProperties
      }
    >
      {glow && <div className="particle-glow absolute inset-0" />}
 
      {particles.map((p, i) => (
        <span
          key={i}
          className="particle absolute rounded-full"
          data-direction={direction}
          style={
            {
              left: `${p.left}%`,
              width: `${p.size}px`,
              height: `${p.size}px`,
              "--particle-color": `oklch(${lightness} ${chroma} ${p.hue})`,
              animationDuration: `${p.duration}s`,
              animationDelay: `${p.delay}s`,
            } as CSSProperties
          }
        />
      ))}
    </div>
  );
}

Detalles importantes:

Server Component. No necesita "use client" porque no hay state ni event handlers ni useEffect. Solo render más CSS. Eso significa que el JS bundle del cliente no incluye este componente.

Inline styles via CSS variables. Cada partícula recibe --particle-color como CSS var. Esto permite que el box-shadow glow alrededor de la partícula coincida con su color sin tener que duplicarlo:

.particle {
  background: var(--particle-color);
  box-shadow:
    0 0 4px var(--particle-color),
    0 0 8px var(--particle-color);
}

aria-hidden en el contenedor. Las partículas son decorativas, los lectores de pantalla las ignoran.

pointer-events-none asegura que el layer no bloquea hover o clicks en lo que está debajo o encima.

Componiendo para casos específicos

ParticleLayer es la unidad atómica. A partir de ahí construyes patrones específicos según necesites.

Caso 1: portrait con atmósfera

Para mostrar un personaje con partículas alrededor (delante y detrás de la figura):

function AnimatedPortrait({ src, alt, intensity = "quiet", palette = "forge" }) {
  // Split del budget total entre back y front (~⅔ + ⅓)
  const totalCount = INTENSITY_CONFIG[intensity].count;
  const frontCount = Math.ceil(totalCount / 3);
  const backCount = totalCount - frontCount;
 
  return (
    <div className="relative aspect-square overflow-hidden">
      <ParticleLayer
        direction="up" palette={palette} intensity={intensity}
        count={backCount} seed={42}
      />
 
      <Image src={src} alt={alt} fill className="z-10 object-contain" />
 
      <ParticleLayer
        direction="up" palette={palette} intensity={intensity}
        count={frontCount} seed={108} glow={false} className="z-20"
      />
    </div>
  );
}

El detalle de performance aquí: el total de partículas no cambia comparado con una sola capa. Si intensity="quiet" daría 10 partículas en una sola capa, aquí dan 6 atrás + 4 adelante = 10 totales. La doble capa solo agrega un <div> de contenedor extra, no más partículas. Cero impacto de performance, ganancia visual significativa (sparks delante y atrás del personaje).

Las seeds distintas (42 para back, 108 para front) aseguran que las posiciones no se solapen: cada capa tiene su propia "lluvia" de partículas, no duplicados.

Caso 2: hero con partículas cayendo

Para un hero a full viewport con partículas cayendo desde arriba (cenizas, nieve, lluvia mágica):

<section className="relative min-h-dvh">
  <Image src="/hero.jpg" alt="" fill className="object-cover" />
 
  <ParticleLayer
    direction="down"
    palette="snow"
    intensity="energetic"
    glow={false}
    className="hidden lg:block"
  />
 
  <div>... contenido del hero ...</div>
</section>

El mismo componente. Diferentes props. Cero condicionales internos.

Accesibilidad: prefers-reduced-motion

Una sola regla CSS apaga todo el sistema para usuarios con esa preferencia:

@media (prefers-reduced-motion: reduce) {
  .particle-glow {
    animation: none;
    opacity: 0.85;
  }
  .particle {
    animation: none;
    opacity: 0;
  }
}

Nota la decisión: el glow se mantiene visible pero estático (sin pulse), las partículas se ocultan completamente. La intuición: el glow es atmósfera ambiental, las partículas son movimiento. Si el usuario pide menos movimiento, sacamos el movimiento pero mantenemos la atmósfera.

Performance breakdown

Para que el costo del sistema quede claro, lo medí. En un portrait con intensity="quiet" (10 partículas):

Métrica Valor
Elementos DOM agregados 13 (1 contenedor + 1 glow + 10 spans + 1 contenedor front)
JS runtime cost 0 (solo render inicial, sin loops)
JS bundle agregado 0 (Server Component)
Repaints por frame 0 (transforms compositados en GPU)
CPU usage cuando off-screen 0 (el browser pausa animaciones fuera del viewport)

Compara eso con un Canvas con requestAnimationFrame:

Métrica Valor
Elementos DOM 1 (el canvas)
JS runtime cost ~16ms cada frame (60fps target)
JS bundle agregado ~3-5KB minificado (lógica de física + render loop)
Repaints 60/s (entero el canvas)
CPU usage Continuo mientras visible

Para sistemas de 10-30 partículas, CSS gana fácil. Cuando empiezas a hablar de cientos o miles de partículas con física inter-partícula (colisiones, gravedad real, etc.), Canvas se vuelve necesario. Pero esa no es la mayoría de los casos en una web típica.

Mejores prácticas

1. Usa cqh desde el principio, no vh ni %

Si caes en la trampa de translateY(%) (que refiere al propio elemento), vas a perder tiempo debugueando por qué las partículas no se mueven. Usa cqh desde el primer día. Recuerda agregar container-type: size al contenedor padre.

2. Pre-computa la distribución de delays

Si dejas los delays al random puro, vas a tener gaps visibles donde no hay partículas. Distribúyelos uniformemente con un jitter pequeño:

delay: (i / count) * baseDuration + r4 * 0.6

Esto garantiza flujo continuo.

3. CSS variables para colores por partícula

No insertes el color directamente en background. Úsalo como --particle-color CSS variable. Eso te permite que box-shadow, filter, o futuras propiedades hereden el color sin duplicar la lógica.

4. Server Component cuando puedas

Si el componente no necesita state o event handlers, déjalo como Server Component. Bajas bundle y mejoras LCP. El sistema completo de partículas cabe perfecto en SSR.

5. Calibra la curva de opacidad, no la dejes lineal

El efecto "brasa/spark" depende de que las partículas estén brillantes la mayor parte del recorrido y se apaguen al final. Una curva lineal (fade gradual de 100% a 0%) lee como "se apagan desde el principio" y se siente sin vida. Mantén el plateau brillante hasta el 70-80% del recorrido.

Problemas comunes y soluciones

"Las partículas no se mueven, solo vibran en la base"

Causa: estás usando translateY(-100%) que refiere al tamaño de la partícula, no del contenedor.

Solución: cambia a translateY(-100cqh) y agrega container-type: size al contenedor padre. Verifica soporte de browser si necesitas browsers viejos.

"Aparece un warning de hydration mismatch"

Causa: estás usando Math.random() o Date.now() para generar posiciones.

Solución: usa un LCG determinista con seed fija. Misma seed = misma secuencia = server y cliente concuerdan.

"Las partículas se ven 'sintéticas', no parecen brasas reales"

Causa: probablemente alguna de estas tres cosas:

  • Curva de opacidad lineal (fade gradual desde el inicio)
  • Tamaño de partículas demasiado uniforme
  • Falta de box-shadow glow alrededor de cada partícula

Solución: opacidad en plateau brillante el primer 75%, fade en el último 25%. Varía los tamaños entre 2px y 5px. Agrega doble box-shadow para halo glow.

"El loop de animación se ve obvio (se repite igual cada cierto tiempo)"

Causa: pocas partículas (3-5) con duraciones idénticas.

Solución: sube el count a 10+ y agrega variación en duration (baseDuration ± 0.75s). Con 10 partículas a duraciones ligeramente distintas, el patrón visualmente nunca se repite.

"El efecto consume batería en mobile"

Causa: el browser está animando incluso cuando el componente está fuera del viewport.

Solución: aunque CSS pausa animaciones fuera del viewport automáticamente, puedes ayudar agregando content-visibility: auto al contenedor. También verifica que prefers-reduced-motion esté respetado.

Conclusión

El instinto inicial de "voy a usar Canvas para partículas" es razonable, pero no siempre es la respuesta correcta. Para efectos atmosféricos con conteos pequeños (~10-30 partículas), CSS puro te da:

  • Cero impacto en JS bundle cuando el componente es Server Component
  • Cero costo de runtime (sin loops)
  • SSR safe desde el primer paint
  • Composición limpia con el resto del DOM
  • Accesibilidad declarativa vía media queries
  • Performance superior para conteos pequeños

El costo es que pierdes flexibilidad para física compleja (colisiones, gravedad inter-partícula, comportamientos emergentes). Si tu efecto no necesita esas cosas, CSS es la herramienta correcta.

Checklist para construir tu propio sistema

  1. Define qué tan complejo es el efecto que quieres. Si cabe en una curva lineal de keyframes, vas a CSS.
  2. Genera posiciones con un LCG determinista (seed fija) para que SSR no se rompa.
  3. Distribuye delays uniformemente, no random puro: evita gaps y clumping.
  4. Usa cqh para movimiento que escale con el contenedor padre.
  5. Calibra la curva de opacidad asimétrica (plateau bright + fade tardío).
  6. Suma box-shadow glow por partícula para sensación de brasa/spark real.
  7. Respeta prefers-reduced-motion con un media query simple.
  8. Si el componente no tiene state, déjalo como Server Component.
  9. Si el sistema sirve para múltiples contextos, sepáralo en una primitiva más composiciones específicas.

Puntos clave a recordar

  • El gotcha de translateY(%): refiere al elemento, no al contenedor. Usa cqh.
  • Math.random() es prohibido en componentes SSR: usa LCG determinista con seed.
  • El "feel" de brasa real está en la curva de opacidad asimétrica, no en cuántas partículas tengas.
  • El sistema de palettes con lightness y chroma opcionales te deja construir variantes muy distintas (forge → snow) reutilizando la misma estructura.

Recursos adicionales

Demo en vivo

Probá las combinaciones

Cada combinación de palette, intensity, direction y glow cambia el carácter visual del sistema. Este es exactamente el mismo componente descrito arriba, corriendo en vivo con los controles abajo.

Direction
Glow
Bio
Angel Cruz

Desarrollador web full-stack enfocado en React, buenas prácticas y código abierto. Apasionado por construir productos útiles y compartir lo aprendido en el camino.