Radial Text Marquee

Documentation
Webflow
Code
Setup: External Scripts
External Scripts in Webflow
Make sure to always put the External Scripts before the Javascript step of the resource.
In this video you learn where to put these in your Webflow project? Or how to include a paid GSAP Club plugin in your project?
HTML
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>Step 1: Copy structure to Webflow
Copy structure to Webflow
In the video below we described how you can copy + paste the structure of this resource to your Webflow project.
Copy to Webflow
Webflow structure is not required for this resource.
Step 1: Add HTML
HTML
<div data-radial-text-marquee-init="" data-radial-text-marquee-speed="2" data-radial-text-marquee-radius="8" data-radial-text-marquee-spacer="-" data-radial-text-marquee-spacer-color="#A1FF62" class="radial-text-marquee">
<div data-radial-text-marquee-text="" class="radial-text-marquee__text">Radial Text Marquee</div>
</div>HTML structure is not required for this resource.
Step 2: Add CSS
CSS
.radial-text-marquee {
width: 100%;
position: relative;
}
.radial-text-marquee__text {
text-align: center;
letter-spacing: -.04em;
white-space: nowrap;
user-select: none;
font-size: clamp(4.5em, 10vw, 10em);
}Step 2: Add custom Javascript
Custom Javascript in Webflow
In this video, Ilja gives you some guidance about using JavaScript in Webflow:
Step 2: Add Javascript
Step 3: Add Javascript
Javascript
function initRadialTextMarquee() {
const wraps = document.querySelectorAll('[data-radial-text-marquee-init]');
if (!wraps.length) return;
const ns = 'http://www.w3.org/2000/svg';
const xns = 'http://www.w3.org/1999/xlink';
const prefersReducedMotion =
window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (n, a, b) => Math.min(b, Math.max(a, Number(n) || 0));
const speedScale = { minViewport: 250, maxViewport: 2000, minMultiplier: 0.5, maxMultiplier: 1 };
const getSpeedMultiplier = () => {
const w = window.innerWidth || speedScale.maxViewport;
const t = clamp((w - speedScale.minViewport) / (speedScale.maxViewport - speedScale.minViewport), 0, 1);
return speedScale.minMultiplier + t * (speedScale.maxMultiplier - speedScale.minMultiplier);
};
const letterSpacingToPx = (ls, fontSizePx) => {
if (!ls || ls === 'normal') return 0;
if (ls.endsWith('px')) return parseFloat(ls) || 0;
if (ls.endsWith('em')) return (parseFloat(ls) || 0) * fontSizePx;
if (ls.endsWith('rem')) {
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return (parseFloat(ls) || 0) * root;
}
const n = parseFloat(ls);
return Number.isFinite(n) ? n : 0;
};
const syncTypography = (fromEl, svgText, svgTextPath) => {
const s = getComputedStyle(fromEl);
const fontSizePx = parseFloat(s.fontSize) || 16;
const lsPx = letterSpacingToPx(s.letterSpacing, fontSizePx);
svgText.setAttribute('font-family', s.fontFamily);
svgText.setAttribute('font-size', s.fontSize);
svgText.setAttribute('font-weight', s.fontWeight);
svgText.setAttribute('dominant-baseline', 'alphabetic');
svgText.setAttribute('text-rendering', 'geometricPrecision');
svgText.setAttribute('letter-spacing', `${lsPx}px`);
svgText.setAttribute('fill', s.color);
if (svgTextPath) svgTextPath.setAttribute('letter-spacing', `${lsPx}px`);
return fontSizePx;
};
const appendTspan = (tp, value, fill) => {
const t = document.createElementNS(ns, 'tspan');
t.textContent = value;
if (fill) t.setAttribute('fill', fill);
tp.appendChild(t);
};
const buildRun = (tp, text, spacer, spacerColor, pad, reps) => {
tp.textContent = '';
appendTspan(tp, text);
for (let i = 0; i < reps; i++) {
appendTspan(tp, pad);
appendTspan(tp, spacer, spacerColor);
appendTspan(tp, pad);
appendTspan(tp, text);
}
};
const buildUnit = (tp, text, spacer, spacerColor, pad) => {
tp.textContent = '';
appendTspan(tp, pad);
appendTspan(tp, spacer, spacerColor);
appendTspan(tp, pad);
appendTspan(tp, text);
};
const radiusLevelToCircleR = (half, level01) => {
if (level01 <= 0) return half * 200;
const inv = 1 - level01;
return half * (1.01 + inv * inv * 16.99);
};
const setPlaying = (state, play) => {
state.isInView = play;
if (!state.tween) return;
if (prefersReducedMotion) return state.tween.pause();
play ? state.tween.play() : state.tween.pause();
};
const makeSvg = (wrap) => {
const svg = document.createElementNS(ns, 'svg');
const defs = document.createElementNS(ns, 'defs');
const g = document.createElementNS(ns, 'g');
const path = document.createElementNS(ns, 'path');
const text = document.createElementNS(ns, 'text');
const textPath = document.createElementNS(ns, 'textPath');
const id = `rtm-${Math.random().toString(16).slice(2)}`;
Object.assign(svg.style, {
position: 'absolute',
top: 0,
left: 0,
overflow: 'visible',
pointerEvents: 'none',
display: 'block'
});
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
path.setAttribute('id', id);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', 'none');
textPath.setAttributeNS(xns, 'xlink:href', `#${id}`);
textPath.setAttribute('text-anchor', 'start');
text.appendChild(textPath);
defs.appendChild(path);
svg.appendChild(defs);
g.appendChild(path);
g.appendChild(text);
svg.appendChild(g);
wrap.appendChild(svg);
const textEl = wrap.querySelector('[data-radial-text-marquee-text]');
if (textEl) textEl.style.opacity = '0';
const msvg = document.createElementNS(ns, 'svg');
const mdefs = document.createElementNS(ns, 'defs');
const mpath = document.createElementNS(ns, 'path');
const mtext = document.createElementNS(ns, 'text');
const mtp = document.createElementNS(ns, 'textPath');
const mid = `rtm-m-${Math.random().toString(16).slice(2)}`;
msvg.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;opacity:0;pointer-events:none';
mpath.setAttribute('id', mid);
mpath.setAttribute('d', 'M 0 0 L 100000 0');
mtp.setAttributeNS(xns, 'xlink:href', `#${mid}`);
mtp.setAttribute('text-anchor', 'start');
mtext.appendChild(mtp);
mdefs.appendChild(mpath);
msvg.appendChild(mdefs);
msvg.appendChild(mtext);
wrap.appendChild(msvg);
return { svg, g, path, text, textPath, measureText: mtext, measureTextPath: mtp };
};
wraps.forEach((wrap) => {
const textEl = wrap.querySelector('[data-radial-text-marquee-text]');
if (!textEl) return;
const state = {
...makeSvg(wrap),
tween: null,
proxy: { x: 0 },
isInView: true,
raf: 0
};
new IntersectionObserver(
(e) => setPlaying(state, e[0].isIntersecting),
{ threshold: 0 }
).observe(wrap);
const rebuild = () => {
const text = (textEl.textContent || '').trim();
if (!text) return;
const duplicateBase = 6;
const speed = clamp(wrap.getAttribute('data-radial-text-marquee-speed') || 4, 0, 200);
const speedPx = Math.max(speed * 100 * getSpeedMultiplier(), 1);
const radiusLevel = clamp(wrap.getAttribute('data-radial-text-marquee-radius') || 10, 0, 10);
const level01 = radiusLevel / 10;
const spacer = wrap.getAttribute('data-radial-text-marquee-spacer') || '•';
const spacerColor = wrap.getAttribute('data-radial-text-marquee-spacer-color') || null;
const padCount = clamp(
wrap.getAttribute('data-radial-text-marquee-spacer-padding') || 1,
0,
20
);
const pad = '\u00A0'.repeat(padCount);
const fontSizePx = syncTypography(textEl, state.text, state.textPath);
syncTypography(textEl, state.measureText, state.measureTextPath);
const wrapW = Math.max(wrap.clientWidth, 1);
const wrapH = Math.max(wrap.clientHeight || textEl.offsetHeight, 1);
const bleed = fontSizePx * 2;
const w = wrapW + bleed * 2;
const h = wrapH;
Object.assign(state.svg.style, {
width: `${w}px`,
height: `${h}px`,
left: `${-bleed}px`
});
state.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
const half = w / 2;
const r = level01 <= 0.0001 ? half * 200 : Math.max(radiusLevelToCircleR(half, level01), half + 0.001);
const under = Math.max(r * r - half * half, 0);
const y = Math.max(r - Math.sqrt(under), 0);
state.path.setAttribute(
'd',
level01 <= 0.0001
? `M 0 ${y} L ${w} ${y}`
: `M 0 ${y} A ${r} ${r} 0 0 1 ${w} ${y}`
);
state.text.setAttribute('x', '0');
state.text.setAttribute('y', y);
state.g.setAttribute('transform', `translate(0 ${fontSizePx})`);
cancelAnimationFrame(state.raf);
state.raf = requestAnimationFrame(() => {
buildUnit(state.measureTextPath, text, spacer, spacerColor, pad);
const unitLen = state.measureTextPath.getComputedTextLength();
const loopLen = unitLen || 1;
let reps = duplicateBase;
buildRun(state.textPath, text, spacer, spacerColor, pad, reps);
while (state.textPath.getComputedTextLength() < Math.max(loopLen * 2.6, wrapW * 3)) {
reps = Math.ceil(reps * 1.35);
buildRun(state.textPath, text, spacer, spacerColor, pad, reps);
}
state.tween?.kill();
if (prefersReducedMotion) return;
state.proxy.x = 0;
state.tween = gsap.to(state.proxy, {
x: loopLen,
duration: loopLen / speedPx,
ease: 'none',
repeat: -1,
onUpdate: () => {
const x = ((state.proxy.x % loopLen) + loopLen) % loopLen;
state.textPath.setAttribute('startOffset', `${-x}px`);
}
});
setPlaying(state, state.isInView);
});
};
rebuild();
document.fonts?.ready?.then(rebuild).catch(() => {});
if (window.ResizeObserver) {
new ResizeObserver(rebuild).observe(wrap);
new ResizeObserver(rebuild).observe(textEl);
} else {
window.addEventListener('resize', rebuild);
}
window.addEventListener('resize', rebuild);
});
}
// Initialize Radial Text Marquee
document.addEventListener('DOMContentLoaded', function () {
initRadialTextMarquee();
});Step 3: Add custom CSS
Step 2: Add custom CSS
Custom CSS in Webflow
Curious about where to put custom CSS in Webflow? Ilja explains it in the below video:
CSS
Implementation
This radial text marquee relies on a relatively long script because it dynamically creates multiple SVG elements, measures text geometry in real time, synchronizes font styles, and calculates circular paths and looping offsets based on layout and viewport size.
Container
Use [data-radial-text-marquee-init] to define the wrapper that initializes the radial marquee, creates the SVG, controls animation, and manages viewport-based play and pause. Make sure the Radial Text Marquee is wrapped in an element with overflow: hidden/clip; applied, so any horizontal overflow is clipped and no unwanted scrolling occurs.
Text
Use [data-radial-text-marquee-text] to provide the source text whose content and typography are read and mirrored into the animated SVG text path.
Radius
Use [data-radial-text-marquee-radius] (0–10, default 10) to control the curvature of the path, where lower values flatten the curve and higher values increase the arc.
Speed
Use [data-radial-text-marquee-speed] (default 4) to control how fast the marquee moves, where each step roughly equals 100px per second before responsive scaling.
Spacer
Use [data-radial-text-marquee-spacer] (default •) to insert a character between repeated text segments inside the marquee flow.
Spacer Color
Use [data-radial-text-marquee-spacer-color] to apply a different fill color to the spacer character while keeping the main text color inherited from the original text.
Spacer Padding
Use [data-radial-text-marquee-spacer-padding] (default 1) to control the spacing before and after the spacer character, affecting how tightly text segments sit together.
Resource details
Last updated
December 15, 2025
Category
Sliders & Marquees
Need help?
Join Slack




































































































































