Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "SVG text path scroll" for my site using the Enrichr Library element at https://enrichr.dev/elements/svg-text-path-scroll.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/svg-text-path-scroll.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- text: Move with the page
- speed: 40
- direction: left
- color: #fbf8f1
- accent: #e6ff55
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/svg-text-path-scroll.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-stp"><svg class="ef-stp__svg" viewBox="0 0 1000 400" preserveAspectRatio="xMidYMid slice" role="img"><title></title><defs><path class="ef-stp__pa" d="M0 0"/><path class="ef-stp__pb" d="M0 0"/></defs><text class="ef-stp__tb" aria-hidden="true"><textPath class="ef-stp__tpb"></textPath></text><text class="ef-stp__ta" aria-hidden="true"><textPath class="ef-stp__tpa"></textPath></text></svg></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-stp { container-type: inline-size; width: 100%; box-sizing: border-box; }
.ef-stp__svg { display: block; width: 100%; height: 15rem; height: clamp(15rem, 36cqi, 24rem); overflow: hidden; }
.ef-stp__ta, .ef-stp__tb { font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 72px; font-weight: 800; letter-spacing: 0.01em; }
.ef-stp__ta { fill: var(--ef-stp-c, #fbf8f1); }
.ef-stp__tb { fill: none; stroke: var(--ef-stp-a, #e6ff55); stroke-width: 1.5px; vector-effect: non-scaling-stroke; stroke-linejoin: round; opacity: 0.85; }
.ef-stp__tb * { vector-effect: non-scaling-stroke; }JavaScript
// Enrichr Library · https://enrichr.dev
// SVG text on a path: large text laid along a wavy SVG path, with a second outlined line running the other way underneath.
// The text slides along the path by animating textPath startOffset. It drifts on its own and also follows page scroll (scroll down pushes it in the chosen direction, scroll up pulls it back).
// With reduced motion it shows a still frame. Extras: amplitude (wave height, default 60) and follow (scroll gain, default 0.6) can be passed to update().
// mount(root, { text, speed (drift in SVG units per second, 0 = scroll only), direction ("left" or "right"), color, accent, amplitude, follow })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var svg = root.querySelector(".ef-stp__svg"), box = root.querySelector(".ef-stp");
var pa = svg.querySelector(".ef-stp__pa"), pb = svg.querySelector(".ef-stp__pb");
var tpa = svg.querySelector(".ef-stp__tpa"), tpb = svg.querySelector(".ef-stp__tpb");
var XL = "http://www.w3.org/1999/xlink", uid = "ef-stp-" + Math.random().toString(36).slice(2, 8);
var opts = {}, unit = 0, measured = false, clock = 0, sTarget = 0, sShown = 0, lastScroll = window.scrollY || 0;
var raf = 0, last = 0, visible = true, io = null;
function link(tp, id) { tp.setAttribute("href", "#" + id); tp.setAttributeNS(XL, "xlink:href", "#" + id); }
pa.setAttribute("id", uid + "-a"); pb.setAttribute("id", uid + "-b");
link(tpa, uid + "-a"); link(tpb, uid + "-b");
function wave(y0, amp) {
var d = "";
for (var x = -300; x <= 1300; x += 8) {
var y = y0 + amp * Math.sin(x * 0.0072 + 0.6) + amp * 0.28 * Math.sin(x * 0.019 + 1.7);
d += (x === -300 ? "M" : "L") + x + " " + y.toFixed(1);
}
return d;
}
function fill() {
var u = opts.text + " • ";
tpa.textContent = u; tpb.textContent = u;
var len = 0;
try { len = tpa.getComputedTextLength(); } catch (e) { len = 0; }
measured = len > 20;
unit = measured ? len : u.length * 72 * 0.56;
var total = 1700, k = Math.ceil((total + unit) / unit) + 1, s = "";
for (var i = 0; i < k; i++) s += u;
tpa.textContent = s; tpb.textContent = s;
}
function place() {
var dir = opts.direction === "right" ? -1 : 1;
var p = dir * (clock * Number(opts.speed) + sShown);
var m = ((p % unit) + unit) % unit;
tpa.setAttribute("startOffset", (-m).toFixed(2));
var q = ((((-p * 0.55) % unit) + unit) % unit);
tpb.setAttribute("startOffset", (-q).toFixed(2));
}
function kick() { if (!reduce && !raf && visible) { last = performance.now(); raf = requestAnimationFrame(tick); } }
function tick(t) {
raf = 0;
var dt = Math.min(0.05, (t - last) / 1000 || 0.016); last = t;
if (!measured) { fill(); }
var y = window.scrollY || 0;
sTarget += (y - lastScroll) * Number(opts.follow); lastScroll = y;
sShown += (sTarget - sShown) * (1 - Math.exp(-dt * 7));
clock += dt;
place();
if (visible && (Number(opts.speed) > 0 || Math.abs(sTarget - sShown) > 0.01)) raf = requestAnimationFrame(tick);
}
function update(next) {
var prev = opts.text, prevAmp = opts.amplitude;
opts = Object.assign({ text: "Move with the page", speed: 40, direction: "left", color: "#fbf8f1", accent: "#e6ff55", amplitude: 60, follow: 0.6 }, opts, next || {});
box.style.setProperty("--ef-stp-c", opts.color);
box.style.setProperty("--ef-stp-a", opts.accent);
svg.querySelector("title").textContent = opts.text;
svg.setAttribute("aria-label", opts.text);
if (prevAmp !== Number(opts.amplitude)) {
var a = Number(opts.amplitude);
pa.setAttribute("d", wave(168, a)); pb.setAttribute("d", wave(268, a));
opts.amplitude = a;
}
if (prev !== opts.text) fill();
place(); kick();
}
update(o);
if (!reduce) {
window.addEventListener("scroll", kick, { passive: true });
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) { lastScroll = window.scrollY || 0; kick(); } else { cancelAnimationFrame(raf); raf = 0; } });
io.observe(root);
}
}
return {
update: update,
destroy: function () {
cancelAnimationFrame(raf); raf = 0;
window.removeEventListener("scroll", kick);
if (io) io.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Move with the page","speed":40,"direction":"left","color":"#fbf8f1","accent":"#e6ff55"});How it works
The text is repeated inside an SVG textPath that follows a wavy path built from two sine waves. The script measures the length of one repeat with getComputedTextLength, repeats the text enough times to cover the whole path, and then animates startOffset between minus one repeat length and zero, so the wrap is invisible. Every frame the offset is the drift speed times elapsed time plus an amount taken from how far the page has scrolled, smoothed so scroll feels fluid. Scrolling down pushes the text in the chosen direction and scrolling up pulls it back. A second, outline-only line sits on a parallel path and moves the opposite way at about half the speed. The SVG uses a slice viewBox, so the text stays large on narrow containers.
Accessibility and performance
The SVG has a role of img and an accessible name taken from the text, and the repeated glyph copies are hidden from assistive technology so the phrase is read once. With reduced motion the text does not drift or follow scroll and a still frame is shown. Keep the text short and the contrast high, because letters on a curve are harder to read than straight text.
Two startOffset attributes change per frame, which makes the browser re-lay out two text runs on a path. That is cheap for a few hundred glyphs but keep the repeat count and font size reasonable. The loop runs only while there is drift or scroll catch up and it pauses off screen. It reads window.scrollY, so it follows native scroll and smooth scroll libraries.
| Section | Text |
|---|---|
| Techniques | Scroll-driven, Text animation |
| Stack | Vanilla JS, SVG, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Animating SVG Text on a Path by Manoela Ilic on Codrops. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Does it need a scroll container?
It reads the window scroll position, so it needs the page to scroll. With a drift speed above zero it keeps moving on its own even if nothing scrolls, for example inside a fixed preview.
Can I change the shape of the wave?
Yes. update() accepts amplitude for the wave height and follow for how strongly scroll moves the text. For a different curve, change the path string.
Why is the wrap seamless?
The offset only ever moves within one repeat length and the text is the same in every repeat, so jumping back by exactly one repeat looks identical.