A festival hero on deep indigo with a headline, a line of copy and two buttons, above a band where two wavy ribbons cross. Each ribbon is a thick SVG stroke with uppercase text riding along the same path, one running left to right and the other right to left at different speeds. The loop is seamless because the text is shifted by exactly one phrase length. A pause button stops the ribbons. On phones the band crops to the centre of the curves at a readable type size instead of shrinking the whole drawing.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/text/text-along-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";
// Each ribbon: an SVG path drawn inside the 1400 x 320 canvas, the phrase that runs along it,
// its colours and its speed in canvas units per second (negative runs right to left).
const ribbons = [
{
id: "ribbon-path-sunset",
path: "M-60 236 C 180 336, 380 26, 700 116 S 1180 336, 1460 96",
phrase: "MARLOW QUAY / THE SALT CHOIR / ODESSA PARK / NIGHT FERRIES / ",
band: "#ff5e3a",
ink: "#1b1340",
speed: 70,
},
{
id: "ribbon-path-lime",
path: "M-60 84 C 220 -16, 420 304, 720 244 S 1160 14, 1460 254",
phrase: "THREE STAGES / TWO NIGHTS / ONE HARBOUR / 12 TO 14 SEPTEMBER / ",
band: "#c8f169",
ink: "#1b1340",
speed: -55,
},
];
const REPEATS = 8; // copies of the phrase laid along the path, enough to cover it with one to spare
const BAND_WIDTH = 58;
const FONT_SIZE = 25;
export default function RibbonTextScrollingAlongACurvedSvgPath() {
const reduce = useReducedMotion();
const sectionRef = useRef<HTMLElement>(null);
const textRefs = useRef<(SVGTextPathElement | null)[]>([]);
const inView = useInView(sectionRef, { amount: 0.1 });
const [paused, setPaused] = useState(false);
useEffect(() => {
const nodes = textRefs.current;
// One phrase length per ribbon: shifting by exactly this much lands on an identical frame.
const units = nodes.map((n) => (n ? n.getComputedTextLength() / REPEATS : 0));
const place = (offsets: number[]) => nodes.forEach((n, i) => n && n.setAttribute("startOffset", String(-units[i] + offsets[i])));
const offsets = ribbons.map(() => 0);
place(offsets);
if (reduce || paused || !inView) return;
let raf = 0;
let last = performance.now();
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
const dt = Math.min(now - last, 60) / 1000;
last = now;
if (document.hidden) return;
ribbons.forEach((r, i) => {
if (!units[i]) return;
offsets[i] = (((offsets[i] + r.speed * dt) % units[i]) + units[i]) % units[i];
});
place(offsets);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [reduce, paused, inView]);
return (
<section ref={sectionRef} className="relative overflow-hidden bg-[#1b1340] text-white">
<div aria-hidden="true" className="pointer-events-none absolute -top-40 left-1/2 h-96 w-[48rem] -translate-x-1/2 rounded-full bg-fuchsia-500/20 blur-3xl" />
<div className="relative mx-auto max-w-4xl px-6 pt-20 text-center sm:pt-24">
<p className="font-mono text-xs uppercase tracking-[0.3em] text-[#c8f169]">Tidewater Festival / Year six</p>
<h2 className="mt-6 text-5xl font-black leading-[1.02] tracking-tight sm:text-7xl">Three days of sound on the water.</h2>
<p className="mx-auto mt-6 max-w-xl text-lg leading-relaxed text-indigo-100/80">
Forty acts across the old harbour, from sunrise sets on the pier to late shows in the boat sheds. Weekend passes include the ferry.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<a href="#" className="inline-flex h-12 items-center rounded-full bg-[#ff5e3a] px-7 text-sm font-bold text-[#1b1340] transition hover:bg-[#ff7a5c] focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#1b1340]">
Get weekend passes
</a>
<button
type="button"
aria-pressed={paused}
onClick={() => setPaused((p) => !p)}
className="inline-flex h-12 items-center gap-2 rounded-full border border-white/30 px-6 text-sm font-semibold text-white transition hover:border-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#1b1340]"
>
<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 ribbons" : "Pause ribbons"}
</button>
</div>
</div>
<svg aria-hidden="true" viewBox="0 0 1400 320" preserveAspectRatio="xMidYMid slice" className="relative mt-8 block h-60 w-full sm:mt-10 sm:h-80">
{ribbons.map((r, i) => (
<g key={r.id}>
<path d={r.path} fill="none" stroke="rgba(0,0,0,0.35)" strokeWidth={BAND_WIDTH} transform="translate(0 10)" />
<path id={r.id} d={r.path} fill="none" stroke={r.band} strokeWidth={BAND_WIDTH} />
<text fill={r.ink} fontSize={FONT_SIZE} fontWeight={800} letterSpacing="2" dy="0.36em" style={{ fontFamily: "ui-sans-serif, system-ui, sans-serif", whiteSpace: "pre" }}>
<textPath
href={`#${r.id}`}
startOffset="0"
ref={(el) => {
textRefs.current[i] = el;
}}
>
{r.phrase.repeat(REPEATS)}
</textPath>
</text>
</g>
))}
</svg>
<p className="sr-only">Line-up includes Marlow Quay, The Salt Choir, Odessa Park and Night Ferries. Three stages, two nights, one harbour, 12 to 14 September.</p>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/ribbon-text-scrolling-along-a-curved-svg-path.json
SVG has this built in. A `textPath` element points at a `path` by id and lays each glyph along it. The component draws the same path twice: once as a thick stroke for the ribbon, and once as the invisible guide for the text.
The phrase is repeated several times in one text element. On mount the component measures the full text length and divides by the number of copies to get the length of one phrase. The `startOffset` attribute then cycles through exactly that distance, so the frame at the end of a cycle matches the frame at the start.
An `animate` element keeps running when the visitor has asked for reduced motion and cannot be paused from React state as easily. A small frame loop that writes `startOffset` handles both, and it can stop when the section leaves the screen.
Draw a curve in any vector tool on a 1400 by 320 artboard and copy the `d` attribute. Start and end the path about 60 units outside the canvas so the ribbon ends are never visible.
SVG text can be wrapped in an `a` element, but a moving target is hard to click. A better pattern is to keep the ribbon decorative and put the real link in a button near it, as the demo does.
Adapted from Text Along Path in Fancy Components, used under its open-source licence. Licences and credits
Related: Dual Row Logo Marquee with Edge Fade, Live News Ticker Bar with Pulsing Dot, Three Column Testimonial Wall with Vertical Scroll.
More marquees or all free UI components. Start for free to have a full site built around it.
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.