HandgemachtOhne VorlagenStartklar
HandgemachtOhne VorlagenStartklar
HandgemachtOhne VorlagenStartklar
HandgemachtOhne VorlagenStartklar
React + MotionText & Scroll

Laufband mit Scroll-Tempo

Vier Zeilen riesiger Text laufen gegeneinander, werden mit deinem Scrollen schneller und schräger und drehen beim Hochscrollen um.

Einstellungen

3%/s

Code

import { Fragment, useRef } from "react";
import {
  motion,
  useAnimationFrame,
  useMotionValue,
  useReducedMotion,
  useScroll,
  useSpring,
  useTransform,
  useVelocity,
  type MotionValue,
} from "framer-motion";

interface ScrollVelocityMarqueeProps {
  /** Frases separadas por "·". */
  text?: string;
  accent?: string;
  /** Velocidad base, en % de la cinta por segundo. */
  speed?: number;
  /** true: caja con scroll propio. false: reacciona al scroll de la página. */
  contained?: boolean;
}

type RowStyle = "solid" | "band" | "ghost";
const ROWS: { look: RowStyle; direction: 1 | -1; start: number }[] = [
  { look: "ghost", direction: 1, start: -9 },
  { look: "solid", direction: -1, start: -6 },
  { look: "band", direction: 1, start: -14 },
  { look: "ghost", direction: -1, start: -5 },
];

const wrap = (min: number, max: number, v: number) => {
  const r = max - min;
  return ((((v - min) % r) + r) % r) + min;
};

function Row({
  items,
  speed,
  direction,
  factor,
  skew,
  look,
  start,
  accent,
}: {
  items: string[];
  speed: number;
  direction: 1 | -1;
  start: number;
  factor: MotionValue<number>;
  skew: MotionValue<number>;
  look: RowStyle;
  accent: string;
}) {
  const baseX = useMotionValue(start);
  const dir = useRef<number>(direction);
  const reduce = useReducedMotion();
  const x = useTransform(baseX, (v) => wrap(-25, 0, v) + "%");

  useAnimationFrame((_, delta) => {
    if (reduce) return;
    const f = factor.get();
    if (f < 0) dir.current = -direction;
    else if (f > 0) dir.current = direction;
    baseX.set(baseX.get() + dir.current * speed * (delta / 1000) * (1 + Math.abs(f)));
  });

  const text =
    look === "band"
      ? "text-[#0b0b0c]"
      : look === "ghost"
        ? "text-[#1d1d1f]/[0.13] dark:text-[#f5f5f7]/[0.16]"
        : "text-[#1d1d1f] dark:text-[#f5f5f7]";
  const dot = look === "band" ? "bg-[#0b0b0c]" : "bg-current opacity-30";

  return (
    <div
      className={"overflow-hidden whitespace-nowrap " + (look === "band" ? "py-[0.08em]" : "")}
      style={look === "band" ? { background: accent } : undefined}
    >
      <motion.div className="flex w-max" style={{ x, skewX: skew }}>
        {Array.from({ length: 4 }, (_, copy) => (
          <span
            key={copy}
            aria-hidden={copy > 0 ? true : undefined}
            className={
              "flex shrink-0 items-center text-[clamp(44px,17cqi,140px)] font-semibold leading-[1.02] tracking-[-0.045em] " +
              text
            }
          >
            {items.map((item, i) => (
              <Fragment key={i}>
                <span className="px-[0.18em]">{item}</span>
                <span className={"mx-[0.12em] h-[0.14em] w-[0.14em] shrink-0 rounded-full " + dot} />
              </Fragment>
            ))}
          </span>
        ))}
      </motion.div>
    </div>
  );
}

export default function ScrollVelocityMarquee({
  text = "Handgemacht · Ohne Vorlagen · Startklar",
  accent = "#d4ff3a",
  speed = 3,
  contained = true,
}: ScrollVelocityMarqueeProps) {
  const scroller = useRef<HTMLDivElement>(null);
  const { scrollY } = useScroll(contained ? { container: scroller } : {});
  const velocity = useVelocity(scrollY);
  const smooth = useSpring(velocity, { damping: 50, stiffness: 400 });
  const factor = useTransform(smooth, [0, 1000], [0, 5], { clamp: false });
  const skew = useTransform(smooth, [-2000, 2000], [-12, 12]);
  const items = text
    .split("·")
    .map((s) => s.trim())
    .filter(Boolean);

  const rows = (
    <div className="flex w-full flex-col gap-[1.5cqi] [container-type:inline-size]">
      {ROWS.map((r, i) => (
        <Row
          key={i}
          items={items}
          speed={speed}
          direction={r.direction}
          start={r.start}
          factor={factor}
          skew={skew}
          look={r.look}
          accent={accent}
        />
      ))}
    </div>
  );

  if (!contained) return <div className="w-full overflow-hidden py-10">{rows}</div>;
  return (
    <div
      ref={scroller}
      className="relative h-[28rem] w-full overflow-y-auto overflow-x-hidden rounded-3xl bg-[#fafafa] [scrollbar-width:none] dark:bg-[#0a0a0a]"
    >
      <div className="h-[500%]">
        <div className="sticky top-0 flex h-[20%] items-center overflow-hidden">{rows}</div>
      </div>
    </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 Laufband, das auf die Scrollgeschwindigkeit reagiert, gibt einer Seite zwischen den Abschnitten einen Puls: eine Agentur, die zeigt, was sie macht, ein Festival mit Daten und Städten, ein Modelabel mit seinem Slogan. Volle Zeilen, blasse Zeilen und ein Farbband laufen gegeneinander, und je schneller man scrollt, desto stärker kippen sie.

Zwei bis vier kurze Sätze, getrennt durch „·“, lesen sich besser als lange. Die Farbe des Bands macht den Effekt aus, such sie also zuerst aus. Wer im System weniger Bewegung eingestellt hat, sieht die Zeilen stillstehen, und der Text bleibt lesbar.