Copied!
Gallery

Dome grid 3D

A grid of puzzle-piece tiles wrapped onto the inside of a sphere that you drag to turn with inertia; tiles face the viewer and fade with depth.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

3DElementVanilla 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 "Dome grid 3D" for my site using the Enrichr Library element at https://enrichr.dev/elements/dome-grid-3d.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/dome-grid-3d.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
- columns: 16
- depth: 0.5
- inertia: 0.6
- autoSpin: 0.3

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/dome-grid-3d.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-dg">
  <svg class="ef-dg__defs" width="0" height="0" aria-hidden="true" focusable="false">
    <defs>
      <symbol id="efdg-p1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="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"/></symbol>
      <symbol id="efdg-p4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="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"/></symbol>
      <symbol id="efdg-p8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="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"/></symbol>
    </defs>
  </svg>
  <div class="ef-dg__wrap" tabindex="0" role="group" aria-roledescription="3D dome" aria-label="Dome of puzzle tiles. Drag, scroll or use the arrow keys to rotate it.">
    <div class="ef-dg__stage"><div class="ef-dg__scene"></div></div>
    <span class="ef-dg__vig" aria-hidden="true"></span>
    <span class="ef-dg__hint" aria-hidden="true">Drag to turn the dome</span>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-dg { width: 100%; height: 100%; }
