HandmadeNo templatesReady to ship
HandmadeNo templatesReady to ship
HandmadeNo templatesReady to ship
HandmadeNo templatesReady to ship
React + MotionText & scroll

Scroll velocity marquee

Four rows of giant text run in opposite directions, speed up and lean with your scroll, then reverse when you scroll up.

Settings

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 = "Handmade · No templates · Ready to ship",
  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 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 scroll velocity marquee gives a page a pulse between sections: an agency showing what it does, a festival listing dates and cities, a fashion brand shouting its slogan. Solid rows, ghosted rows and one colour band run against each other, and the faster someone scrolls, the harder they lean and rush.

Two to four short phrases separated by “·” read better than long sentences. A bright band colour gives the block its punch, so pick it before anything else. For visitors who ask their system for less motion, the rows stay still, which keeps the text readable.