How we work

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 + MotionText & scroll

Horizontal scroll steps

The section pins in place and vertical scrolling slides giant words sideways, one per step, with a counter and progress bar.

Settings

Code

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: "Explore", text: "We listen before we draw the first line." },
  { title: "Sketch", text: "A hundred ideas on paper to land on three." },
  { title: "Prototype", text: "Something you can touch before Friday." },
  { title: "Polish", text: "The last few millimeters are the ones you notice." },
  { title: "Launch", text: "Ship, measure, and start again." },
];

export default function PinnedHorizontalScroll({
  title = "How we work",
  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 for AI

Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.

More in Text & scroll

Where to use it

A horizontal scroll section is a good way to tell a process in order: how an agency works from first call to launch, the stages of a renovation, the steps of an onboarding. The section pins, the page scroll moves the steps sideways, and the active word lights up while the rest wait in shadow.

Four to six steps with one‑word titles feel right; longer words push the track too far. Leave the spring smoothing on for trackpads and turn it off if it feels floaty with a mouse wheel. Give the section plenty of height on the page.