Lluvia de estrellas — UI/UX mejorada

✨ Lluvia de estrellas

Mueve el ratón o arrastra el dedo. Cambia la física y la densidad en tiempo real.

Modos

Atajos: 1 Gravedad, 2 Sin gravedad, 3 Desvanecer, C Limpiar.

FPS:
Interactivo · WebGL/Canvas Friendly

¿Cómo funciona la lluvia de estrellas?

El cursor (o tu dedo en móvil) “siembra” partículas que se mueven según el modo elegido. La animación está optimizada para 60 FPS y pensada para integrarse en landings modernas.

Modos

  • Gravedad: caída suave con aceleración vertical.
  • Sin gravedad: partículas flotan libremente.
  • Desvanecimiento: deja estela con fundido sutil.

Rendimiento

  • Canvas único, sin múltiples DOM nodes.
  • Pool de partículas para evitar GC excesivo.
  • Escalado por DPR y throttling de input.

Accesibilidad

  • Rol e aria-label descriptivos en el lienzo.
  • Controles con labels visibles y foco claro.
  • Atajos de teclado (1/2/3 y C) anunciados.

Controles

  • Densidad: más o menos partículas por gesto.
  • Tamaño: escala de la estrella y su brillo.
  • Limpiar: reinicia el lienzo sin recargar.
1

Entrada

Leemos tu posición y velocidad de movimiento (mouse o touch).

2

Spawn

Generamos partículas con color, tamaño y rotación aleatoria.

3

Física

Aplicamos gravedad (si procede) y actualizamos posición por frame.

4

Dibujo

Pintamos formas estrelladas con blending aditivo para brillo suave.

¿Consume muchos recursos?

Está pensado para ser ligero. Aun así, puedes limitar densidad y tamaño para dispositivos modestos.

¿Se puede integrar en React/Next/Astro?

Sí. Encapsula el lienzo en un componente y mueve los listeners al efecto de montaje.

¿Cómo cambio la paleta?

Modifica las variables CSS --accent y --accent-2, o expón un selector en el HUD.

HTML+CSS+JS

				
					
<head>
  <style>
    :root {
      --bg: #0b1020;
      --panel: rgba(17, 24, 39, 0.7);
      --text: #e5e7eb;
      --muted: #9ca3af;
      --accent: #8b5cf6;
      --accent-2: #06b6d4;
      --radius: 14px;
    }
    * { box-sizing: border-box }
    html, body { height: 100%; }
    body {
      margin: 0;
      background: radial-gradient(1200px 800px at 70% 20%, #101736 0%, var(--bg) 60%);
      color: var(--text);
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
      overflow-x: hidden;
    }

    /* Canvas fills the viewport */
    #stage { position: fixed; inset: 0; display: block; }

    /* HUD */
    .hud {
      position: fixed; left: 16px; top: 16px; z-index: 10;
      backdrop-filter: blur(10px);
      background: var(--panel);
      border: 1px solid rgba(255,255,255,0.08);
      border-radius: var(--radius);
      box-shadow: 0 10px 30px rgba(0,0,0,.35);
      padding: 14px 16px;
      width: min(92vw, 360px);
      margin-top: 25%;
    }
    .hud h2 { margin: 0 0 6px; font-size: 15px; font-weight: 600; letter-spacing: .2px; }
    .hud p  { margin: 0 0 10px; font-size: 12.5px; color: var(--muted); line-height: 1.4; }

    .row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 10px; margin-top: 10px; }
    fieldset { border: 0; padding: 0; margin: 0; }
    .modes { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
    .modes input { display: none; }
    .modes label {
      user-select: none; text-align: center; cursor: pointer;
      font-size: 12.5px; padding: 8px 10px; border-radius: 10px;
      background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08);
      transition: transform .12s ease, background .2s ease, border-color .2s ease;
    }
    .modes input:checked + label { background: linear-gradient(135deg, rgba(139,92,246,.35), rgba(6,182,212,.18)); border-color: rgba(139,92,246,.6); }
    .modes label:active { transform: scale(.98); }

    .slider { appearance: none; height: 6px; border-radius: 999px; width: 100%; background: rgba(255,255,255,0.15); outline: none; }
    .slider::-webkit-slider-thumb { appearance: none; width: 16px; height: 16px; border-radius: 50%; background: white; box-shadow: 0 1px 6px rgba(0,0,0,.35); }
    .slider::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: white; border: 0; box-shadow: 0 1px 6px rgba(0,0,0,.35); }

    .btns { display: flex; gap: 8px; flex-wrap: wrap; }
    .btn {
      cursor: pointer; border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.06);
      color: var(--text); padding: 8px 10px; border-radius: 10px; font-size: 12.5px;
      transition: transform .12s ease, background .2s ease, border-color .2s ease;
    }
    .btn:hover { background: rgba(255,255,255,0.12); }
    .btn:active { transform: translateY(1px); }

    .kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 11px; opacity: .7; }

    /* Tooltip chip */
    .chip { position: fixed; right: 16px; bottom: 16px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.14); backdrop-filter: blur(8px); color: var(--text); padding: 10px 12px; border-radius: 999px; font-size: 12.5px; box-shadow: 0 8px 24px rgba(0,0,0,.35); }

    @media (max-width: 480px) {
      .hud { left: 50%; transform: translateX(-50%); width: calc(100vw - 24px); }
    }
  </style>
