Sección fijada con pasos que cambian
El escenario se queda quieto mientras el scroll cambia el texto y la forma de cada paso, con indicador lateral. Autocontenida en su panel.
Entendemos tu operación
Antes de escribir una línea de código, miramos cómo trabajas hoy.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
/** Sección fijada al hacer scroll: el escenario se queda quieto mientras el desplazamiento cambia el texto y la ilustración de cada paso. Autocontenida en su panel de desplazamiento; en uso real, haz que el panel sea la página. React + TypeScript, sin dependencias. */
export type ScrollFijoPaso = { title: string; text: string };
export type ScrollFijoProps = {
steps?: ScrollFijoPaso[];
/** Alto del panel de desplazamiento en px. */
height?: number;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcsfj-root { box-sizing: border-box; position: relative; width: 100%; height: var(--h, 460px); overflow-y: auto; overflow-x: hidden; background: #0b0d12; color: #eef0f6; font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; scrollbar-width: thin; scrollbar-color: #2a2f3d transparent; outline: 0; }
.gcsfj-root *, .gcsfj-root *::before, .gcsfj-root *::after { box-sizing: border-box; }
.gcsfj-root:focus-visible { box-shadow: inset 0 0 0 2px #8fb4ff; }
@media (prefers-reduced-motion: reduce) { .gcsfj-root * { transition-duration: .01ms !important; } }
.gcsfj-track { position: relative; height: calc(var(--h, 460px) * var(--n)); }
.gcsfj-stage { position: sticky; top: 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: 24px; height: var(--h, 460px); padding: 0 clamp(18px, 5vw, 56px); overflow: hidden; }
.gcsfj-copy { position: relative; height: 180px; }
.gcsfj-step { position: absolute; inset: 0; display: grid; align-content: center; gap: 10px; opacity: 0; transform: translateY(24px); filter: blur(6px); transition: opacity .5s cubic-bezier(.16, 1, .3, 1), transform .6s cubic-bezier(.16, 1, .3, 1), filter .5s; pointer-events: none; }
.gcsfj-step[data-on="true"] { opacity: 1; transform: none; filter: none; pointer-events: auto; }
.gcsfj-step[data-past="true"] { transform: translateY(-24px); }
.gcsfj-step h3 { margin: 0; font: 700 clamp(26px, 4vw, 44px)/1.05 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.04em; text-wrap: balance; }
.gcsfj-step p { margin: 0; max-width: 34ch; color: #a3abc0; }
.gcsfj-art { position: relative; aspect-ratio: 1; max-height: calc(var(--h, 460px) - 90px); margin: 0 auto; width: 100%; max-width: 320px; }
.gcsfj-shape { position: absolute; inset: 14%; background: hsl(var(--hue) 85% 62%); border-radius: var(--r); transform: rotate(var(--rot)) scale(var(--s)); transition: background .6s, border-radius .8s cubic-bezier(.16, 1, .3, 1), transform .8s cubic-bezier(.16, 1, .3, 1); box-shadow: 0 30px 60px -20px hsl(var(--hue) 85% 40% / .6); }
.gcsfj-shape.two { inset: 30%; background: hsl(calc(var(--hue) + 50) 90% 80%); mix-blend-mode: screen; transform: rotate(calc(var(--rot) * -1)) scale(calc(var(--s) * 1.1)); }
.gcsfj-dots { position: absolute; right: 18px; top: 50%; display: grid; gap: 10px; transform: translateY(-50%); }
.gcsfj-dots i { width: 8px; height: 8px; border-radius: 50%; background: #2a2f3d; transition: background .3s, transform .3s; }
.gcsfj-dots i[data-on="true"] { background: #8fb4ff; transform: scale(1.4); }
.gcsfj-hint { position: absolute; left: clamp(18px, 5vw, 56px); bottom: 14px; font-size: 12px; color: #6e7690; }
@media (max-width: 560px) { .gcsfj-stage { grid-template-columns: 1fr; align-content: center; } .gcsfj-art { max-width: 160px; } .gcsfj-copy { height: 160px; } }`;
const DEFAULT: ScrollFijoPaso[] = [
{ title: "Entendemos tu operación", text: "Antes de escribir una línea de código, miramos cómo trabajas hoy." },
{ title: "Diseñamos el flujo", text: "Dibujamos el proceso ideal y lo validamos contigo en papel." },
{ title: "Construimos por etapas", text: "Entregas cortas que puedes probar y corregir sobre la marcha." },
{ title: "Lanzamos y acompañamos", text: "Puesta en producción con capacitación y soporte continuo." },
];
const LOOKS = [
{ hue: 220, r: "50%", rot: "0deg", s: 1 },
{ hue: 160, r: "18%", rot: "12deg", s: 1.05 },
{ hue: 30, r: "34% 66% 40% 60% / 50% 40% 60% 50%", rot: "-18deg", s: 0.96 },
{ hue: 320, r: "8%", rot: "45deg", s: 0.9 },
];
export function ScrollFijo({ steps = DEFAULT, height = 460, className = "", style }: ScrollFijoProps) {
const ref = useRef<HTMLDivElement>(null);
const [i, setI] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
let raf = 0;
const update = () => {
raf = 0;
const max = el.scrollHeight - el.clientHeight;
const p = max > 0 ? el.scrollTop / max : 0;
setI(Math.min(steps.length - 1, Math.floor(p * steps.length * 0.9999)));
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
update();
el.addEventListener("scroll", onScroll, { passive: true });
return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(raf); };
}, [steps.length]);
const look = LOOKS[i % LOOKS.length];
return (
<div ref={ref} className={`gcsfj-root ${className}`} style={{ "--h": `${height}px`, "--n": steps.length, ...style } as CSSProperties} tabIndex={0} role="region" aria-label="Sección fijada: desplázate para cambiar de paso">
<style>{CSS}</style>
<div className="gcsfj-track">
<div className="gcsfj-stage">
<div className="gcsfj-copy">
{steps.map((step, n) => (
<div key={step.title} className="gcsfj-step" data-on={n === i} data-past={n < i} aria-hidden={n !== i}>
<h3>{step.title}</h3>
<p>{step.text}</p>
</div>
))}
</div>
<div className="gcsfj-art" aria-hidden="true" style={{ "--hue": look.hue, "--r": look.r, "--rot": look.rot, "--s": look.s } as CSSProperties}>
<div className="gcsfj-shape" />
<div className="gcsfj-shape two" />
</div>
<div className="gcsfj-dots" aria-hidden="true">
{steps.map((_, n) => (
<i key={n} data-on={n === i} />
))}
</div>
<span className="gcsfj-hint">Desplázate ↓ Paso {i + 1} de {steps.length}</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-fijoEjemplo de uso de Sección fijada con pasos que cambian
Después de copiar scroll-fijo.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { ScrollFijo } from "./scroll-fijo";
export default function Ejemplo() {
return <ScrollFijo />;
}Propiedades de ScrollFijo
ScrollFijo acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
steps? | ScrollFijoPaso[] | Opcional. |
height? | number | Alto del panel de desplazamiento en px. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Sección fijada con pasos que cambian 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 |
Accesibilidad y rendimiento
El archivo tiene 100 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Sección fijada con pasos que cambian 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".