Testimonial Marquee Travelling Along a Looping Path

A testimonial section where seven quote chips travel along a dotted track that sweeps in from the left, goes once round a loop and leaves on the right. Each chip has an initials avatar, a one-line quote and a name, and stays upright as it follows the curve. Chips further along the track are drawn on top, so the loop crosses over itself the right way. The track slows while the pointer is over it and a button pauses it. A headline, copy and a call to action sit above. On narrow screens the drawing scales down to a set minimum and then crops its sides, keeping the loop in view.

Code

// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/blocks/marquee-along-svg-path)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "framer-motion";

const quotes = [
  { text: "Paid in two days, not sixty.", name: "Noor A.", role: "Illustrator", color: "#f97316" },
  { text: "My tax return took one evening.", name: "Callum R.", role: "Joiner", color: "#0ea5e9" },
  { text: "Clients actually open the reminders.", name: "Priya S.", role: "Copywriter", color: "#10b981" },
  { text: "I stopped dreading month end.", name: "Tomasz W.", role: "Photographer", color: "#8b5cf6" },
  { text: "The mileage log alone is worth it.", name: "Dee O.", role: "Florist", color: "#ec4899" },
  { text: "First invoice sent in four minutes.", name: "Jonah K.", role: "Sound engineer", color: "#14b8a6" },
  { text: "It chases late payers so I don't.", name: "Aiko T.", role: "Web designer", color: "#ef4444" },
];

// The track, drawn on a 1200 x 440 canvas: in from the left, once round a loop, out to the right.
const PATH = "M -180 100 C 120 100, 240 310, 470 310 C 700 310, 810 90, 640 66 C 460 42, 420 340, 700 366 C 940 388, 1080 170, 1380 160";
const CANVAS = { w: 1200, h: 440 };
const SPEED = 5.5; // percent of the track per second
const HOVER_SPEED = 0.2; // share of normal speed while the pointer is over the track
const MIN_SCALE = 0.8; // on narrow screens the canvas stops shrinking here and crops its sides instead

