Fondos animados para React
Tres fondos animados sin dependencias: puntos que reaccionan, aurora y cuadrícula.
Pruébalos
<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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/fondos-animadosEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
color? | string | Color principal en hexadecimal, por ejemplo "#0b81ff". |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".
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.