Escena con paralaje de montañas
Sol, tres capas de montañas y un titular se mueven a velocidades distintas mientras la niebla se despeja.
Sube la montaña
Cada capa se mueve a su propio ritmo y crea profundidad sin una sola imagen pesada.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
/** Escena con paralaje: sol, tres capas de montañas y un titular se mueven a velocidades distintas mientras el contenido sube, con niebla que se despeja. Autocontenida en su panel. Capas SVG propias. React + TypeScript, sin dependencias. */
export type ScrollParalajeProps = {
title?: string;
text?: string;
height?: number;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcspl-root { box-sizing: border-box; position: relative; width: 100%; height: var(--h, 460px); overflow-y: auto; overflow-x: hidden; background: #0f1a33; color: #fff; font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; scrollbar-width: thin; outline: 0; }
.gcspl-root *, .gcspl-root *::before, .gcspl-root *::after { box-sizing: border-box; }
.gcspl-root:focus-visible { box-shadow: inset 0 0 0 2px #fff; }
.gcspl-track { position: relative; height: calc(var(--h, 460px) * 2.1); }
.gcspl-stage { position: sticky; top: 0; height: var(--h, 460px); overflow: hidden; background: linear-gradient(180deg, #1b2a5e 0%, #7a4b9c 45%, #ff9a6b 78%, #ffd6a0 100%); }
.gcspl-l { position: absolute; left: -4%; right: -4%; bottom: 0; will-change: transform; transform: translateY(calc(var(--p, 0) * var(--f) * 1px)); }
.gcspl-l svg { display: block; width: 100%; height: auto; }
.gcspl-sun { position: absolute; left: 50%; bottom: 40%; width: 22%; aspect-ratio: 1; margin-left: -11%; border-radius: 50%; background: #fff2c2; box-shadow: 0 0 80px 30px rgba(255, 220, 160, .55); transform: translateY(calc(var(--p, 0) * 90px)); }
.gcspl-copy { position: absolute; left: 0; right: 0; top: 14%; display: grid; justify-items: center; gap: 10px; padding: 0 20px; text-align: center; transform: translateY(calc(var(--p, 0) * -180px)); opacity: calc(1 - var(--p, 0) * 1.4); }
.gcspl-copy h2 { margin: 0; font: 700 clamp(30px, 7vw, 68px)/.98 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.045em; text-shadow: 0 4px 30px rgba(20, 10, 60, .4); }
.gcspl-copy p { margin: 0; max-width: 36ch; opacity: .92; }
.gcspl-fog { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 50%, rgba(255, 233, 210, .85)); opacity: calc(1 - var(--p, 0) * 1.2); pointer-events: none; }
.gcspl-hint { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); font-size: 12px; opacity: .8; }
@media (prefers-reduced-motion: reduce) { .gcspl-l, .gcspl-sun, .gcspl-copy { transform: none !important; } }`;
export function ScrollParalaje({ title = "Sube la montaña", text = "Cada capa se mueve a su propio ritmo y crea profundidad sin una sola imagen pesada.", height = 460, className = "", style }: ScrollParalajeProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
let raf = 0;
const update = () => {
raf = 0;
const max = el.scrollHeight - el.clientHeight;
el.style.setProperty("--p", String(max > 0 ? el.scrollTop / max : 0));
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
update();
el.addEventListener("scroll", onScroll, { passive: true });
return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(raf); };
}, []);
return (
<div ref={ref} className={`gcspl-root ${className}`} style={{ "--h": `${height}px`, ...style } as CSSProperties} tabIndex={0} role="region" aria-label="Escena con paralaje: desplázate">
<style>{CSS}</style>
<div className="gcspl-track">
<div className="gcspl-stage">
<div className="gcspl-sun" aria-hidden="true" />
<div className="gcspl-l" style={{ "--f": 40 } as CSSProperties} aria-hidden="true">
<svg viewBox="0 0 1200 300" preserveAspectRatio="none"><path d="M0 300V190l120-60 130 70 140-90 150 80 130-60 160 90 150-70 120 50v190z" fill="#5a3b84" opacity=".7" /></svg>
</div>
<div className="gcspl-l" style={{ "--f": 90 } as CSSProperties} aria-hidden="true">
<svg viewBox="0 0 1200 300" preserveAspectRatio="none"><path d="M0 300V210l100-50 150 80 120-100 160 120 120-70 150 90 140-80 160 70v40z" fill="#3a2a66" opacity=".88" /></svg>
</div>
<div className="gcspl-l" style={{ "--f": 160 } as CSSProperties} aria-hidden="true">
<svg viewBox="0 0 1200 300" preserveAspectRatio="none"><path d="M0 300V230l140-70 130 90 160-110 150 100 130-60 170 90 150-40 170 60v40z" fill="#1d1640" /></svg>
</div>
<div className="gcspl-fog" aria-hidden="true" />
<div className="gcspl-copy">
<h2>{title}</h2>
<p>{text}</p>
</div>
<span className="gcspl-hint">Desplázate ↓</span>
</div>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/scroll-paralajeEjemplo de uso de Escena con paralaje de montañas
Después de copiar scroll-paralaje.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { ScrollParalaje } from "./scroll-paralaje";
export default function Ejemplo() {
return <ScrollParalaje title="Ejemplo" />;
}Propiedades de ScrollParalaje
ScrollParalaje acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
title? | string | Opcional. |
text? | string | Opcional. |
height? | number | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Escena con paralaje de montañas con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--h | 460px |
--p | 0 |
Accesibilidad y rendimiento
El archivo tiene 74 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Escena con paralaje de montañas 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).
- Declara roles ARIA para lectores de pantalla: region.
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Es un componente de cliente de Next.js: incluye la directiva "use client".