Ir al contenido
Evaluar mi proyecto
Componentes · FondosDisponible

Fondos animados para React

Tres fondos animados sin dependencias: puntos que reaccionan, aurora y cuadrícula.

DEMO INTERACTIVA

Pruébalos

Azul
Tu mensaje va aquíMueve el puntero sobre el fondo.
<section style={{ position: "relative", background: "#000" }}>
  <DotGridBackground color="#0b81ff" />
  <div style={{ position: "relative" }}>…</div>
</section>

Código completo

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

animated-backgrounds.tsx237 líneas
Descargar
"use client";

/**
 * Fondos animados para React: tres fondos que no dependen de ninguna biblioteca.
 *
 * Uso: colócalos dentro de un contenedor con `position: relative`. Cada fondo ocupa todo el contenedor,
 * no captura clics y queda detrás del contenido si este tiene `position: relative` y un z-index mayor.
 *
 *   <section style={{ position: "relative", background: "#000", overflow: "hidden" }}>
 *     <AuroraBackground color="#0b81ff" />
 *     <div style={{ position: "relative" }}>Tu contenido</div>
 *   </section>
 *
 * Todos respetan `prefers-reduced-motion`: sin animación se muestra una imagen fija.
 */

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

export type BackgroundProps = {
  /** Color principal en hexadecimal, por ejemplo "#0b81ff". */
  color?: string;
  className?: string;
  style?: CSSProperties;
};

const DEFAULT_COLOR = "#0b81ff";

