Étapes à l’horizontale
La section se fige et le défilement vertical fait glisser de grands mots à l’horizontale, un par étape, avec compteur et barre.
Réglages
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: "Découvrir", text: "Nous écoutons avant de tracer le premier trait." },
{ title: "Esquisser", text: "Cent idées sur papier pour n’en garder que trois." },
{ title: "Prototyper", text: "Quelque chose de concret avant vendredi." },
{ title: "Peaufiner", text: "Ce sont les derniers millimètres qui font la différence." },
{ title: "Lancer", text: "Publier, mesurer, recommencer." },
];
export default function PinnedHorizontalScroll({
title = "Notre façon de travailler",
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 pour l’IA
Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.
Plus dans Texte et défilement
Où l’utiliser
Une section à défilement horizontal raconte bien un processus dans l’ordre : la méthode d’une agence du premier appel au lancement, les phases d’une rénovation, les étapes d’un onboarding. La section reste fixe, le scroll fait glisser les étapes sur le côté, et le mot actif s’allume pendant que les autres attendent dans l’ombre.
Quatre à six étapes avec des titres d’un seul mot, c’est l’idéal ; les mots longs rallongent trop le parcours. Gardez le lissage à ressort pour les pavés tactiles et coupez-le s’il paraît mou à la molette. Prévoyez une bonne hauteur pour la section.