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 "Text trail on mousemove" for my site using the Enrichr Library element at https://enrichr.dev/elements/text-trail-mousemove.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/text-trail-mousemove.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
- words: Make,Move,Motion,Trail
- spacing: 46
- life: 1400
- size: 44
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/text-trail-mousemove.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-ttm">
<div class="ef-ttm__stage" tabindex="0">
<p class="ef-ttm__hint">Move your pointer</p>
<div class="ef-ttm__layer" aria-hidden="true"></div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-ttm { width: 100%; }
.ef-ttm__stage { position: relative; box-sizing: border-box; width: 100%; min-height: 20rem; border-radius: 1rem; overflow: hidden; display: flex; align-items: center; justify-content: center; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; touch-action: pan-y; cursor: crosshair; outline: none; }
.ef-ttm__stage:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-ttm__hint { margin: 0; position: relative; z-index: 0; color: #fbf8f1; opacity: 0.35; font-size: 0.8rem; letter-spacing: 0.22em; text-transform: uppercase; pointer-events: none; transition: opacity 300ms; }
.ef-ttm__layer { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.ef-ttm__w { position: absolute; left: 0; top: 0; white-space: nowrap; font-weight: 800; letter-spacing: -0.02em; line-height: 1; color: var(--ef-acc, #e6ff55); opacity: 0; will-change: transform, opacity; }
.ef-ttm__w.is-alt { color: #fbf8f1; }JavaScript
// Enrichr Library · https://enrichr.dev
// Text trail on mousemove: words spawn along the pointer path, spin as they drift and fade out. Spawns are spaced by distance travelled, not time, so fast and slow moves are evenly dotted.
// A pooled set of spans is recycled by one rAF loop that only runs while words are alive or the idle demo is playing. When the pointer is idle (or the stage has keyboard focus) a gentle demo path plays. Reduced motion: no trail, only a static word.
// mount(root, { background, accent, words (comma list), spacing (px), life (ms), size (px), rotate (deg) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var stage = root.querySelector(".ef-ttm__stage");
var layer = root.querySelector(".ef-ttm__layer");
var hint = root.querySelector(".ef-ttm__hint");
var opts = {}, pool = [], live = [], list = ["Move"], wi = 0, raf = 0, dead = false;
var lastX = null, lastY = null, acc = 0, lastMove = 0, focused = false, MAX = 40, IDLE = 1800;
function make() {
var s = document.createElement("span"); s.className = "ef-ttm__w"; layer.appendChild(s);
return { el: s, x: 0, y: 0, r: 0, spin: 0, born: 0, dx: 0, dy: 0 };
}
function spawn(x, y, ux, uy) {
var w = pool.pop() || (live.length < MAX ? make() : live.shift());
wi++;
w.el.textContent = list[wi % list.length];
w.el.classList.toggle("is-alt", wi % 4 === 0);
w.el.style.fontSize = opts.size + "px";
w.x = x; w.y = y; w.born = performance.now();
w.r = (Math.random() * 2 - 1) * opts.rotate * 0.4;
w.spin = (Math.random() < 0.5 ? -1 : 1) * opts.rotate * (0.5 + Math.random() * 0.5);
w.dx = ux * 18; w.dy = uy * 18 - 14;
live.push(w);
}
function move(x, y) {
if (lastX !== null) {
var dx = x - lastX, dy = y - lastY, d = Math.sqrt(dx * dx + dy * dy);
acc += d;
if (acc >= opts.spacing) { spawn(x, y, dx / (d || 1), dy / (d || 1)); acc = 0; }
}
lastX = x; lastY = y;
}
function tick(now) {
raf = 0;
if (dead) return;
if (now - lastMove > IDLE || focused) {
var r = stage.getBoundingClientRect(), t = now / 900;
move(r.width / 2 + Math.cos(t) * r.width * 0.32, r.height / 2 + Math.sin(t * 1.7) * r.height * 0.28);
}
for (var i = live.length - 1; i >= 0; i--) {
var w = live[i], p = (now - w.born) / opts.life;
if (p >= 1) { w.el.style.opacity = "0"; live.splice(i, 1); pool.push(w); continue; }
var e = 1 - Math.pow(1 - p, 3), a = p < 0.12 ? p / 0.12 : 1 - (p - 0.12) / 0.88;
w.el.style.opacity = a.toFixed(3);
w.el.style.transform = "translate(" + (w.x + w.dx * e).toFixed(1) + "px," + (w.y + w.dy * e).toFixed(1) + "px) translate(-50%,-50%) rotate(" + (w.r + w.spin * e).toFixed(1) + "deg) scale(" + (0.7 + 0.5 * Math.min(1, p * 3)).toFixed(3) + ")";
}
raf = requestAnimationFrame(tick);
}
function onMove(e) {
var r = stage.getBoundingClientRect(), now = performance.now();
if (now - lastMove > IDLE) { lastX = null; acc = 0; }
lastMove = now;
hint.style.opacity = "0";
move(e.clientX - r.left, e.clientY - r.top);
}
function onLeave() { lastX = null; acc = 0; lastMove = performance.now() - IDLE + 400; }
function onFocus() { focused = true; lastX = null; }
function onBlur() { focused = false; lastX = null; }
function update(next) {
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", words: "Make,Move,Motion,Trail", spacing: 46, life: 1400, size: 44, rotate: 60 }, opts, next || {});
stage.style.background = opts.background;
stage.style.setProperty("--ef-acc", opts.accent);
list = String(opts.words).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
if (!list.length) list = ["Move"];
if (reduce) hint.textContent = list[0];
}
update(o);
if (!reduce) {
stage.addEventListener("pointermove", onMove);
stage.addEventListener("pointerleave", onLeave);
stage.addEventListener("focus", onFocus);
stage.addEventListener("blur", onBlur);
lastMove = performance.now() - IDLE;
raf = requestAnimationFrame(tick);
} else {
hint.style.opacity = "0.8"; hint.style.fontSize = opts.size + "px"; hint.style.textTransform = "none"; hint.style.letterSpacing = "-0.02em"; hint.style.fontWeight = "800";
}
return {
update: function (n) { update(n); if (reduce) hint.style.fontSize = opts.size + "px"; },
destroy: function () {
dead = true;
if (raf) cancelAnimationFrame(raf); raf = 0;
stage.removeEventListener("pointermove", onMove); stage.removeEventListener("pointerleave", onLeave);
stage.removeEventListener("focus", onFocus); stage.removeEventListener("blur", onBlur);
layer.textContent = ""; pool = []; live = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","words":"Make,Move,Motion,Trail","spacing":46,"life":1400,"size":44});How it works
On pointermove the script converts the pointer to stage coordinates and adds the distance travelled since the last point. Each time that total passes the spacing, it takes a word from a pooled set of spans, places it at the pointer, gives it a random rotation and a spin direction, and records its start time. One requestAnimationFrame loop eases every live word along a short drift, spins it, fades it in then out, and returns the span to the pool when its lifetime ends. Spawning by distance rather than time keeps the dots of the trail evenly spaced at any speed. When no pointer has moved for a moment, or the stage has keyboard focus, the same code is fed a looping demo path.
Accessibility and performance
The trail is decorative and sits in an aria-hidden layer, so screen readers are not flooded with repeated words. The stage is focusable and plays the demo path on focus, so keyboard users see the effect too, and it shows a focus ring. With prefers-reduced-motion nothing spawns or spins; a single static word is shown instead. If you use the trail to carry real content, repeat that content in normal text elsewhere.
The number of spans is capped and recycled, so no nodes are created while moving once the pool is warm. Only transform and opacity change. The loop runs continuously while mounted because of the idle demo; destroy() cancels it. Avoid very large font sizes together with long lifetimes on low-powered devices.
| Section | Text |
|---|---|
| Techniques | Text animation, Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Text Trail Effect on Mousemove by Kiril Bernard Tucker 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
Can I use letters instead of words?
Yes. Put single characters in the words list, for example a,b,c, and lower the spacing.
Does it work on touch?
Pointer events fire for touch drags, and the stage allows vertical scrolling, so dragging sideways draws the trail while vertical swipes still scroll the page.
How do I stop the idle demo?
Remove the idle branch in the tick function; the trail will then only appear while the pointer moves.