function hexToRgb(hex: string): [number, number, number] {
  const clean = hex.trim().replace(/^#/, "");
  const full = clean.length === 3 ? clean.split("").map((char) => char + char).join("") : clean;
  const value = Number.parseInt(full, 16);
  if (full.length !== 6 || Number.isNaN(value)) return [11, 129, 255];
  return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}

const BASE_STYLES = `
.gcbg { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.gcbg-aurora i { position: absolute; border-radius: 50%; filter: blur(70px); opacity: .55; will-change: transform; }
.gcbg-aurora i:nth-child(1) { top: -20%; left: -10%; width: 62%; height: 78%; background: radial-gradient(circle, rgb(var(--gcbg-rgb)), transparent 66%); animation: gcbg-drift-a 17s ease-in-out infinite alternate; }
.gcbg-aurora i:nth-child(2) { right: -12%; bottom: -26%; width: 70%; height: 82%; background: radial-gradient(circle, rgba(var(--gcbg-rgb), .75), transparent 64%); animation: gcbg-drift-b 22s ease-in-out infinite alternate; }
.gcbg-aurora i:nth-child(3) { top: 22%; left: 34%; width: 38%; height: 46%; background: radial-gradient(circle, rgba(255, 255, 255, .42), transparent 62%); opacity: .3; animation: gcbg-drift-c 14s ease-in-out infinite alternate; }
.gcbg-grid { background-image: linear-gradient(to right, rgba(var(--gcbg-rgb), .16) 1px, transparent 1px), linear-gradient(to bottom, rgba(var(--gcbg-rgb), .16) 1px, transparent 1px); background-size: 48px 48px; -webkit-mask-image: radial-gradient(ellipse at 50% 45%, #000 28%, transparent 76%); mask-image: radial-gradient(ellipse at 50% 45%, #000 28%, transparent 76%); }
.gcbg-grid::after { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 46%; background: linear-gradient(to bottom, transparent, rgba(var(--gcbg-rgb), .22), transparent); animation: gcbg-scan 7s linear infinite; }
@keyframes gcbg-drift-a { to { transform: translate3d(14%, 18%, 0) scale(1.15); } }
@keyframes gcbg-drift-b { to { transform: translate3d(-16%, -14%, 0) scale(1.1); } }
@keyframes gcbg-drift-c { to { transform: translate3d(-22%, 12%, 0) scale(.85); } }
@keyframes gcbg-scan { from { transform: translateY(-100%); } to { transform: translateY(250%); } }
@media (prefers-reduced-motion: reduce) { .gcbg-aurora i, .gcbg-grid::after { animation: none; } }
`;

function rgbVar(color: string, style?: CSSProperties) {
  return { "--gcbg-rgb": hexToRgb(color).join(", "), ...style } as CSSProperties;
}

/** Manchas de luz que se desplazan despacio. Solo CSS. */
export function AuroraBackground({ color = DEFAULT_COLOR, className, style }: BackgroundProps) {
  return (
    <div className={`gcbg gcbg-aurora${className ? ` ${className}` : ""}`} style={rgbVar(color, style)} aria-hidden="true">
      <style>{BASE_STYLES}</style>
      <i /><i /><i />
    </div>
  );
}

/** Cuadrícula con una línea de luz que la recorre. Solo CSS. */
export function GridPulseBackground({ color = DEFAULT_COLOR, className, style }: BackgroundProps) {
  return (
    <div className={`gcbg gcbg-grid${className ? ` ${className}` : ""}`} style={rgbVar(color, style)} aria-hidden="true">
      <style>{BASE_STYLES}</style>
    </div>
  );
}

type DotGridProps = BackgroundProps & {
  /** Separación entre puntos, en píxeles. */
  gap?: number;
  /** Alcance del puntero, en píxeles. */
  radius?: number;
  /** Ondas suaves que cruzan el fondo cuando nadie lo toca. Se apaga con false. */
  ambient?: boolean;
};

const SPRING = 60;
const DAMPING = 9;
const REPEL = 900;
const RIPPLE_SPEED = 320;
const RIPPLE_WIDTH = 50;
const RIPPLE_LIFE = 2.6;
const RIPPLE_PUSH = 700;

/**
 * Retícula de puntos que reacciona al puntero: los empuja, vibran y vuelven a su lugar
 * encendiéndose en tu color. Escucha el puntero del contenedor y se pausa fuera de pantalla.
 * Si nadie lo toca, una onda lenta cruza el fondo de vez en cuando para que no se vea muerto.
 */
export function DotGridBackground({ color = DEFAULT_COLOR, gap = 28, radius = 150, ambient = true, className, style }: DotGridProps) {
  const ref = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = ref.current;
    const ctx = canvas?.getContext("2d");
    const host = canvas?.parentElement;
    if (!canvas || !ctx || !host) return;

    const [cr, cg, cb] = hexToRgb(color);
    const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let width = 0, height = 0, count = 0;
    let baseX = new Float32Array(0), baseY = new Float32Array(0);
    let offX = new Float32Array(0), offY = new Float32Array(0);
    let velX = new Float32Array(0), velY = new Float32Array(0), glow = new Float32Array(0);
    let pointerX = 0, pointerY = 0, inside = false;
    let raf = 0, running = false, visible = true, previous = 0, ambientTimer = 0, lastMove = -1e9;
    const ripples: { x: number; y: number; age: number }[] = [];

    function build() {
      if (!canvas || !ctx) return;
      const box = canvas.getBoundingClientRect();
      width = box.width; height = box.height;
      const cols = Math.ceil(width / gap) + 1, rows = Math.ceil(height / gap) + 1;
      const startX = (width - (cols - 1) * gap) / 2, startY = (height - (rows - 1) * gap) / 2;
      count = cols * rows;
      baseX = new Float32Array(count); baseY = new Float32Array(count);
      for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) {
        baseX[row * cols + col] = startX + col * gap;
        baseY[row * cols + col] = startY + row * gap;
      }
      offX = new Float32Array(count); offY = new Float32Array(count);
      velX = new Float32Array(count); velY = new Float32Array(count); glow = new Float32Array(count);
      ripples.length = 0;
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio);
      ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
      draw();
    }

    function draw() {
      if (!ctx) return;
      ctx.clearRect(0, 0, width, height);
      ctx.fillStyle = "rgba(255,255,255,0.22)";
      ctx.beginPath();
      for (let i = 0; i < count; i++) if (glow[i] === 0 && offX[i] === 0 && offY[i] === 0) ctx.rect(baseX[i] - 1, baseY[i] - 1, 2, 2);
      ctx.fill();
      for (let i = 0; i < count; i++) {
        if (glow[i] === 0 && offX[i] === 0 && offY[i] === 0) continue;
        const k = Math.min(1, glow[i] * 2);
        ctx.fillStyle = `rgba(${Math.round(255 + (cr - 255) * k)},${Math.round(255 + (cg - 255) * k)},${Math.round(255 + (cb - 255) * k)},${(0.22 + 0.73 * k).toFixed(3)})`;
        ctx.beginPath();
        ctx.arc(baseX[i] + offX[i], baseY[i] + offY[i], 1 + 1.5 * glow[i], 0, Math.PI * 2);
        ctx.fill();
      }
    }

    function frame(time: number) {
      const dt = Math.min(0.033, (time - previous) / 1000);
      previous = time;
      const decay = Math.exp(-2.4 * dt), reach2 = radius * radius;
      for (let r = ripples.length - 1; r >= 0; r--) { ripples[r].age += dt; if (ripples[r].age > RIPPLE_LIFE) ripples.splice(r, 1); }
      let alive = inside || ripples.length > 0;
      for (let i = 0; i < count; i++) {
        let ax = -SPRING * offX[i] - DAMPING * velX[i], ay = -SPRING * offY[i] - DAMPING * velY[i];
        if (inside) {
          const dx = baseX[i] - pointerX, dy = baseY[i] - pointerY, d2 = dx * dx + dy * dy;
          if (d2 < reach2 && d2 > 1) {
            const d = Math.sqrt(d2), force = (1 - d / radius) ** 2;
            ax += (REPEL * force * dx) / d; ay += (REPEL * force * dy) / d;
            if (force > glow[i]) glow[i] = force;
          }
        }
        for (const ripple of ripples) {
          const dx = baseX[i] - ripple.x, dy = baseY[i] - ripple.y, d = Math.sqrt(dx * dx + dy * dy) || 1;
          const off = Math.abs(d - RIPPLE_SPEED * ripple.age);
          if (off < RIPPLE_WIDTH) {
            const force = (1 - off / RIPPLE_WIDTH) ** 2 * (1 - ripple.age / RIPPLE_LIFE);
            ax += (RIPPLE_PUSH * force * dx) / d; ay += (RIPPLE_PUSH * force * dy) / d;
            if (force * 0.7 > glow[i]) glow[i] = force * 0.7;
          }
        }
        velX[i] += ax * dt; velY[i] += ay * dt;
        offX[i] += velX[i] * dt; offY[i] += velY[i] * dt;
        glow[i] = glow[i] * decay < 0.015 ? 0 : glow[i] * decay;
        const settled = Math.abs(offX[i]) < 0.03 && Math.abs(offY[i]) < 0.03 && Math.abs(velX[i]) < 0.05 && Math.abs(velY[i]) < 0.05;
        if (settled) { offX[i] = offY[i] = velX[i] = velY[i] = 0; } else alive = true;
        if (glow[i] > 0) alive = true;
      }
      draw();
      if (alive && visible) raf = requestAnimationFrame(frame); else running = false;
    }

    function wake() {
      if (running || !visible) return;
      running = true;
      previous = performance.now();
      raf = requestAnimationFrame(frame);
    }

    function onMove(event: PointerEvent) {
      if (event.pointerType === "touch" || !canvas) return;
      const box = canvas.getBoundingClientRect();
      pointerX = event.clientX - box.left; pointerY = event.clientY - box.top;
      inside = true;
      lastMove = performance.now();
      wake();
    }
    function onLeave() { inside = false; wake(); }

    function startAmbient() {
      if (visible && !document.hidden && performance.now() - lastMove > 2500 && count && ripples.length < 2) {
        ripples.push({ x: Math.random() * width, y: Math.random() * height, age: 0 });
        wake();
      }
      ambientTimer = window.setTimeout(startAmbient, 2200 + Math.random() * 1600);
    }

    const resize = new ResizeObserver(build);
    resize.observe(canvas);
    build();
    if (still) return () => resize.disconnect();

    const intersect = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible) { draw(); wake(); } });
    intersect.observe(host);
    host.addEventListener("pointermove", onMove);
    host.addEventListener("pointerleave", onLeave);
    if (ambient) ambientTimer = window.setTimeout(startAmbient, 700);

    return () => {
      clearTimeout(ambientTimer);
      host.removeEventListener("pointermove", onMove);
      host.removeEventListener("pointerleave", onLeave);
      resize.disconnect();
      intersect.disconnect();
      cancelAnimationFrame(raf);
    };
  }, [color, gap, radius, ambient]);

  return <canvas ref={ref} className={`gcbg${className ? ` ${className}` : ""}`} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none", ...style }} aria-hidden="true" />;
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

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

