Así trabajamos

1 de 5
01

Descubrir

Escuchamos antes de dibujar la primera línea.

02

Bocetar

Cien ideas en papel para quedarnos con tres.

03

Prototipar

Algo que se pueda tocar antes del viernes.

04

Pulir

Los últimos milímetros son los que se notan.

05

Lanzar

Publicar, medir y volver a empezar.

React + MotionTexto y scroll

Pasos en horizontal

La sección se queda fija y el scroll vertical desliza en horizontal palabras gigantes, una por paso, con contador y barra.

Ajustes

Código

import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useMotionValueEvent, useScroll, useSpring, useTransform } from "framer-motion";

interface PinnedHorizontalScrollProps {
  title?: string;
  accent?: string;
  /** Suaviza el desplazamiento con un muelle. */
  smooth?: boolean;
  /** true: caja con scroll propio. false: se fija en la página mientras haces scroll. */
  contained?: boolean;
}

const STEPS = [
  { title: "Descubrir", text: "Escuchamos antes de dibujar la primera línea." },
  { title: "Bocetar", text: "Cien ideas en papel para quedarnos con tres." },
  { title: "Prototipar", text: "Algo que se pueda tocar antes del viernes." },
  { title: "Pulir", text: "Los últimos milímetros son los que se notan." },
  { title: "Lanzar", text: "Publicar, medir y volver a empezar." },
];

export default function PinnedHorizontalScroll({
  title = "Así trabajamos",
  accent = "#6e56ff",
  smooth = true,
  contained = true,
}: PinnedHorizontalScrollProps) {
  const scroller = useRef<HTMLDivElement>(null);
  const target = useRef<HTMLDivElement>(null);
  const frame = useRef<HTMLDivElement>(null);
  const track = useRef<HTMLDivElement>(null);
  const items = useRef<(HTMLDivElement | null)[]>([]);
  const stops = useRef<number[]>([]);
  const distance = useMotionValue(0);
  const [active, setActive] = useState(0);
  const { scrollYProgress } = useScroll(
    contained
      ? { container: scroller, target, offset: ["start start", "end end"] }
      : { target, offset: ["start start", "end end"] },
  );
  const springy = useSpring(scrollYProgress, { stiffness: 160, damping: 30, restDelta: 0.0005 });
  const progress = smooth ? springy : scrollYProgress;
  const x = useTransform([progress, distance], ([p = 0, d = 0]: number[]) => {
    const s = stops.current;
    if (s.length < 2) return -p * d;
    const t = Math.min(s.length - 1, Math.max(0, p * (s.length - 1)));
    const i = Math.min(s.length - 2, Math.floor(t));
    const a = s[i] ?? 0;
    const b = s[i + 1] ?? a;
    const f = t - i;
    return -Math.min(d, a + (b - a) * f * f * (3 - 2 * f));
  });

  useMotionValueEvent(scrollYProgress, "change", (p) =>
    setActive(Math.min(STEPS.length - 1, Math.round(p * (STEPS.length - 1)))),
  );

  useEffect(() => {
    const measure = () => {
      if (!frame.current || !track.current) return;
      const first = items.current[0]?.offsetLeft ?? 0;
      stops.current = items.current.map((el) => (el ? el.offsetLeft - first : 0));
      distance.set(Math.max(0, track.current.scrollWidth - frame.current.clientWidth));
    };
    measure();
    const ro = new ResizeObserver(measure);
    if (frame.current) ro.observe(frame.current);
    if (track.current) ro.observe(track.current);
    return () => ro.disconnect();
  }, [distance]);

  const section = (
    <div ref={target} className={contained ? "relative h-[400%]" : "relative h-[400vh]"}>
      <div
        className={
          (contained ? "h-[25%]" : "h-screen") +
          " sticky top-0 flex flex-col justify-between overflow-hidden py-7 [container-type:inline-size]"
        }
      >
        <div className="flex items-baseline justify-between gap-4 px-7">
          <h3 className="text-[15px] font-semibold tracking-[-0.01em] text-[#1d1d1f] dark:text-[#f5f5f7]">{title}</h3>
          <span className="shrink-0 text-[13px] tabular-nums text-[#86868b]">
            {active + 1} de {STEPS.length}
          </span>
        </div>
        <div ref={frame} className="w-full">
          <motion.div ref={track} style={{ x }} className="flex w-max items-end gap-[7cqi] px-7">
            {STEPS.map((s, i) => (
              <motion.div
                key={s.title}
                ref={(el) => {
                  items.current[i] = el;
                }}
                animate={{ opacity: i === active ? 1 : 0.16 }}
                transition={{ duration: 0.45, ease: [0.25, 0.1, 0.25, 1] }}
                className="shrink-0"
              >
                <span className="block text-[13px] font-semibold tabular-nums" style={{ color: accent }}>
                  {String(i + 1).padStart(2, "0")}
                </span>
                <h4 className="mt-1 text-[clamp(56px,15cqi,132px)] font-semibold leading-[0.92] tracking-[-0.055em] text-[#1d1d1f] dark:text-[#f5f5f7]">
                  {s.title}
                </h4>
                <p className="mt-4 max-w-[24ch] text-[15px] leading-snug text-[#6e6e73] dark:text-[#a1a1a6]">{s.text}</p>
              </motion.div>
            ))}
          </motion.div>
        </div>
        <div className="mx-7 h-[2px] overflow-hidden rounded-full bg-black/[0.06] dark:bg-white/10">
          <motion.div style={{ scaleX: progress, background: accent }} className="h-full origin-left" />
        </div>
      </div>
    </div>
  );

  if (!contained) return <div className="bg-[#f5f5f7] dark:bg-black">{section}</div>;
  return (
    <div
      ref={scroller}
      className="relative h-[26rem] w-full overflow-y-auto rounded-3xl bg-[#f5f5f7] [scrollbar-width:none] dark:bg-black"
    >
      {section}
    </div>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Texto y scroll

Dónde usarlo

Una sección con scroll horizontal cuenta muy bien un proceso en orden: cómo trabaja una agencia de la primera llamada al lanzamiento, las fases de una reforma, los pasos de un onboarding. La sección se fija, el scroll mueve los pasos de lado y la palabra activa se enciende mientras el resto espera en sombra.

Cuatro a seis pasos con títulos de una palabra es lo ideal; las palabras largas alargan demasiado el recorrido. Deja el suavizado con muelle para trackpad y quítalo si con rueda de ratón se nota flotante. Dale a la sección bastante altura en la página.