# 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.

- Category: [Text & scroll](https://library.aimran.es/components/text-scroll)
- Built with: React + Motion
- Live editor: https://library.aimran.es/blocks/scroll-velocity-marquee
- Library: AIMRAN Library (https://library.aimran.es), free, no sign-up, by Aimran Oukham

## 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.

## Code

### React (Motion)

```tsx
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 assistants

Paste this into ChatGPT, Claude, Cursor, v0 or Lovable to adapt the block to your stack (Tailwind, Vue, Svelte…):

````text
I want to add the “Scroll velocity marquee” block (Text & scroll) from AIMRAN Library to my project.

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

## Chosen settings
- Frases (separadas por ·): "Handmade · No templates · Ready to ship"
- Ribbon color: #d4ff3a
- Base speed: 3%/s (from 1 to 12%/s)

## What I need
1. Add it to my project with the same look and animation as the reference code: same sizes, colours, radii, shadows, timings and easing.
2. It is built with React + TypeScript, with framer-motion for the animation and lucide-react for the icons. If my project uses something else (Vue, Svelte, Astro, plain HTML…), translate it without losing any behaviour. If I use Tailwind, turn the styles into classes; if not, keep the CSS in its own file.
3. Expose the settings above as props (or CSS variables) with those values as defaults.
4. Make it work well on phones: nothing that depends only on hover, touch targets of at least 44 px and no overflow on 320 px screens.
5. Make it look right in light and dark mode.
6. Keep it accessible: semantic HTML, roles and aria-* where needed, visible focus and keyboard support.
7. Respect prefers-reduced-motion: with reduced motion, no big animations but the same end result.

## Reference code
```tsx
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>
  );
}
```

Give me the complete final code, ready to paste, and tell me in one line where to put it in my project.
````
