Copied!
Gallery

Floating proximity gallery

Tiles drift on slow random paths over soft blobs and swell as the pointer gets close, the nearest one most.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

CursorElementVanilla JS + CSS

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.

SectionGallery
TechniquesPointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byOyster 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.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

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.