# CTA Marquee Text Band — Tailwind CTA Section

> Three rows of giant type drift in opposite directions behind one button; they speed up and lean with page scroll and slow to a crawl while you hover the button.

- Category: [Calls to Action](https://pixelpapa.com/components/cta.md)
- Tags: cta, marquee, typography, scroll, velocity
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/cta/cta-marquee-text-band

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/cta-marquee-text-band.json"
```

The file is written to `components/pixelpapa/cta-marquee-text-band.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ArrowUpRight } from "lucide-react";
import {
  motion,
  useAnimationFrame,
  useMotionValue,
  useReducedMotion,
  useScroll,
  useSpring,
  useTransform,
  useVelocity,
  type MotionValue,
} from "motion/react";
import { cn } from "@/lib/utils";

type RowProps = {
  words: string[];
  direction: 1 | -1;
  /** Percent of one loop travelled per second. */
  speed: number;
  /** Starting offset in percent, so rows never line up. */
  start: number;
  boost: MotionValue<number>;
  pace: MotionValue<number>;
  className?: string;
};

function MarqueeRow({ words, direction, speed, start, boost, pace, className }: RowProps) {
  const reduce = useReducedMotion();
  const offset = useMotionValue(start);
  const x = useTransform(offset, (v) => `${v}%`);
  // The track holds two identical halves, so wrapping inside [-50, 0) is seamless.
  const half = [...words, ...words];

  useAnimationFrame((_, delta) => {
    if (reduce) return;
    let next = offset.get() + direction * speed * (Math.min(delta, 64) / 1000) * (1 + boost.get()) * pace.get();
    if (next <= -50) next += 50;
    if (next > 0) next -= 50;
    offset.set(next);
  });

  return (
    <div className="overflow-hidden border-t border-border/60 py-1 last:border-b">
      <motion.div style={{ x }} className={cn("flex w-max items-center whitespace-nowrap will-change-transform", className)}>
        {[0, 1].map((copy) =>
          half.map((word, i) => (
            <span key={`${copy}-${i}`} className="flex items-center">
              <span className="px-[0.22em]">{word}</span>
              <svg viewBox="0 0 24 24" className="size-[0.3em] shrink-0 text-primary/60" fill="currentColor">
                <path d="M12 0c1 7 5 11 12 12-7 1-11 5-12 12-1-7-5-11-12-12 7-1 11-5 12-12z" />
              </svg>
            </span>
          )),
        )}
      </motion.div>
    </div>
  );
}

type CtaMarqueeTextBandProps = {
  eyebrow?: string;
  title?: string;
  label?: string;
  note?: string;
  rows?: [string[], string[], string[]];
};

