Ir al contenido
Evaluar mi proyecto
Componentes · FondosDisponible

Fondo de campo de estrellas

Viaje a velocidad de salto en canvas con estelas que se aceleran al entrar el puntero; pausa fuera de pantalla.

Rumbo al infinito

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

fondo-estrellas.tsx122 líneas
Descargar
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";

/** Fondo de campo de estrellas en canvas: el viaje a velocidad de salto con estelas, que se aceleran con el puntero. Pausa fuera de pantalla y con botón. React + TypeScript, sin dependencias. */
export type FondoEstrellasProps = {
  count?: number;
  speed?: number;
  children?: ReactNode;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcfes-root { box-sizing: border-box; position: relative; isolation: isolate; container-type: inline-size; width: 100%; min-height: 480px; overflow: hidden; display: grid; place-items: center; background: radial-gradient(120% 100% at 50% 50%, #0b1030, #02030a 70%); color: #eef1ff; font: 500 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcfes-root *, .gcfes-root *::before, .gcfes-root *::after { box-sizing: border-box; }
.gcfes-root :focus-visible { outline: 2px solid #9db4ff; outline-offset: 3px; }
.gcfes-canvas { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; display: block; }
.gcfes-pause { position: absolute; right: 14px; bottom: 14px; z-index: 3; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border: 1px solid #2b3568; border-radius: 999px; background: rgba(5, 8, 25, .75); color: #d3dcff; font: 600 12px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
.gcfes-pause:hover { border-color: #5a6dc0; }`;

type S = { x: number; y: number; z: number; pz: number };

export function FondoEstrellas({ count = 420, speed = 1, children, className = "", style }: FondoEstrellasProps) {
  const root = useRef<HTMLElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const [paused, setPaused] = useState(false);
  const pausedRef = useRef(false);
  useEffect(() => {
    pausedRef.current = paused;
  }, [paused]);
  const startRef = useRef<() => void>(() => {});

  useEffect(() => {
    const wrap = root.current;
    const cv = canvas.current;
    const ctx = cv?.getContext("2d");
    if (!wrap || !cv || !ctx) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let w = 0, h = 0, dpr = 1, raf = 0, visible = true, boost = 0, targetBoost = 0;
    const stars: S[] = Array.from({ length: count }, () => ({ x: (Math.random() - 0.5) * 2, y: (Math.random() - 0.5) * 2, z: Math.random(), pz: 0 }));
    stars.forEach((s) => (s.pz = s.z));

    function size() {
      const r = wrap!.getBoundingClientRect();
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = r.width; h = r.height;
      cv!.width = Math.round(w * dpr); cv!.height = Math.round(h * dpr);
    }
    function frame() {
      if (!ctx) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.fillStyle = "rgba(3, 5, 16, .35)";
      ctx.fillRect(0, 0, w, h);
      boost += (targetBoost - boost) * 0.06;
      const v = (reduced ? 0 : 0.006 + boost * 0.03) * speed;
      const cx = w / 2, cy = h / 2, scale = Math.max(w, h) * 0.55;
      for (const s of stars) {
        s.pz = s.z;
        s.z -= v;
        if (s.z <= 0.02) { s.x = (Math.random() - 0.5) * 2; s.y = (Math.random() - 0.5) * 2; s.z = 1; s.pz = 1; }
        const sx = cx + (s.x / s.z) * scale * 0.12, sy = cy + (s.y / s.z) * scale * 0.12;
        const px = cx + (s.x / s.pz) * scale * 0.12, py = cy + (s.y / s.pz) * scale * 0.12;
        const a = Math.min(1, (1 - s.z) * 1.4);
        ctx.strokeStyle = `rgba(${200 + (1 - s.z) * 55}, ${215 + (1 - s.z) * 40}, 255, ${a})`;
        ctx.lineWidth = 0.6 + (1 - s.z) * 1.6;
        ctx.beginPath();
        ctx.moveTo(px, py);
        ctx.lineTo(sx + (reduced ? 0.5 : 0), sy);
        ctx.stroke();
      }
      raf = !reduced && visible && !pausedRef.current && !document.hidden ? requestAnimationFrame(frame) : 0;
    }
    const start = () => { if (!raf) raf = requestAnimationFrame(frame); };
    startRef.current = start;
    size();
    ctx.fillStyle = "#03050f";
    ctx.fillRect(0, 0, cv.width, cv.height);
    for (let i = 0; i < 40; i++) frame();
    start();
    const ro = new ResizeObserver(() => { size(); start(); });
    ro.observe(wrap);
    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) start(); });
    io.observe(wrap);
    const enter = () => (targetBoost = 1);
    const leave = () => (targetBoost = 0);
    const vis = () => !document.hidden && start();
    wrap.addEventListener("pointerenter", enter);
    wrap.addEventListener("pointerleave", leave);
    document.addEventListener("visibilitychange", vis);
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect(); io.disconnect();
      wrap.removeEventListener("pointerenter", enter);
      wrap.removeEventListener("pointerleave", leave);
      document.removeEventListener("visibilitychange", vis);
    };
  }, [count, speed]);

  return (
    <section ref={root} className={`gcfes-root ${className}`} style={style}>
      <style>{CSS}</style>
      <canvas ref={canvas} className="gcfes-canvas" aria-hidden="true" />
      {children}
      <button
        className="gcfes-pause"
        type="button"
        aria-pressed={paused}
        onClick={() => {
          setPaused((value) => {
            if (value) setTimeout(() => startRef.current(), 0);
            return !value;
          });
        }}
      >
        <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">{paused ? <path d="M7 4v16l13-8z" /> : <path d="M6 4h4v16H6zM14 4h4v16h-4z" />}</svg>
        {paused ? "Reanudar" : "Pausar"} fondo
      </button>
    </section>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/fondo-estrellas

Ejemplo de uso de Fondo de campo de estrellas

Después de copiar fondo-estrellas.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { FondoEstrellas } from "./fondo-estrellas";

export default function Ejemplo() {
  return <FondoEstrellas>Contenido</FondoEstrellas>;
}

Propiedades de FondoEstrellas

FondoEstrellas acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
count?numberOpcional.
speed?numberOpcional.
children?ReactNodeOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 122 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Fondo de campo de estrellas por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • Dibuja en <canvas>, sin imágenes ni librerías gráficas.
  • Se pausa cuando sale de la pantalla para no gastar batería ni CPU.
  • Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)