.ef-dg__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-dg__wrap { --ef-acc: #e6ff55; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 26rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; outline: none; }
.ef-dg__wrap.is-drag { cursor: grabbing; }
.ef-dg__wrap:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc); }
.ef-dg__stage { position: absolute; inset: 0; perspective: 900px; perspective-origin: 50% 50%; }
.ef-dg__scene { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform-style: preserve-3d; will-change: transform; }
.ef-dg__tile { position: absolute; left: 0; top: 0; border-radius: 12%; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; box-shadow: 0 0 0 1px rgba(251, 248, 241, 0.08) inset; }
.ef-dg__tile svg { position: absolute; left: 16%; top: 16%; width: 68%; height: 68%; display: block; overflow: visible; }
.ef-dg__vig { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(ellipse at 50% 50%, transparent 46%, rgba(12, 30, 24, 0.85) 100%); }
.ef-dg__hint { position: absolute; left: 50%; bottom: 0.9rem; transform: translateX(-50%); padding: 0.35rem 0.8rem; border-radius: 99px; background: rgba(12, 30, 24, 0.75); color: #fbf8f1; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; pointer-events: none; transition: opacity 400ms; }
.ef-dg__wrap.is-used .ef-dg__hint { opacity: 0; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Dome grid 3D: puzzle-piece tiles laid out on the inside of a sphere. Every tile faces the centre, so it faces the viewer; drag to rotate the
// dome with inertia, scroll or use the arrow keys. Tiles fade as they swing away, which sells the depth. Pure CSS 3D transforms + one rAF loop.
// Reduced motion: no inertia, no auto-spin; the dome turns only while you drag and keys step it instantly.
// mount(root, { accent, columns (8-24), depth (0-1 fade strength), inertia (0-1), autoSpin (0-1) })
function mount(root, o) {
  var mq = window.matchMedia("(prefers-reduced-motion: reduce)");
  var wrap = root.querySelector(".ef-dg__wrap");
  var stage = root.querySelector(".ef-dg__stage");
  var scene = root.querySelector(".ef-dg__scene");
  var opts = {}, tiles = [], offs = [], raf = 0, last = 0, onScreen = true, ro = null, io = null;
  var rx = -8, ry = 0, vx = 0, vy = 0, drag = null, dirty = true, size = { w: 0, h: 0 };
  var D2R = Math.PI / 180;
  var LIME = "#e6ff55";
  var SYMS = ["efdg-p1", "efdg-p4", "efdg-p8"];

  function rnd(i) { var x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); }
  function palette(a) {
    return [["#005236", a], ["#0a6b46", "#fbf8f1"], ["#2f7d5b", "#c6f26b"], ["#c6f26b", "#005236"], ["#fbf8f1", "#0a6b46"], ["#0c1e18", "#c6f26b"], [a, "#0c1e18"], ["#005236", "#fbf8f1"]];
  }
  function clearOffs() { offs.forEach(function (f) { f(); }); offs = []; }

  function build() {
    scene.textContent = "";
    tiles = [];
    var W = wrap.clientWidth || 520, H = wrap.clientHeight || 420;
    size = { w: W, h: H };
    var R = Math.max(W, H) * 0.56;
    stage.style.perspective = Math.round(R * 1.5) + "px";
    var cols = opts.columns;
    var tw = (2 * Math.PI * R / cols) * 0.8;
    var dLat = (tw * 1.1 / R) / D2R;
    var latMax = 64;
    var rows = Math.floor(2 * latMax / dLat) + 1;
    var pal = palette(opts.accent);
    var k = 0;
    for (var r = 0; r < rows; r++) {
      var lat = -latMax + r * dLat + (2 * latMax - (rows - 1) * dLat) / 2;
      var cl = Math.cos(lat * D2R);
      var n = Math.max(5, Math.round(cols * cl));
      var step = 360 / n;
      for (var c = 0; c < n; c++) {
        var lon = c * step + (r % 2) * step / 2;
        var t = document.createElement("div");
        t.className = "ef-dg__tile";
        var pc = pal[Math.floor(rnd(k) * pal.length)];
        t.style.width = tw.toFixed(1) + "px";
        t.style.height = tw.toFixed(1) + "px";
        t.style.background = pc[0];
        t.style.color = pc[1];
        t.style.transform = "rotateY(" + lon.toFixed(2) + "deg) rotateX(" + lat.toFixed(2) + "deg) translateZ(" + (-R).toFixed(1) + "px) translate(-50%,-50%) rotate(" + (Math.floor(rnd(k + 7) * 4) * 90) + "deg)";
        var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        svg.setAttribute("aria-hidden", "true");
        var use = document.createElementNS("http://www.w3.org/2000/svg", "use");
        use.setAttribute("href", "#" + SYMS[Math.floor(rnd(k + 3) * 3)]);
        svg.appendChild(use);
        t.appendChild(svg);
        scene.appendChild(t);
        var lr = lon * D2R, ar = lat * D2R;
        tiles.push({ el: t, x: -Math.cos(ar) * Math.sin(lr), y: Math.sin(ar), z: -Math.cos(ar) * Math.cos(lr), op: -1 });
        k++;
      }
    }
    dirty = true;
    render();
  }

  function render() {
    dirty = false;
    scene.style.transform = "rotateX(" + rx.toFixed(2) + "deg) rotateY(" + ry.toFixed(2) + "deg)";
    var a = rx * D2R, b = ry * D2R, sa = Math.sin(a), ca = Math.cos(a), sb = Math.sin(b), cb = Math.cos(b);
    var span = 0.18 + 0.7 * opts.depth;
    var start = 0.1;
    for (var i = 0; i < tiles.length; i++) {
      var t = tiles[i];
      var z1 = -t.x * sb + t.z * cb;
      var z2 = t.y * sa + z1 * ca;
      var cs = -z2;
      var op = cs <= start ? 0 : Math.min(1, (cs - start) / span);
      op = Math.round(op * 50) / 50;
      if (op !== t.op) {
        t.op = op;
        t.el.style.opacity = op;
        t.el.style.visibility = op === 0 ? "hidden" : "visible";
      }
    }
  }

  function clampX() { rx = Math.max(-48, Math.min(48, rx)); }
  function frame(now) {
    raf = 0;
    var dt = Math.min(0.05, (now - last) / 1000 || 0.016);
    last = now;
    var reduce = mq.matches;
    if (!drag && !reduce) {
      var decay = Math.exp(-dt * (9 - opts.inertia * 7.6));
      ry += vx * dt; rx += vy * dt;
      vx *= decay; vy *= decay;
      if (Math.abs(vx) < 0.05) vx = 0;
      if (Math.abs(vy) < 0.05) vy = 0;
      if (opts.autoSpin > 0) ry += opts.autoSpin * 10 * dt;
      clampX();
      dirty = true;
    }
    if (dirty) render();
    schedule();
  }
  function schedule() {
    if (raf || !onScreen) return;
    var moving = vx || vy || drag || dirty || (opts.autoSpin > 0 && !mq.matches);
    if (moving) raf = requestAnimationFrame(frame);
  }
  function kick() { last = performance.now(); schedule(); }

  // pointer drag
  function down(e) {
    if (e.pointerType === "mouse" && e.button !== 0) return;
    drag = { id: e.pointerId, x: e.clientX, y: e.clientY, t: performance.now() };
    vx = 0; vy = 0;
    wrap.classList.add("is-drag", "is-used");
    try { wrap.setPointerCapture(e.pointerId); } catch (err) {}
    kick();
  }
  function move(e) {
    if (!drag || e.pointerId !== drag.id) return;
    var now = performance.now();
    var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
    var dts = Math.max(0.008, (now - drag.t) / 1000);
    var k = 0.28;
    ry -= dx * k; rx += dy * k;
    clampX();
    if (!mq.matches) {
      vx = vx * 0.5 + (-dx * k / dts) * 0.5;
      vy = vy * 0.5 + (dy * k / dts) * 0.5;
      var cap = 600; vx = Math.max(-cap, Math.min(cap, vx)); vy = Math.max(-cap, Math.min(cap, vy));
    }
    drag.x = e.clientX; drag.y = e.clientY; drag.t = now;
    dirty = true; schedule();
  }
  function up(e) {
    if (!drag || e.pointerId !== drag.id) return;
    if (performance.now() - drag.t > 90) { vx = 0; vy = 0; }
    drag = null;
    wrap.classList.remove("is-drag");
    try { wrap.releasePointerCapture(e.pointerId); } catch (err) {}
    kick();
  }
  // wheel is captured only while the pointer is over the element (listener lives on it)
  function wheel(e) {
    e.preventDefault();
    wrap.classList.add("is-used");
    var dx = e.deltaX, dy = e.deltaY;
    if (e.shiftKey && !dx) { dx = dy; dy = 0; }
    var k = e.deltaMode === 1 ? 14 : 1;
    ry += dx * k * 0.12; rx -= dy * k * 0.12;
    clampX();
    dirty = true;
    if (!mq.matches) { vx = dx * k * 0.4; vy = -dy * k * 0.4; }
    kick();
  }
  function key(e) {
    var s = 0, t = 0;
    if (e.key === "ArrowRight") s = 1; else if (e.key === "ArrowLeft") s = -1;
    else if (e.key === "ArrowDown") t = 1; else if (e.key === "ArrowUp") t = -1;
    else return;
    e.preventDefault();
    wrap.classList.add("is-used");
    if (mq.matches) { ry += s * 18; rx += t * 12; clampX(); dirty = true; }
    else { vx += s * 110; vy += t * 70; }
    kick();
  }

  function attach() {
    wrap.addEventListener("pointerdown", down);
    wrap.addEventListener("pointermove", move);
    wrap.addEventListener("pointerup", up);
    wrap.addEventListener("pointercancel", up);
    wrap.addEventListener("wheel", wheel, { passive: false });
    wrap.addEventListener("keydown", key);
    offs.push(function () {
      wrap.removeEventListener("pointerdown", down);
      wrap.removeEventListener("pointermove", move);
      wrap.removeEventListener("pointerup", up);
      wrap.removeEventListener("pointercancel", up);
      wrap.removeEventListener("wheel", wheel);
      wrap.removeEventListener("keydown", key);
    });
    if (window.ResizeObserver) {
      ro = new ResizeObserver(function () {
        if (Math.abs(wrap.clientWidth - size.w) > 2 || Math.abs(wrap.clientHeight - size.h) > 2) build();
      });
      ro.observe(wrap);
    }
    if (window.IntersectionObserver) {
      io = new IntersectionObserver(function (en) { onScreen = en[0].isIntersecting; if (onScreen) kick(); });
      io.observe(wrap);
    }
  }

  function update(next) {
    var prev = opts;
    opts = Object.assign({ accent: LIME, columns: 16, depth: 0.5, inertia: 0.6, autoSpin: 0.3 }, opts, next || {});
    opts.columns = Math.max(8, Math.min(24, Math.round(opts.columns)));
    opts.depth = Math.max(0, Math.min(1, +opts.depth));
    opts.inertia = Math.max(0, Math.min(1, +opts.inertia));
    opts.autoSpin = Math.max(0, Math.min(1, +opts.autoSpin));
    wrap.style.setProperty("--ef-acc", opts.accent);
    if (prev.columns !== opts.columns || prev.accent !== opts.accent) build(); else dirty = true;
    kick();
  }
  attach();
  update(o);
  return {
    update: update,
    destroy: function () {
      clearOffs();
      if (raf) cancelAnimationFrame(raf);
      raf = 0; onScreen = false;
      if (ro) ro.disconnect();
      if (io) io.disconnect();
      scene.textContent = ""; tiles = [];
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","columns":16,"depth":0.5,"inertia":0.6,"autoSpin":0.3});

How it works

Tiles are placed in rows of latitude, with fewer tiles in the rows nearer the poles so spacing stays even. Each tile gets rotateY(lon) rotateX(lat) translateZ(-radius), which puts it on the far inside of the sphere with its face pointing at the centre, so it always faces the viewer. The whole scene is one element rotated with rotateX and rotateY. Dragging adds to the two angles and records a velocity; a single requestAnimationFrame loop applies that velocity with exponential decay, and stops itself when the dome is still. Each frame the script works out how directly each tile faces the camera from its fixed direction vector and fades it with opacity, hiding the ones turned away.

Accessibility and performance

The dome is one focusable group with a label; arrow keys turn it, so no pointer is needed. The tiles are decorative and hidden from assistive tech. Under reduced motion there is no auto spin and no inertia: the dome moves only while dragged, and keys step it instantly. The wheel is captured only while the pointer is over the dome.

About 100 tiles with transform and opacity only, updated in one loop that sleeps when nothing moves or the element is off screen. Opacity is quantised so most frames touch few styles. Avoid blurs or large shadows on the tiles.

SectionGallery
TechniquesPointer interaction, CSS 3D
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byInfinite Dome Grid by Osmo on osmo.supply. 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.

3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay. Card hover tilt with glareA grid of cards that tilt in 3D toward the pointer with a moving glare highlight and a lifted shadow, all driven by CSS variables. Parallax photo carouselA draggable 3D carousel of gradient photo cards with parallax inner layers, inertia and spring snap, plus arrow keys, buttons and dots.

FAQ

How do I use my own images?

Replace the inline svg in each tile with an img or a background image. The tile is a plain square div, and the fade works on the whole tile.

Why do tiles near the poles look sparse?

Rows nearer the poles carry fewer tiles so they stay the same size as the middle rows. Raise the tiles-around value for a denser dome.

Can it work on touch?

Yes. The stage uses touch-action none and pointer events, so a finger drag turns it. That does mean the dome captures vertical swipes while a finger is on it.