export default function CtaMarqueeTextBand({
  eyebrow = "Tessel · component workbench",
  title = "Stop reading. Start shipping.",
  label = "Start building free",
  note = "Free for solo projects · set up in two minutes",
  rows = [
    ["Start building", "Ship tonight"],
    ["Zero config", "Free to start", "Yours to keep"],
    ["Your move", "Start building"],
  ],
}: CtaMarqueeTextBandProps) {
  const { scrollY } = useScroll();
  const velocity = useSpring(useVelocity(scrollY), { stiffness: 400, damping: 50 });
  const boost = useTransform(velocity, (v) => Math.min(Math.abs(v) / 300, 5));
  const skewX = useTransform(velocity, [-2400, 2400], [10, -10], { clamp: true });
  // 1 = cruising, 0.12 = nearly parked while the pointer rests on the button.
  const pace = useSpring(1, { stiffness: 70, damping: 18 });

  return (
    <section className="relative overflow-hidden bg-background py-20 sm:py-24">
      <style>{`
        @keyframes pp-cta-marquee-text-band-ring { 0% { transform: scale(1); opacity: .5; } 100% { transform: scale(1.35, 1.9); opacity: 0; } }
      `}</style>

      <motion.div
        aria-hidden
        style={{ skewX }}
        className="text-[clamp(4.25rem,13vw,10.5rem)] leading-[1.02] font-semibold tracking-tighter uppercase select-none [mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]"
      >
        <MarqueeRow words={rows[0]} direction={-1} speed={3.2} start={-8} boost={boost} pace={pace} className="text-foreground/[0.09]" />
        <MarqueeRow
          words={rows[1]}
          direction={1}
          speed={2.4}
          start={-31}
          boost={boost}
          pace={pace}
          className="text-transparent [-webkit-text-stroke:1.5px_color-mix(in_oklch,var(--foreground)_26%,transparent)]"
        />
        <MarqueeRow
          words={rows[2]}
          direction={-1}
          speed={4}
          start={-20}
          boost={boost}
          pace={pace}
          className="font-serif font-normal tracking-tight text-primary/25 normal-case italic"
        />
      </motion.div>

      <div className="absolute inset-0 flex items-center justify-center px-6">
        {/* A soft pool of page colour keeps the copy readable while the type passes underneath. */}
        <div
          aria-hidden
          className="absolute h-[125%] w-[min(820px,120%)] [background:radial-gradient(closest-side,var(--background)_42%,transparent)]"
        />
        <motion.div
          initial="hidden"
          animate="shown"
          transition={{ staggerChildren: 0.09, delayChildren: 0.1 }}
          className="relative flex flex-col items-center text-center"
        >
          <motion.p
            variants={{ hidden: { opacity: 0, y: 10 }, shown: { opacity: 1, y: 0 } }}
            className="rounded-full border border-border bg-card/80 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur"
          >
            {eyebrow}
          </motion.p>
          <motion.h2
            variants={{ hidden: { opacity: 0, y: 14 }, shown: { opacity: 1, y: 0 } }}
            className="mt-5 text-3xl font-semibold tracking-tight text-balance text-foreground sm:text-5xl"
          >
            {title}
          </motion.h2>

          <motion.div variants={{ hidden: { opacity: 0, scale: 0.85 }, shown: { opacity: 1, scale: 1 } }} className="relative mt-9">
            <span
              aria-hidden
              className="absolute inset-0 rounded-full border-2 border-primary motion-reduce:hidden"
              style={{ animation: "pp-cta-marquee-text-band-ring 2.4s cubic-bezier(.22,1,.36,1) infinite" }}
            />
            <motion.a
              href="#"
              onPointerEnter={() => pace.set(0.12)}
              onPointerLeave={() => pace.set(1)}
              onFocus={() => pace.set(0.12)}
              onBlur={() => pace.set(1)}
              whileHover={{ scale: 1.05 }}
              whileTap={{ scale: 0.96 }}
              transition={{ type: "spring", stiffness: 380, damping: 20 }}
              className="group relative inline-flex h-16 items-center gap-4 rounded-full bg-primary pr-2.5 pl-8 text-lg font-medium text-primary-foreground shadow-2xl shadow-primary/35 outline-offset-4"
            >
              {label}
              <span className="flex size-11 items-center justify-center rounded-full bg-primary-foreground text-primary transition-transform duration-500 ease-out group-hover:rotate-45">
                <ArrowUpRight className="size-5" />
              </span>
            </motion.a>
          </motion.div>

          <motion.p
            variants={{ hidden: { opacity: 0 }, shown: { opacity: 1 } }}
            className="mt-6 text-sm text-muted-foreground"
          >
            {note}
          </motion.p>
        </motion.div>
      </div>
    </section>
  );
}
```

## AI prompt

```text
Add the "CTA Marquee Text Band" component from PixelPapa UI to this project.

What it is: Three rows of giant type drift in opposite directions behind one button; they speed up and lean with page scroll and slow to a crawl while you hover the button.
Category: Calls to Action. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/cta-marquee-text-band.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/cta-marquee-text-band.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/cta-marquee-text-band" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More calls to action

- [CTA App Download](https://pixelpapa.com/components/cta/cta-app-download.md) (Pro): App download panel with generic store buttons, a scannable-looking code, and a phone that tilts toward the cursor while its route draws itself and a map saves offline.
- [CTA Countdown Launch](https://pixelpapa.com/components/cta/cta-countdown-launch.md) (Pro): A launch countdown on split-flap tiles whose digits roll over each second, with orbit rings turning behind it and a waitlist field that confirms your place inline.
- [CTA Dark Spotlight](https://pixelpapa.com/components/cta/cta-dark-spotlight.md): An always-dark closing section lit by a cursor torch: the headline ink and a dot grid brighten where the light falls, and it drifts on its own when no pointer is around.
- [CTA Gradient Glow Card](https://pixelpapa.com/components/cta/cta-gradient-glow-card.md) (Pro): A closing card ringed by a slowly rotating gradient border, with a soft light that trails the cursor and a copyable install command.
- [CTA Minimal Serif](https://pixelpapa.com/components/cta/cta-minimal-serif.md): One large serif sentence that rises word by word, closed by a magnetic link: a ring of turning type leans toward the cursor while the label's underline draws in.
- [CTA Split With Form](https://pixelpapa.com/components/cta/cta-split-with-form.md) (Pro): Pitch and a what-happens-next timeline on the left, a demo request form on the right that validates, sends and morphs into a drawn-check confirmation.
