---
title: "Partículas atmosféricas en Next.js sin Canvas: performante con CSS puro"
excerpt: "Cómo construir un sistema de partículas (embers, chispas, copos de nieve) en Next.js con CSS puro: sin Canvas, sin requestAnimationFrame, SSR-safe, configurable por palette y dirección, y con cero impacto en performance."
date: "2026-05-24T12:00:00.000Z"
category: "Next.js"
seo_title: "Partículas CSS-only en Next.js: sistema de embers sin canvas ni rAF"
seo_description: "Construye un efecto de partículas atmosféricas en Next.js usando CSS puro: keyframes con cqh, generación determinista para SSR, palettes configurables y respeto por prefers-reduced-motion. Sin canvas, sin loops JS."
author:
  name: "angel cruz"
  picture: "https://angelcruzdevcdn.nyc3.cdn.digitaloceanspaces.com/images/me/angel-cruz.png"
ogImage:
  url: "https://angelcruzdevcdn.nyc3.cdn.digitaloceanspaces.com/content/4/01KF4MJMCWDRH9M8H7J0P25GP9.png"
---

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.

```typescript
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:

```typescript
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%)`:

```css
@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`.

```css
.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:

```css
.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:

```tsx
<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:

```typescript
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:

```tsx
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:

```css
.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):

```tsx
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):

```tsx
<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:

```css
@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:

```typescript
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

- [CSS Container Queries en MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries)
- [Container query length units (cqh, cqw, cqi, cqb)](https://developer.mozilla.org/en-US/docs/Web/CSS/length#container_query_length_units)
- [OKLCH color picker (oklch.com)](https://oklch.com/)
- [`prefers-reduced-motion` media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
- [Numerical Recipes LCG constants](https://en.wikipedia.org/wiki/Linear_congruential_generator)

---

## Sitemap

Índice completo del sitio: [/sitemap.md](https://angelcruz.dev/sitemap.md)

Canónico HTML: [https://angelcruz.dev/post/particulas-atmosfericas-nextjs-css-sin-canvas](https://angelcruz.dev/post/particulas-atmosfericas-nextjs-css-sin-canvas)
