Ir al contenido
Evaluar mi proyecto
Componentes · ScrollDisponible

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.

Desplázate ↓ Paso 1 de 4

Código completo

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

scroll-fijo.tsx100 líneas
Descargar
"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.

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

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
steps?ScrollFijoPaso[]Opcional.
height?numberAlto del panel de desplazamiento en px.
className?stringOpcional.
style?CSSPropertiesOpcional.

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.

VariableValor por defecto
--h460px

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)