Ejemplo de uso de Fondos animados para React

Después de copiar animated-backgrounds.tsx a tu proyecto, impórtalo y colócalo donde lo necesites. Exporta 3 componentes: AuroraBackground, GridPulseBackground, DotGridBackground.

ejemplo.tsx5 líneas
import { AuroraBackground } from "./animated-backgrounds";

export default function Ejemplo() {
  return <AuroraBackground color="Ejemplo" />;
}

Propiedades de AuroraBackground

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

PropiedadTipoDescripción
color?stringColor principal en hexadecimal, por ejemplo "#0b81ff".
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 237 líneas (11 KB) y define 4 animaciones con @keyframes. Esto es lo que hace Fondos animados para React por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
  • 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 · Next.js (quita la directiva use client fuera de Next.js) · Sin Tailwind

Acerca del producto

Un solo archivo React + TypeScript con tres fondos para secciones de inicio y páginas de servicio. Los estilos van dentro del mismo archivo, así que se copia y funciona. No requiere bibliotecas ni Tailwind, se pausa fuera de pantalla y respeta la preferencia de reducir movimiento.

Incluye

Tres componentes exportados desde el mismo archivo. Se colocan dentro de un contenedor con position: relative.

  • DotGridBackground: retícula de puntos que reacciona al puntero (canvas)
  • AuroraBackground: manchas de luz en movimiento (solo CSS)
  • GridPulseBackground: cuadrícula con una línea de luz (solo CSS)
  • Color configurable con un valor hexadecimal
  • Los estilos (CSS) y las animaciones van dentro del archivo: no hay hojas de estilo aparte

Requisitos

Solo React 18 o superior. El archivo empieza con la directiva "use client" de Next.js; en otros entornos puedes quitar esa línea.