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 "Floating proximity gallery" for my site using the Enrichr Library element at https://enrichr.dev/elements/proximity-float-gallery.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/proximity-float-gallery.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:
- items: Pearl, Grey, Blue, King, Pink, Mint
- drift: 36
- maxScale: 1.45
- radius: 240
- background: #111827
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/proximity-float-gallery.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-pfg"><div class="ef-pfg__zone"><ul class="ef-pfg__grid"></ul></div></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-pfg__zone { position: relative; overflow: hidden; border-radius: 1rem; padding: clamp(1.6rem, 5vw, 3rem); }
.ef-pfg .ef-pfg__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); gap: clamp(1.4rem, 4vw, 2.6rem); margin: 0; padding: 0; list-style: none; }
.ef-pfg .ef-pfg__cell { position: relative; margin: 0; opacity: 1; display: grid; place-items: center; aspect-ratio: 1; }
.ef-pfg__blob, .ef-pfg__card { grid-area: 1 / 1; will-change: transform; }
.ef-pfg__blob { width: 92%; height: 92%; border-radius: 58% 42% 55% 45% / 46% 58% 42% 54%; background: var(--ef-c2); opacity: 0.35; }
.ef-pfg__card { position: relative; width: 74%; height: 74%; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 0.7rem; border-radius: 28%; background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.55), transparent 42%), linear-gradient(145deg, var(--ef-c1), var(--ef-c2)); box-shadow: 0 14px 30px rgba(0, 0, 0, 0.35); color: #fff; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); }
.ef-pfg__cell.is-near { z-index: 2; }JavaScript
// Enrichr Library · https://enrichr.dev
// Floating proximity gallery: tiles drift on their own slow, random paths with a soft blob behind each one,
// and every tile swells as the pointer comes near it (the closer, the bigger), then settles back when it leaves.
// mount(root, { items (comma separated labels), drift (px), maxScale, radius (px of pointer reach), background })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var zone = root.querySelector(".ef-pfg__zone"), grid = root.querySelector(".ef-pfg__grid");
var COLORS = [["#f1e8d5", "#b8a589"], ["#94a3b8", "#475569"], ["#60a5fa", "#2563eb"], ["#d6a972", "#7c5e26"], ["#f472b6", "#db2777"], ["#34d399", "#059669"], ["#c4b5fd", "#7c3aed"], ["#fdba74", "#ea580c"]];
var opts = {}, tiles = [], raf = 0, last = 0, t0 = 0, visible = true, io = null, ro = null;
var px = 0, py = 0, near = false;
function rand(a, b) { return a + Math.random() * (b - a); }
// A path that eases out to a random offset and back again, like a yoyo sine tween.
function path(span) { return { x: rand(-span, span), y: rand(-span * 0.8, span * 0.8), r: rand(-10, 10), T: rand(2.2, 4.2), ph: Math.random() }; }
function build() {
grid.textContent = "";
tiles = opts.items.split(",").map(function (s) { return s.trim(); }).filter(Boolean).map(function (label, i) {
var li = document.createElement("li"), blob = document.createElement("span"), card = document.createElement("span");
var c = COLORS[i % COLORS.length];
li.className = "ef-pfg__cell";
blob.className = "ef-pfg__blob"; blob.setAttribute("aria-hidden", "true");
card.className = "ef-pfg__card"; card.textContent = label;
li.style.setProperty("--ef-c1", c[0]); li.style.setProperty("--ef-c2", c[1]);
li.appendChild(blob); li.appendChild(card); grid.appendChild(li);
var a = path(opts.drift), b = path(opts.drift * 0.55);
b.r *= 0.5; b.T = rand(2.6, 5);
return { li: li, card: card, blob: blob, a: a, b: b, cx: 0, cy: 0, s: 1, dx: 0, dy: 0 };
});
measure();
}
function measure() {
var z = zone.getBoundingClientRect();
tiles.forEach(function (t) { var r = t.li.getBoundingClientRect(); t.cx = r.left - z.left + r.width / 2; t.cy = r.top - z.top + r.height / 2; });
}
function wave(p, time) { var u = (time / p.T + p.ph) % 2; return (1 - Math.cos(Math.PI * u)) / 2; }
function frame(now) {
raf = 0;
if (!visible) return;
var dt = Math.min(0.05, (now - last) / 1000 || 0.016), time = (now - t0) / 1000;
last = now;
var ease = 1 - Math.exp(-dt / 0.08);
tiles.forEach(function (t) {
var wa = reduce ? 0 : wave(t.a, time), wb = reduce ? 0 : wave(t.b, time);
t.dx = t.a.x * wa; t.dy = t.a.y * wa;
var goal = 1;
if (near && !reduce) {
var d = Math.hypot(px - (t.cx + t.dx), py - (t.cy + t.dy));
goal = 1 + (opts.maxScale - 1) * Math.max(0, Math.min(1, 1 - d / opts.radius));
}
t.s += (goal - t.s) * ease;
t.li.classList.toggle("is-near", t.s > 1.02);
t.card.style.transform = "translate3d(" + t.dx.toFixed(1) + "px," + t.dy.toFixed(1) + "px,0) rotate(" + (t.a.r * wa).toFixed(2) + "deg) scale(" + t.s.toFixed(3) + ")";
t.blob.style.transform = "translate3d(" + (t.b.x * wb).toFixed(1) + "px," + (t.b.y * wb).toFixed(1) + "px,0) rotate(" + (t.b.r * wb).toFixed(2) + "deg)";
});
if (!reduce) raf = requestAnimationFrame(frame);
}
function wake() { if (!raf && visible) { last = performance.now(); raf = requestAnimationFrame(frame); } }
function move(e) { var z = zone.getBoundingClientRect(); px = e.clientX - z.left; py = e.clientY - z.top; near = true; }
function leave() { near = false; }
function update(next) {
var prev = opts;
opts = Object.assign({ items: "Pearl, Grey, Blue, King, Pink, Mint", drift: 36, maxScale: 1.45, radius: 240, background: "#111827" }, opts, next || {});
opts.drift = Number(opts.drift); opts.maxScale = Number(opts.maxScale); opts.radius = Math.max(20, Number(opts.radius));
zone.style.background = opts.background;
if (opts.items !== prev.items || opts.drift !== prev.drift || !tiles.length) build();
wake();
}
t0 = performance.now();
update(o);
zone.addEventListener("pointermove", move);
zone.addEventListener("pointerleave", leave);
if ("ResizeObserver" in window) { ro = new ResizeObserver(measure); ro.observe(zone); }
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) wake(); }); io.observe(zone); }
return {
update: update,
destroy: function () {
cancelAnimationFrame(raf); raf = 0;
if (io) io.disconnect();
if (ro) ro.disconnect();
zone.removeEventListener("pointermove", move);
zone.removeEventListener("pointerleave", leave);
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"items":"Pearl, Grey, Blue, King, Pink, Mint","drift":36,"maxScale":1.45,"radius":240,"background":"#111827"});How it works
Each tile and the blob behind it get their own random offset, tilt and duration (2.2 to 4.2 seconds for tiles, slower for blobs) and ease out to that offset and back forever on a cosine curve, the same shape as a yoyo sine tween, each starting at a random point so nothing moves in step. The grid cells themselves never move, so their centres are measured once (and again on resize) instead of every frame. While the pointer is over the gallery, each tile's distance from the pointer is mapped to a size: full size at the reach distance, the biggest size right under the pointer. Sizes ease toward their goal so they swell and shrink smoothly, and a tile that is growing is raised above its neighbours.
Accessibility and performance
Labels are real text inside a list, and the blobs are hidden from assistive technology. With reduced motion the tiles hold still and do not grow. Put real links or images inside the tiles if they lead somewhere; the scaling never changes the layout, so focus order stays the grid order.
One requestAnimationFrame loop drives every tile and pauses when the gallery is off screen. Each frame writes two transforms per tile and reads no layout. Keep it to a dozen or so tiles; for big grids, lower the drift or drop the blobs.
| Section | Gallery |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Oyster Mushrooms Co. by Homeless Romantic Creative on CodePen. 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 images instead of colour tiles?
Put an img inside each tile card and give it width: 100% and object-fit: contain. Transparent PNG or WebP cut-outs look best floating over the blobs.
Can the tiles stay still and only grow?
Set the drift distance to 0. The blobs and tiles stop drifting but still swell near the pointer.
What about touch screens?
There is no hover on touch, so the tiles just drift. Tapping does not trigger the swell.