Schritte im Querformat
Der Abschnitt bleibt stehen, und vertikales Scrollen schiebt riesige Wörter seitwärts, eines pro Schritt, mit Zähler und Fortschrittsbalken.
Einstellungen
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: "Entdecken", text: "Wir hören zu, bevor wir die erste Linie ziehen." },
{ title: "Skizzieren", text: "Hundert Ideen auf Papier, drei bleiben übrig." },
{ title: "Prototypen", text: "Etwas zum Anfassen – noch vor Freitag." },
{ title: "Feinschliff", text: "Die letzten Millimeter sind die, die man spürt." },
{ title: "Launchen", text: "Veröffentlichen, messen, neu anfangen." },
];
export default function PinnedHorizontalScroll({
title = "So arbeiten wir",
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 für KI
Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.
Mehr aus Text & Scroll
Wofür du ihn nutzen kannst
Ein Abschnitt mit horizontalem Scrollen erzählt einen Ablauf schön der Reihe nach: wie eine Agentur vom ersten Gespräch bis zum Launch arbeitet, die Phasen einer Renovierung, die Schritte eines Onboardings. Der Abschnitt bleibt fixiert, das Scrollen schiebt die Schritte zur Seite, und das aktive Wort leuchtet, während der Rest im Schatten wartet.
Vier bis sechs Schritte mit Titeln aus einem Wort passen gut, lange Wörter machen den Weg zu weit. Lass die Federglättung fürs Trackpad an und schalte sie aus, wenn es sich mit dem Mausrad schwammig anfühlt. Gib dem Abschnitt genug Höhe.