</head>
<body>
  <canvas id="stage" aria-label="Lienzo de lluvia de estrellas interactivo" role="img"></canvas>

  <div class="hud" aria-live="polite">
    <h2>✨ Lluvia de estrellas</h2>
    <p>Mueve el ratón o arrastra el dedo. Cambia la física y la densidad en tiempo real.</p>

    <fieldset class="row">
      <legend class="sr-only">Modos</legend>
      <div class="modes" id="modeGroup">
        <input type="radio" name="mode" id="m1" value="gravity" checked>
        <label for="m1" title="Las estrellas caen">Gravedad ON</label>
        <input type="radio" name="mode" id="m2" value="nogravity">
        <label for="m2" title="Sin caída">Sin gravedad</label>
        <input type="radio" name="mode" id="m3" value="fade">
        <label for="m3" title="Estela persistente">Desvanecimiento</label>
      </div>
    </fieldset>

    <div class="row" style="align-items:center">
      <label for="density" style="font-size:12.5px; color:var(--muted)">Densidad</label>
      <input id="density" class="slider" type="range" min="0" max="1" step="0.01" value="0.6" />
    </div>

    <div class="row" style="align-items:center">
      <label for="size" style="font-size:12.5px; color:var(--muted)">Tamaño</label>
      <input id="size" class="slider" type="range" min="0.5" max="2.0" step="0.05" value="1.0" />
    </div>

    <div class="btns" style="margin-top:10px">
      <button class="btn" id="clear">Limpiar</button>
      <button class="btn" id="toggleTrail">Estela: <span id="trailVal">ON</span></button>
    </div>

    <p style="margin-top:10px; font-size:11.5px; color:var(--muted)">Atajos: <span class="kbd">1</span> Gravedad, <span class="kbd">2</span> Sin gravedad, <span class="kbd">3</span> Desvanecer, <span class="kbd">C</span> Limpiar.</p>
  </div>

  <div class="chip">FPS: <span id="fps">–</span></div>

