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 "Circular text spin" for my site using the Enrichr Library element at https://enrichr.dev/elements/circular-text-spin.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/circular-text-spin.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:
- accent: #e6ff55
- speed: 24
- scrollBoost: 1.5
- hoverFactor: 4
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/circular-text-spin.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-cts">
<div class="ef-cts__wrap">
<div class="ef-cts__stage">
<svg class="ef-cts__svg" viewBox="0 0 300 300" aria-hidden="true" focusable="false"></svg>
<a class="ef-cts__badge" href="#" aria-label="Enrichr Library, build with motion">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 18 18 6M8 6h10v10" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
</div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-cts { width: 100%; }
.ef-cts__wrap { --ef-acc: #e6ff55; box-sizing: border-box; width: 100%; min-height: 18rem; padding: 1.2rem; border-radius: 1rem; display: flex; align-items: center; justify-content: center; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-cts__stage { position: relative; width: min(100%, 17rem); aspect-ratio: 1; }
.ef-cts__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.ef-cts__svg text { fill: #fbf8f1; font-size: 21px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; }
.ef-cts__badge { position: absolute; left: 50%; top: 50%; width: 28%; aspect-ratio: 1; margin: -14% 0 0 -14%; border-radius: 50%; display: grid; place-items: center; background: var(--ef-acc); color: #0c1e18; text-decoration: none; outline: none; box-shadow: 0 0 0 0 rgba(230, 255, 85, 0.35); transition: transform 400ms cubic-bezier(.2,.8,.2,1), box-shadow 400ms; }
.ef-cts__badge svg { width: 46%; height: 46%; transition: transform 400ms cubic-bezier(.2,.8,.2,1); }
.ef-cts__stage.is-hot .ef-cts__badge { transform: scale(1.1); box-shadow: 0 0 0 10px rgba(230, 255, 85, 0.18); }
.ef-cts__stage.is-hot .ef-cts__badge svg { transform: rotate(45deg); }
.ef-cts__badge:focus-visible { box-shadow: 0 0 0 3px #0c1e18, 0 0 0 5px var(--ef-acc); }
@media (prefers-reduced-motion: reduce) { .ef-cts__badge, .ef-cts__badge svg { transition: none; } .ef-cts__stage.is-hot .ef-cts__badge { transform: none; } .ef-cts__stage.is-hot .ef-cts__badge svg { transform: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Circular text spin: SVG text set on a circle that rotates continuously. Scrolling adds speed in the scroll direction, and
// hovering or focusing the centre badge multiplies the speed (a negative factor reverses it). Speed eases toward its target.
// Reduced motion: the ring stays still and no frame loop or scroll listener is created. Pauses while off screen.
// mount(root, { background, accent, speed (deg/s), scrollBoost (0-4), hoverFactor (-4 to 4), text })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-cts__wrap");
var stage = root.querySelector(".ef-cts__stage");
var svg = root.querySelector(".ef-cts__svg");
var badge = root.querySelector(".ef-cts__badge");
var NS = "http://www.w3.org/2000/svg";
var opts = {}, offs = [], raf = 0, angle = 0, vel = 0, scrollV = 0, hot = false, visible = true, last = 0, lastY = 0, ring = null, tp = null;
var R = 112, CIRC = 2 * Math.PI * R;
var uid = "ef-cts-" + Math.random().toString(36).slice(2, 8);
function on(t, ev, fn, op) { t.addEventListener(ev, fn, op); offs.push(function () { t.removeEventListener(ev, fn, op); }); }
function buildText() {
svg.textContent = "";
var defs = document.createElementNS(NS, "defs");
var path = document.createElementNS(NS, "path");
path.setAttribute("id", uid);
path.setAttribute("d", "M150 150 m-" + R + " 0 a" + R + " " + R + " 0 1 1 " + 2 * R + " 0 a" + R + " " + R + " 0 1 1 -" + 2 * R + " 0");
defs.appendChild(path); svg.appendChild(defs);
ring = document.createElementNS(NS, "g");
var t = document.createElementNS(NS, "text");
tp = document.createElementNS(NS, "textPath");
tp.setAttribute("href", "#" + uid);
tp.setAttribute("textLength", String(CIRC - 2));
tp.setAttribute("lengthAdjust", "spacing");
tp.textContent = String(opts.text).toUpperCase() + " • ";
t.appendChild(tp); ring.appendChild(t); svg.appendChild(ring);
paint();
}
function paint() { if (ring) ring.setAttribute("transform", "rotate(" + angle.toFixed(2) + " 150 150)"); }
function target() { return opts.speed * (hot ? opts.hoverFactor : 1) + scrollV * opts.scrollBoost; }
function frame(now) {
raf = 0;
if (!visible) return;
var dt = Math.min(0.05, (now - last) / 1000 || 0.016); last = now;
scrollV *= Math.pow(0.04, dt);
vel += (target() - vel) * Math.min(1, dt * 5);
angle = (angle + vel * dt) % 360;
paint();
raf = requestAnimationFrame(frame);
}
function kick() { if (!reduce && !raf && visible) { last = performance.now(); raf = requestAnimationFrame(frame); } }
function setHot(v) { hot = v; stage.classList.toggle("is-hot", v); }
function onScroll() {
var y = window.pageYOffset || document.documentElement.scrollTop || 0;
scrollV = Math.max(-600, Math.min(600, scrollV + (y - lastY) * 2.5)); lastY = y;
}
function onWheel(e) { scrollV = Math.max(-600, Math.min(600, scrollV + e.deltaY * 1.5)); }
function ck(e) { e.preventDefault(); }
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", speed: 24, scrollBoost: 1.5, hoverFactor: 4, text: "Enrichr Library • Build with motion" }, opts, next || {});
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
if (prev.text !== opts.text) buildText();
}
update(o);
lastY = window.pageYOffset || 0;
on(badge, "pointerenter", function () { setHot(true); });
on(badge, "pointerleave", function () { setHot(false); });
on(badge, "focus", function () { setHot(true); });
on(badge, "blur", function () { setHot(false); });
on(badge, "click", ck);
if (!reduce) {
on(window, "scroll", onScroll, { passive: true });
on(root, "wheel", onWheel, { passive: true });
if (window.IntersectionObserver) {
var io = new IntersectionObserver(function (es) { visible = es[0].isIntersecting; if (visible) kick(); }, { threshold: 0 });
io.observe(stage);
offs.push(function () { io.disconnect(); });
}
kick();
}
function destroy() { offs.forEach(function (f) { f(); }); offs = []; if (raf) cancelAnimationFrame(raf); raf = 0; svg.textContent = ""; ring = null; }
return { update: function (n) { update(n); kick(); }, destroy: destroy };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","speed":24,"scrollBoost":1.5,"hoverFactor":4});How it works
The text is an SVG textPath on a circle, stretched with textLength so it fills the whole ring. A requestAnimationFrame loop adds velocity times delta time to an angle and writes it as a rotate transform on the group. The target velocity is the base speed, multiplied by the hover factor while the badge is hovered or focused, plus a scroll impulse. Scroll and wheel events add to that impulse, which decays every frame, and the real velocity eases toward the target, so speed ramps and reverses smoothly instead of snapping. The loop pauses when the element is off screen.
Accessibility and performance
The rotating ring is aria-hidden and the badge carries the accessible name, so screen readers get one clear label instead of repeated moving text. The badge is a focusable link, and keyboard focus triggers the same speed change as hover plus a visible focus ring. With prefers-reduced-motion the ring is static and no frame loop or scroll listener is created. Make sure the ring text is not the only place the message appears.
One transform attribute write per frame on a single SVG group, no layout reads. The loop stops while the element is off screen via IntersectionObserver, and scroll and wheel listeners are passive. destroy() removes every listener, the observer and the pending frame.
| Section | Band |
|---|---|
| Techniques | Text animation, Scroll-driven, Pointer interaction |
| Stack | Vanilla JS, SVG, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Circular SVG Text Animation 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
How do I change the words on the ring?
Pass a text option to update(). The ring is rebuilt and the text is stretched to fit the circle, so shorter text gets wider letter spacing.
Can it spin the other way by default?
Yes. Use a negative base speed in code, or set the hover factor to a negative value so hovering reverses the spin.
Does it work inside a scrolling container?
Window scroll and wheel events on the element both feed the speed, so it still reacts when the scrolling happens inside a panel.