export default function TestimonialMarqueeTravellingAlongALoopingPath() {
  const reduce = useReducedMotion();
  const wrapRef = useRef<HTMLDivElement>(null);
  const chipRefs = useRef<(HTMLDivElement | null)[]>([]);
  const inView = useInView(wrapRef, { amount: 0.1 });
  const [scale, setScale] = useState(1);
  const [paused, setPaused] = useState(false);
  const hovering = useRef(false);

  useEffect(() => {
    const wrap = wrapRef.current;
    if (!wrap) return;
    const fit = () => setScale(Math.max(MIN_SCALE, Math.min(1, wrap.clientWidth / CANVAS.w)));
    fit();
    const observer = new ResizeObserver(fit);
    observer.observe(wrap);
    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    if (reduce || paused || !inView) return;
    const chips = chipRefs.current;
    let raf = 0;
    let last = performance.now();
    let factor = 1;
    let base = Number(wrapRef.current?.dataset.offset) || 0;
    const loop = (now: number) => {
      raf = requestAnimationFrame(loop);
      const dt = Math.min(now - last, 60) / 1000;
      last = now;
      if (document.hidden) return;
      // Ease towards the slow speed on hover and back again, so it never stops dead.
      factor += ((hovering.current ? HOVER_SPEED : 1) - factor) * Math.min(1, dt * 6);
      base = (base + SPEED * factor * dt) % 100;
      chips.forEach((chip, i) => {
        if (!chip) return;
        const d = (base + (i * 100) / chips.length) % 100;
        chip.style.offsetDistance = `${d.toFixed(3)}%`;
        // Chips further along the track sit on top, which makes the loop cross over itself correctly.
        chip.style.zIndex = String(Math.floor(d));
      });
      if (wrapRef.current) wrapRef.current.dataset.offset = String(base);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [reduce, paused, inView]);

  return (
    <section className="overflow-hidden bg-[#fff7ed] pb-10 pt-16 text-stone-900 sm:pt-20">
      <div className="mx-auto max-w-3xl px-6 text-center">
        <p className="font-mono text-xs uppercase tracking-[0.25em] text-orange-700">Tallybook / Invoicing for one-person businesses</p>
        <h2 className="mt-5 text-4xl font-black tracking-tight sm:text-6xl">Kind words, on a loop.</h2>
        <p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-stone-700">Eleven thousand freelancers send their invoices with Tallybook. These are a few of the things they tell us.</p>
        <div className="mt-7 flex flex-wrap items-center justify-center gap-3">
          <a href="#" className="inline-flex h-12 items-center rounded-full bg-stone-900 px-7 text-sm font-semibold text-white transition hover:bg-stone-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fff7ed]">
            Start free for 30 days
          </a>
          <button
            type="button"
            aria-pressed={paused}
            onClick={() => setPaused((p) => !p)}
            className="inline-flex h-12 items-center gap-2 rounded-full border border-stone-400 px-6 text-sm font-semibold transition hover:border-stone-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fff7ed]"
          >
            <svg aria-hidden="true" viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
              {paused ? <path d="M6 4l10 6-10 6z" /> : <path d="M5 4h3.5v12H5zM11.5 4H15v12h-3.5z" />}
            </svg>
            {paused ? "Play" : "Pause"}
          </button>
        </div>
      </div>

      <ul className="sr-only">
        {quotes.map((q) => (
          <li key={q.name}>
            {q.text} {q.name}, {q.role}.
          </li>
        ))}
      </ul>

      <div ref={wrapRef} aria-hidden="true" className="relative mt-10" style={{ height: CANVAS.h * scale }} onPointerEnter={() => (hovering.current = true)} onPointerLeave={() => (hovering.current = false)}>
        <div className="absolute left-1/2 top-0" style={{ width: CANVAS.w, height: CANVAS.h, marginLeft: -CANVAS.w / 2, transform: `scale(${scale})`, transformOrigin: "50% 0%" }}>
          <svg viewBox={`0 0 ${CANVAS.w} ${CANVAS.h}`} className="absolute inset-0 h-full w-full overflow-visible">
            <path d={PATH} fill="none" stroke="#fdba74" strokeWidth="3" strokeDasharray="2 12" strokeLinecap="round" />
          </svg>
          {quotes.map((q, i) => (
            <div
              key={q.name}
              ref={(el) => {
                chipRefs.current[i] = el;
              }}
              className="absolute left-0 top-0 flex items-center gap-3 whitespace-nowrap rounded-full bg-white py-2 pl-2 pr-6 shadow-lg shadow-orange-900/10 ring-1 ring-stone-900/5 will-change-transform"
              style={{ offsetPath: `path("${PATH}")`, offsetRotate: "0deg", offsetDistance: `${(i * 100) / quotes.length}%`, zIndex: Math.floor((i * 100) / quotes.length) }}
            >
              <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white" style={{ background: q.color }}>
                {q.name
                  .split(" ")
                  .map((n) => n[0])
                  .join("")}
              </span>
              <span>
                <span className="block text-[15px] font-semibold leading-tight text-stone-900">{q.text}</span>
                <span className="mt-0.5 block text-xs text-stone-600">
                  {q.name}, {q.role}
                </span>
              </span>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/testimonial-marquee-travelling-along-a-looping-path.json

When to use it

How to customize it

Accessibility

Questions

How do the chips follow the curve?

CSS motion path. Each chip has `offset-path: path(...)` with the same path data as the dotted line, and its `offset-distance` is a percentage along that path. A frame loop increases one shared value and gives every chip that value plus its own evenly spaced share.

How does the loop cross over itself correctly?

Each chip's `z-index` is set to its distance along the track. A chip leaving the loop is further along than one entering it, so it is drawn on top where the track crosses.

Why is the drawing scaled with a transform instead of using percentages?

`offset-path: path()` takes plain pixel coordinates and does not stretch with its element. The component draws everything on a fixed canvas and scales that canvas to the container width, which keeps the chips exactly on the dotted line at every size.

Why do the chips stay upright?

`offset-rotate: 0deg` stops the browser from turning each element to face along the path. Text stays level and readable all the way round the loop.

What is the browser support for offset-path?

The `path()` form works in current Chrome, Edge, Firefox and Safari. In a browser without it the chips would stack in the top left corner, so keep the hidden list of quotes as a fallback that you can show instead.

Adapted from Marquee Along SVG Path in Fancy Components, used under its open-source licence. Licences and credits

Related: Three Column Testimonial Wall with Vertical Scroll, Dual Row Logo Marquee with Edge Fade, Ribbon Text Scrolling Along a Curved SVG Path.

More marquees or all free UI components. Start for free to have a full site built around it.

More in Marquees

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap