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.