Skip to content

Velocity Marquee Rail

A marquee that accelerates with scroll speed and flips direction when you scroll back.

DOM + CSS1 dep1 file · 3 KB TSX

Freebrutalisteditorialplayful
Preview

Props

2
4
Reverse on scroll up
Requires framer-motion
"use client";

import { useRef } from "react";
import {
  motion,
  useAnimationFrame,
  useMotionValue,
  useScroll,
  useSpring,
  useTransform,
  useVelocity,
  wrap,
} from "framer-motion";

export function VelocityMarqueeRail({
  words,
  baseSpeed = 2,
  velocityBoost = 4,
  reverse = true,
}: {
  words: string[];
  baseSpeed?: number;
  velocityBoost?: number;
  /** Flip the rail's travel direction when the page scrolls up. */
  reverse?: boolean;
}) {
  const { scrollY } = useScroll();
  const scrollVelocity = useVelocity(scrollY);

  // Raw scroll velocity is extremely spiky; the spring is what turns it into
  // something usable as an animation input.
  const smoothVelocity = useSpring(scrollVelocity, { damping: 50, stiffness: 400 });

  // Map px/s into a small multiplier, CLAMPED — without the clamp a fast
  // flick sends the rail off at an unreadable speed.
  const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, velocityBoost], {
    clamp: true,
  });

  const baseX = useMotionValue(0);
  const direction = useRef(1);

  useAnimationFrame((_, delta) => {
    // delta-scaled, so speed is identical on 60Hz and 120Hz displays.
    let moveBy = direction.current * baseSpeed * (delta / 1000) * 60;

    // Scrolling up flips the rail. Advancing position manually in a frame
    // callback (rather than running a CSS animation) is the only way to
    // reverse mid-flight without a visible restart.
    if (reverse) {
      const v = velocityFactor.get();
      if (v < 0) direction.current = -1;
      else if (v > 0) direction.current = 1;
    }

    moveBy += direction.current * moveBy * velocityFactor.get();
    baseX.set(baseX.get() + moveBy);
  });

  // The track repeats 4x, so one tile is 25%. Wrapping over [-25, 0] loops
  // seamlessly and keeps the offset bounded instead of growing forever.
  const x = useTransform(baseX, (v) => wrap(-25, 0, v / 12) + "%");

  return (
    <div className="overflow-hidden py-4">
      <motion.div style={{ x }} className="flex whitespace-nowrap will-change-transform">
        {Array.from({ length: 4 }).map((_, tile) => (
          <span key={tile} className="flex shrink-0 items-center" aria-hidden={tile > 0}>
            {words.map((w) => (
              <span key={w} className="flex items-center">
                <span className="px-3 text-3xl font-black tracking-tight">{w}</span>
                <span className="size-1.5 rounded-full bg-blue-500" />
              </span>
            ))}
          </span>
        ))}
      </motion.div>
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this animation

A CSS-animated marquee cannot do this. Reversing a running CSS animation restarts it from a keyframe boundary, which shows as a visible jump, so direction has to be a property of a position you advance yourself — hence a motion value mutated inside a frame callback. Three details make it behave. Raw scroll velocity is extremely spiky, so it goes through a spring before it is used for anything. The mapping from pixels-per-second to a speed multiplier is clamped, because an unclamped trackpad flick registers thousands of px/s and sends the rail past legibility. And movement is scaled by frame delta, so the drift is the same speed on a 60Hz and a 120Hz display rather than twice as fast on the latter — a bug that is invisible on the machine most people develop on. The loop itself relies on Framer's wrap helper over a range matching one tile's share of the repeated track, which both makes the seam invisible and keeps the offset bounded instead of growing forever. The content repeats, so every duplicate tile must be hidden from assistive technology.

Curator’s note

Built for viberdy 2.0. The clamp on the velocity mapping is not optional — without it a trackpad flick makes the rail unreadable.

Pairs well with

Pro
Pro