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 "Repetition image hover trail" for my site using the Enrichr Library element at https://enrichr.dev/elements/repetition-image-hover.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/repetition-image-hover.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
- copies: 6
- size: 38
- lag: 0.5
- fade: 600
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/repetition-image-hover.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-rih">
<div class="ef-rih__wrap"><div class="ef-rih__grid"></div></div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-rih { width: 100%; }
.ef-rih__wrap { box-sizing: border-box; width: 100%; min-height: 18rem; padding: 1.3rem; border-radius: 1rem; display: flex; align-items: center; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-rih__grid { width: 100%; display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 1rem; }
.ef-rih__tile { position: relative; display: block; aspect-ratio: 4 / 5; border-radius: 0.9rem; overflow: hidden; color: #fbf8f1; text-decoration: none; outline: none; cursor: pointer; box-shadow: 0 8px 14px -8px rgba(0, 0, 0, 0.5); transition: box-shadow 300ms; -webkit-tap-highlight-color: transparent; }
.ef-rih__tile:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55), 0 14px 24px -12px rgba(0, 0, 0, 0.65); }
.ef-rih__art { position: absolute; inset: 0; isolation: isolate; overflow: hidden; }
.ef-rih__art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), transparent 30%, transparent 50%, rgba(0, 0, 0, 0.62)); }
.ef-rih__layer { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.ef-rih__c { isolation: isolate; overflow: hidden; position: absolute; left: 0; top: 0; aspect-ratio: 3 / 4; border-radius: 0.55rem; opacity: 0; visibility: hidden; pointer-events: none; will-change: transform, opacity; box-shadow: 0 6px 14px -4px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.14) inset; }
.ef-rih__still { isolation: isolate; overflow: hidden; position: absolute; left: 50%; top: 46%; width: 44%; aspect-ratio: 3 / 4; border-radius: 0.55rem; transform: translate(-50%, -50%); opacity: 0; transition: opacity 200ms; pointer-events: none; box-shadow: 0 6px 14px -4px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.14) inset; }
.ef-rih__still.is-on { opacity: 1; }
.ef-rih__txt { position: absolute; left: 0.9rem; right: 0.9rem; bottom: 0.85rem; pointer-events: none; }
.ef-rih__t { display: block; font-size: 1.05rem; font-weight: 800; letter-spacing: -0.01em; line-height: 1.15; text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
.ef-rih__s { display: block; margin-top: 0.2rem; font-size: 0.72rem; opacity: 0.8; letter-spacing: 0.04em; }
.ef-rih__n { position: absolute; top: 0.8rem; left: 0.9rem; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em; opacity: 0.85; pointer-events: none; }
@media (prefers-reduced-motion: reduce) { .ef-rih__tile, .ef-rih__still { transition: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Repetition image hover: a gradient "image" tile that, on pointer enter, spawns a staggered trail of repeated copies of itself.
// Each copy chases the pointer with a little more lag than the one before it, so they echo the movement, then fade out on leave.
// One shared rAF loop runs only while a trail is moving. Mouse and pen follow the pointer; keyboard focus plays the trail toward the
// tile centre. Reduced motion shows a single static copy instead. Copies live inside the clipped tile, so overflow:hidden parents are fine.
// mount(root, { background, accent, copies (2-10), size (% of tile width), lag (0.1-1), fade (ms) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-rih__wrap");
var grid = root.querySelector(".ef-rih__grid");
var opts = {}, tiles = [], offs = [], raf = 0, last = 0;
var STAGGER = 55;
var DATA = [["Tidal", "Field notes"], ["Ember", "Studio series"], ["Moss", "Quiet objects"], ["Dusk", "Night shift"], ["Arch", "Form study"]];
// Enrichr puzzle-piece artwork, inlined so the code stays portable. piece(i, accent) returns a decorative <svg> string (viewBox + one path,
// currentColor fill), a different piece, size, rotation and crop per index. It sits behind the card text and never takes pointer events.
var PIECES = [["1513.7 1453.9 322.3 322.3","M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"],["1423.36 860.8 503.07 322.3","M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"],["1513.7 166.86 322.3 503.06","M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"]];
var PLAY = [[38, -12, 95, 16, 0, 0.92], [-30, 28, 150, -22, "#2f7d5b", 0.95], [-8, -30, 80, 32, "#c6f26b", 0.8], [30, 34, 110, -8, "#0a6b46", 0.95], [-24, -10, 135, 12, "#fbf8f1", 0.2], [46, 20, 90, -28, 0, 0.85]];
function piece(i, a) {
var p = PIECES[(i + Math.floor(i / 3)) % 3], s = PLAY[i % 6];
return '<svg viewBox="' + p[0] + '" aria-hidden="true" focusable="false" style="position:absolute;z-index:-1;pointer-events:none;height:auto;left:' + s[0] + '%;top:' + s[1] + '%;width:' + s[2] + '%;transform:rotate(' + s[3] + 'deg);color:' + (s[4] || a || "#e6ff55") + ';opacity:' + s[5] + '"><path fill="currentColor" d="' + p[1] + '"/></svg>';
}
function art(i, a) {
var c = "#fbf8f1", d = "#0c1e18";
var p = i % 5;
if (p === 0) return "radial-gradient(circle at 70% 30%, " + a + " 0 18%, transparent 19%), linear-gradient(160deg, #005236, " + d + ")";
if (p === 1) return "conic-gradient(from 210deg at 35% 65%, #005236, " + a + ", " + c + ", #005236)";
if (p === 2) return "repeating-linear-gradient(120deg, #005236 0 12px, " + d + " 12px 24px)";
if (p === 3) return "radial-gradient(circle at 30% 35%, " + a + " 0 20%, transparent 21%), radial-gradient(circle at 62% 62%, " + c + " 0 20%, transparent 21%), linear-gradient(200deg, #0a6b46, " + d + ")";
return "radial-gradient(circle, " + a + " 0 2.5px, transparent 3px) 0 0 / 16px 16px, linear-gradient(145deg, #005236, " + d + ")";
}
function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; }
function clear() { stop(); offs.forEach(function (f) { f(); }); offs = []; tiles = []; }
function start() { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } }
function resetPool(T) {
T.copies.forEach(function (c) { if (c.el.parentNode) c.el.parentNode.removeChild(c.el); });
T.copies = []; T.active = false; T.live = false;
T.still.classList.remove("is-on");
}
function ensurePool(T) {
if (T.copies.length) return;
for (var i = 0; i < opts.copies; i++) {
var el = document.createElement("span");
el.className = "ef-rih__c";
el.style.background = T.bg;
el.insertAdjacentHTML("afterbegin", T.pc);
el.style.width = opts.size + "%";
T.layer.appendChild(el);
T.copies.push({ el: el, x: 0, y: 0, o: 0, placed: false });
}
}
function spawn(T, x, y, fx, fy) {
if (reduce) { T.still.classList.add("is-on"); return; }
ensurePool(T);
T.active = true; T.live = true; T.at = performance.now();
T.tx = x; T.ty = y; T.fx = fx; T.fy = fy;
T.copies.forEach(function (c) { c.placed = false; });
start();
}
function leave(T) {
if (reduce) { T.still.classList.remove("is-on"); return; }
if (!T.active) return;
T.active = false; T.live = true;
start();
}
function tick(now) {
raf = 0;
var dt = last ? Math.min(64, now - last) : 16;
last = now;
var any = false;
tiles.forEach(function (T) {
if (!T.live) return;
var n = T.copies.length, busy = false;
for (var i = 0; i < n; i++) {
var c = T.copies[i], to = 1 - (i / n) * 0.7;
if (T.active) {
if (now - T.at - i * STAGGER < 0) { busy = true; continue; }
if (!c.placed) { c.x = T.fx; c.y = T.fy; c.placed = true; }
var f = 0.42 / (1 + i * opts.lag * 2);
var k = 1 - Math.pow(1 - f, dt / 16.67);
c.x += (T.tx - c.x) * k; c.y += (T.ty - c.y) * k;
c.o += (to - c.o) * (1 - Math.pow(0.8, dt / 16.67));
if (Math.abs(T.tx - c.x) > 0.3 || Math.abs(T.ty - c.y) > 0.3 || Math.abs(to - c.o) > 0.01) busy = true;
} else if (c.placed) {
var k2 = 1 - Math.pow(1 - 0.12, dt / 16.67);
c.x += (T.tx - c.x) * k2; c.y += (T.ty - c.y) * k2;
c.o -= dt / (opts.fade * (1 - (i / n) * 0.5));
if (c.o <= 0) { c.o = 0; c.placed = false; } else busy = true;
}
if (c.placed && c.o > 0.002) {
var rot = Math.max(-8, Math.min(8, (T.tx - c.x) * 0.06));
c.el.style.visibility = "visible";
c.el.style.opacity = c.o.toFixed(3);
c.el.style.transform = "translate3d(" + c.x.toFixed(1) + "px," + c.y.toFixed(1) + "px,0) translate(-50%,-50%) rotate(" + rot.toFixed(2) + "deg) scale(" + (1 - i * 0.035).toFixed(3) + ")";
} else {
c.el.style.visibility = "hidden"; c.el.style.opacity = "0";
}
}
if (!busy) T.live = false; else any = true;
});
if (any) raf = requestAnimationFrame(tick);
}
function build() {
clear();
grid.textContent = "";
for (var i = 0; i < 3; i++) (function (i) {
var a = document.createElement("a");
a.className = "ef-rih__tile";
a.href = "#";
var bg = art(i, opts.accent), pc = piece(i, opts.accent);
var artEl = document.createElement("span"); artEl.className = "ef-rih__art"; artEl.style.background = bg; artEl.insertAdjacentHTML("afterbegin", pc);
var layer = document.createElement("span"); layer.className = "ef-rih__layer"; layer.setAttribute("aria-hidden", "true");
var still = document.createElement("span"); still.className = "ef-rih__still"; still.style.background = bg; still.insertAdjacentHTML("afterbegin", pc); still.setAttribute("aria-hidden", "true");
var n = document.createElement("span"); n.className = "ef-rih__n"; n.textContent = String(i + 1).padStart(2, "0");
var tx = document.createElement("span"); tx.className = "ef-rih__txt";
var t = document.createElement("span"); t.className = "ef-rih__t"; t.textContent = DATA[i][0];
var s = document.createElement("span"); s.className = "ef-rih__s"; s.textContent = DATA[i][1];
tx.appendChild(t); tx.appendChild(s);
a.appendChild(artEl); a.appendChild(layer); a.appendChild(still); a.appendChild(n); a.appendChild(tx);
var T = { el: a, layer: layer, still: still, bg: bg, pc: pc, copies: [], tx: 0, ty: 0, fx: 0, fy: 0, at: 0, active: false, live: false };
function pos(e) { var r = a.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }
function enter(e) { if (e.pointerType === "touch") return; var p = pos(e); spawn(T, p[0], p[1], p[0], p[1]); }
function mv(e) {
if (e.pointerType === "touch" || reduce) return;
var p = pos(e);
if (!T.active) { spawn(T, p[0], p[1], p[0], p[1]); return; }
T.tx = p[0]; T.ty = p[1];
if (!T.live) { T.live = true; start(); }
}
function lv() { leave(T); }
function fo() {
var vis = true;
try { vis = a.matches(":focus-visible"); } catch (err) {}
if (!vis) return;
var w = a.offsetWidth, h = a.offsetHeight;
spawn(T, w / 2, h * 0.46, w * 0.12, h * 0.85);
}
function ck(e) { e.preventDefault(); }
a.addEventListener("pointerenter", enter);
a.addEventListener("pointermove", mv);
a.addEventListener("pointerleave", lv);
a.addEventListener("focus", fo);
a.addEventListener("blur", lv);
a.addEventListener("click", ck);
offs.push(function () {
a.removeEventListener("pointerenter", enter); a.removeEventListener("pointermove", mv); a.removeEventListener("pointerleave", lv);
a.removeEventListener("focus", fo); a.removeEventListener("blur", lv); a.removeEventListener("click", ck);
});
grid.appendChild(a);
tiles.push(T);
})(i);
}
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", copies: 6, size: 38, lag: 0.5, fade: 600 }, opts, next || {});
opts.copies = Math.max(2, Math.min(10, Math.round(opts.copies)));
opts.size = Math.max(15, Math.min(70, opts.size));
opts.lag = Math.max(0.05, Math.min(1.5, opts.lag));
opts.fade = Math.max(100, opts.fade);
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
if (prev.accent !== opts.accent) build();
else if (prev.copies !== opts.copies || prev.size !== opts.size) tiles.forEach(resetPool);
}
update(o);
return { update: update, destroy: function () { clear(); grid.textContent = ""; } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","copies":6,"size":38,"lag":0.5,"fade":600});How it works
On pointer enter the tile builds a small pool of copy elements, each a miniature of the tile artwork, and starts them one after another a few tens of milliseconds apart. A single requestAnimationFrame loop moves every copy toward the pointer position inside the tile using frame-rate independent easing, where copy number i uses a smaller easing factor than copy i minus one, so the copies string out behind the pointer like an echo. Opacity and scale step down along the trail and each copy tilts slightly with its distance from the pointer. On leave the copies keep drifting and fade out in order, and the loop stops once everything is hidden or has settled. Copies sit inside the tile with overflow hidden, so they never spill out of a clipped container.
Accessibility and performance
Tiles are links, so they stay keyboard reachable with a visible focus ring. Keyboard focus plays the same trail toward the tile centre without needing a pointer. The copies are decorative and hidden from assistive technology. With prefers-reduced-motion the trail is replaced by one static copy that fades in on hover or focus. Touch input does not spawn a trail.
One shared requestAnimationFrame loop, running only while a trail is moving, and each copy is positioned with transform and opacity only. The copy pool is created lazily per tile and reused. Keep the copy count under about ten and avoid large blurred shadows on the copies.
| Section | Gallery |
|---|---|
| Techniques | Pointer interaction, Stagger |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Repetition Image Hover Effects 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 use real photos instead of gradients?
Set the background of the tile art, the copies and the static copy to your image. The copies take the same background value, so they stay in step with the tile.
Why do the copies lag differently?
Each copy uses an easing factor of 0.42 divided by one plus its index times the lag value times two. A bigger lag value stretches the trail out further.
Does it work inside a clipped container?
Yes. The copies are positioned inside the tile, which clips them itself, and no listener is attached outside the tiles.