<script>
(() => {
  const canvas = document.getElementById('stage');
  const ctx = canvas.getContext('2d', { alpha: false });
  const fpsEl = document.getElementById('fps');
  const trailBtn = document.getElementById('toggleTrail');
  const trailVal = document.getElementById('trailVal');
  const clearBtn = document.getElementById('clear');
  const densitySlider = document.getElementById('density');
  const sizeSlider = document.getElementById('size');
  const modeGroup = document.getElementById('modeGroup');

  const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
  let W = 0, H = 0;

  const colors = ['#E23636','#F9F3EE','#E1F8DC','#B8AFE6','#AEE1CD','#5EB0E5'];
  const rand = (a,b)=> a + Math.random()*(b-a);
  const randi = (a,b)=> (a + Math.floor(Math.random()*(b-a+1)));

  let state = {
    mode: 'gravity',     // 'gravity' | 'nogravity' | 'fade'
    trail: true,
    density: parseFloat(densitySlider.value),
    sizeMul: parseFloat(sizeSlider.value),
  };

  // Resize canvas
  const resize = () => {
    W = canvas.clientWidth = window.innerWidth;
    H = canvas.clientHeight = window.innerHeight;
    canvas.width = Math.floor(W * DPR);
    canvas.height = Math.floor(H * DPR);
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    // paint background to avoid black flash on first frame
    ctx.fillStyle = getComputedStyle(document.body).backgroundColor || '#0b1020';
    ctx.fillRect(0,0,W,H);
  };
  addEventListener('resize', resize, { passive: true });
  resize();

  // Particle system (pooled for performance)
  const MAX = 1200; // upper bound; actual active depends on density
  const pool = new Array(MAX);
  let head = 0;
  for (let i=0;i<MAX;i++) pool[i] = { x:0,y:0, vx:0,vy:0, life:0, max:0, size:1, color:'#fff', spin:0, rot:0 };
  const actives = [];

  function spawn(x, y, speed=1) {
    // number based on density and pointer speed
    const n = 1 + Math.floor(state.density * 6 * speed);
    for (let k=0;k<n;k++) {
      const p = pool[head]; head = (head+1) % MAX;
      p.x = x + rand(-4,4);
      p.y = y + rand(-4,4);
      const a = rand(-Math.PI, Math.PI);
      const s = rand(.5, 2.0);
      p.vx = Math.cos(a) * s;
      p.vy = Math.sin(a) * s - (state.mode==='gravity'? rand(0,0.5): 0);
      p.max = 600 + randi(0, 400);
      p.life = p.max;
      p.size = rand(0.8, 1.6) * state.sizeMul;
      p.color = colors[randi(0, colors.length-1)];
      p.spin = rand(-0.2, 0.2);
      p.rot = rand(0, Math.PI*2);
      actives.push(p);
    }
  }

  // Pointer handling
  let lastX = null, lastY = null, lastT = performance.now();
  const onPointerMove = (e) => {
    const x = e.clientX; const y = e.clientY; const t = performance.now();
    if (lastX===null) { lastX=x; lastY=y; lastT=t; return; }
    const dx = x-lastX, dy = y-lastY; const dt = Math.max(1, t-lastT);
    const speed = Math.min(6, Math.hypot(dx,dy)/dt*16); // ~pixels/frame
    spawn(x,y,speed);
    lastX=x; lastY=y; lastT=t;
  };

  addEventListener('pointermove', onPointerMove, { passive: true });
  addEventListener('pointerdown', (e)=> spawn(e.clientX, e.clientY, 3), { passive: true });

  // Controls
  modeGroup.addEventListener('change', (e) => {
    if (e.target && e.target.name==='mode') state.mode = e.target.value;
  });
  trailBtn.addEventListener('click', () => {
    state.trail = !state.trail; trailVal.textContent = state.trail ? 'ON' : 'OFF';
  });
  clearBtn.addEventListener('click', () => {
    actives.length = 0; ctx.clearRect(0,0,W,H); // repaint bg
    ctx.fillStyle = getComputedStyle(document.body).backgroundColor || '#0b1020';
    ctx.fillRect(0,0,W,H);
  });
  densitySlider.addEventListener('input', () => state.density = parseFloat(densitySlider.value));
  sizeSlider.addEventListener('input', () => state.sizeMul = parseFloat(sizeSlider.value));

  addEventListener('keydown', (e)=>{
    if (e.key==='1') { document.getElementById('m1').checked=true; state.mode='gravity'; }
    if (e.key==='2') { document.getElementById('m2').checked=true; state.mode='nogravity'; }
    if (e.key==='3') { document.getElementById('m3').checked=true; state.mode='fade'; }
    if (e.key==='c' || e.key==='C') clearBtn.click();
  });

  // Render loop
  let last = performance.now();
  let fpsAcc = 0, fpsFrames = 0;
  function tick(ts) {
    const dt = Math.min(32, ts - last); last = ts;

    // background / trail
    if (state.mode==='fade' && state.trail) {
      ctx.fillStyle = 'rgba(11,16,32,0.08)';
      ctx.fillRect(0,0,W,H);
    } else if (state.trail) {
      ctx.fillStyle = 'rgba(11,16,32,0.20)';
      ctx.fillRect(0,0,W,H);
    } else {
      ctx.clearRect(0,0,W,H);
      ctx.fillStyle = getComputedStyle(document.body).backgroundColor || '#0b1020';
      ctx.fillRect(0,0,W,H);
    }

    const g = state.mode==='gravity' ? 0.04 : 0.0;

    // update & draw
    for (let i=actives.length-1; i>=0; i--) {
      const p = actives[i];
      p.vy += g * dt; // gravity
      p.x += p.vx * dt * 0.06;
      p.y += p.vy * dt * 0.06;
      p.life -= dt* (state.mode==='fade'? 0.3 : 1);

      if (p.y > H+40 || p.x < -40 || p.x > W+40 || p.life <= 0) {
        actives.splice(i,1);
        continue;
      }

      // draw star (four-point rotated cross)
      ctx.save();
      ctx.translate(p.x, p.y);
      p.rot += p.spin;
      ctx.rotate(p.rot);
      const s = p.size * 3.5;
      const g1 = ctx.createLinearGradient(-s, -s, s, s);
      g1.addColorStop(0, p.color);
      g1.addColorStop(1, '#ffffff');
      ctx.fillStyle = g1;
      ctx.globalCompositeOperation = 'lighter';
      ctx.beginPath();
      ctx.moveTo(0, -s);
      ctx.lineTo(s*0.35, -s*0.35);
      ctx.lineTo(s, 0);
      ctx.lineTo(s*0.35, s*0.35);
      ctx.lineTo(0, s);
      ctx.lineTo(-s*0.35, s*0.35);
      ctx.lineTo(-s, 0);
      ctx.lineTo(-s*0.35, -s*0.35);
      ctx.closePath();
      ctx.fill();
      ctx.restore();
    }

    // fps (smoothed)
    fpsAcc += dt; fpsFrames++;
    if (fpsAcc >= 500) { fpsEl.textContent = Math.round(1000/(dt||1)); fpsAcc = 0; fpsFrames=0; }

    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
})();
</script>
</body>