← 3D & depth

Voxel Build

A little model builds itself out of cubes, from the ground up: each cube drops into place with a bounce while the whole thing turns slowly. Loop it and, once built, the cubes float away and the next model builds: a tree, a toadstool, then a heart.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-voxel-build to a box with a height.

Options

Add any of these attributes to the same element.

data-shapeWhich models, in turn: any of tree, mushroom, heart and house, separated by commas. Default tree,mushroom,heart.
data-voxelsYour own model instead, as cubes separated by semicolons, each x,y,z,colour with z going up, e.g. 0,0,0,#7c3aed;0,0,1,#a78bfa.
data-loopTake each model apart once built, and build the next.
data-buildHow long a model takes to build. Default 2600 ms.
data-holdHow long it stays built before coming apart, when looping. Default 2400 ms.
data-speedHow fast it turns. Default 1; 0 stands it still.

Drawn on a canvas, paused when off screen. With reduced motion the first model is shown built.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Voxel Build: a little 3D model builds itself out of cubes, layer by
    // layer from the ground up, each cube dropping into place with a bounce,
    // while the model turns slowly. Loop it and the cubes float away and the
    // next model builds.
    // Usage: <div data-voxel-build data-loop style="height: 320px"></div>
    // Optional: data-shape (comma list of tree, mushroom, heart, house; default
    // "tree,mushroom,heart"), data-voxels (your own model instead: "x,y,z,colour"
    // cubes separated by semicolons, z up, e.g. "0,0,0,#7c3aed;0,0,1,#a78bfa"),
    // data-loop (take it apart and build the next one), data-build (ms to build
    // a model, default 2600), data-hold (ms it stays built, default 2400),
    // data-speed (how fast it turns, default 1; 0 to stand still).
    var DEFAULTS = { shape: 'tree,mushroom,heart', build: 2600, hold: 2400, speed: 1 };
    var PITCH = 0.6; // how far we look down, radians

    var css =
      '[data-voxel-build]{position:relative;overflow:hidden}' +
      '.vxb-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // A repeatable "random" number for each cell, so models look the same every time.
    function hash(x, y, z) {
      var n = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453;
      return n - Math.floor(n);
    }
    function pick(list, x, y, z) { return list[Math.floor(hash(x, y, z) * list.length)]; }

    var SHAPES = {
      tree: function () {
        var v = [], x, y, z;
        for (z = 0; z < 3; z++) v.push([0, 0, z, '#92400e']);
        for (x = -3; x <= 3; x++) for (y = -3; y <= 3; y++) for (z = 2; z <= 7; z++) {
          var r = (x * x + y * y) / 7.6 + (z - 4.4) * (z - 4.4) / 6;
          if (r <= 1 && !(x === 0 && y === 0 && z === 2)) v.push([x, y, z, pick(['#16a34a', '#22c55e', '#15803d', '#4ade80'], x, y, z)]);
        }
        return v;
      },
      mushroom: function () {
        var v = [], x, y, z;
        for (x = -1; x <= 0; x++) for (y = -1; y <= 0; y++) for (z = 0; z < 3; z++) v.push([x, y, z, '#fef3c7']);
        for (z = 3; z <= 5; z++) {
          var r = 3.7 * Math.sqrt(1 - Math.pow((z - 2.6) / 3.2, 2));
          for (x = -4; x <= 3; x++) for (y = -4; y <= 3; y++) {
            var dx = x + 0.5, dy = y + 0.5;
            if (dx * dx + dy * dy <= r * r) v.push([x, y, z, hash(x, y, z) < 0.16 ? '#fff' : pick(['#ef4444', '#dc2626'], x, y, z)]);
          }
        }
        return v;
      },
      heart: function () {
        var rows = ['.XX.XX.', 'XXXXXXX', 'XXXXXXX', '.XXXXX.', '..XXX..', '...X...'];
        var v = [];
        rows.forEach(function (row, r) {
          row.split('').forEach(function (c, i) {
            if (c !== 'X') return;
            for (var y = 0; y < 2; y++) v.push([i - 3, y, 5 - r, pick(['#ec4899', '#f472b6', '#db2777'], i, y, r)]);
          });
        });
        return v;
      },
      house: function () {
        var v = [], x, y, z;
        for (x = -2; x <= 2; x++) for (y = -2; y <= 1; y++) for (z = 0; z < 3; z++) {
          if (x > -2 && x < 2 && y > -2 && y < 1 && z > 0) continue; // hollow inside
          var door = y === 1 && x === 0 && z < 2, win = (x === 2 && y === -1 && z === 1);
          if (door) continue;
          v.push([x, y, z, win ? '#fde68a' : pick(['#f5f3ff', '#ede9fe'], x, y, z)]);
        }
        for (z = 3; z <= 5; z++) for (y = -3 + (z - 3); y <= 2 - (z - 3); y++) for (x = -2; x <= 2; x++) {
          if (y === -3 + (z - 3) || y === 2 - (z - 3) || z === 5) v.push([x, y, z, pick(['#7c3aed', '#6d28d9'], x, y, z)]);
        }
        return v;
      }
    };

    function parse(text) {
      return text.split(';').map(function (c) {
        var p = c.split(',');
        return [Math.round(+p[0]), Math.round(+p[1]), Math.round(+p[2]), (p[3] || '#7c3aed').trim()];
      }).filter(function (p) { return !isNaN(p[0] + p[1] + p[2]); });
    }

    function rgb(hex) {
      hex = hex.replace('#', '');
      if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
      var n = parseInt(hex, 16);
      return [n >> 16 & 255, n >> 8 & 255, n & 255];
    }
    function tint(c, f) {
      return 'rgb(' + c.map(function (v) { return Math.round(f > 1 ? v + (255 - v) * (f - 1) : v * f); }).join(',') + ')';
    }

    // The six-ish faces of a unit cube: normal, then corners.
    var FACES = [
      [[0, 0, 1], [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]]],
      [[1, 0, 0], [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]]],
      [[-1, 0, 0], [[0, 0, 0], [0, 1, 0], [0, 1, 1], [0, 0, 1]]],
      [[0, 1, 0], [[0, 1, 0], [1, 1, 0], [1, 1, 1], [0, 1, 1]]],
      [[0, -1, 0], [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]]]
    ];

    function model(list) {
      var xs = list.map(function (v) { return v[0]; }), ys = list.map(function (v) { return v[1]; }), zs = list.map(function (v) { return v[2]; });
      var cx = (Math.min.apply(null, xs) + Math.max.apply(null, xs) + 1) / 2;
      var cy = (Math.min.apply(null, ys) + Math.max.apply(null, ys) + 1) / 2;
      var z0 = Math.min.apply(null, zs);
      var cells = {}, R = 0, top = 0;
      var vox = list.map(function (v) {
        var o = { x: v[0] - cx, y: v[1] - cy, z: v[2] - z0, c: rgb(v[3]), key: v[0] + ',' + v[1] + ',' + v[2], gx: v[0], gy: v[1], gz: v[2] };
        cells[o.key] = o;
        R = Math.max(R, Math.hypot(o.x, o.y), Math.hypot(o.x + 1, o.y), Math.hypot(o.x, o.y + 1), Math.hypot(o.x + 1, o.y + 1));
        top = Math.max(top, o.z + 1);
        return o;
      });
      // Build order: ground up, and round each layer in a sweep.
      vox.sort(function (a, b) { return a.z - b.z || Math.atan2(a.y + 0.5, a.x + 0.5) - Math.atan2(b.y + 0.5, b.x + 0.5); });
      vox.forEach(function (v, i) { v.i = i; });
      return { vox: vox, cells: cells, R: R, top: top };
    }

    function bounce(t) {
      var n = 7.5625, d = 2.75;
      if (t < 1 / d) return n * t * t;
      if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75;
      if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
      return n * (t -= 2.625 / d) * t + 0.984375;
    }

    function setup(el) {
      var d = el.dataset;
      var models = d.voxels ? [model(parse(d.voxels))] :
        (d.shape || DEFAULTS.shape).split(',').map(function (n) { return SHAPES[n.trim()]; })
          .filter(Boolean).map(function (f) { return model(f()); });
      if (!models.length || !models[0].vox.length) return;
      var canvas = document.createElement('canvas');
      canvas.className = 'vxb-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var build = d.build !== undefined ? +d.build : DEFAULTS.build;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1;
      // phase: 'build' then 'hold' then (looping) 'leave'; t counts ms within it.
      var s = { m: 0, phase: 'build', t: 0, yaw: 0.6 };
      var DROP = 450, LEAVE = 500;

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
      }

      // How far each cube is from its place (lift, in cubes) and how solid it is.
      function state(v, M) {
        var n = M.vox.length;
        if (s.phase === 'build') {
          var stag = Math.max(0, (build - DROP) / n);
          var p = Math.max(0, Math.min(1, (s.t - v.i * stag) / DROP));
          return { lift: (1 - bounce(p)) * 3.5, a: Math.min(1, p * 4), done: p >= 1 };
        }
        if (s.phase === 'leave') {
          var st = Math.max(0, (900 - LEAVE) / n);
          var q = Math.max(0, Math.min(1, (s.t - (n - 1 - v.i) * st) / LEAVE));
          return { lift: q * q * 6, a: 1 - q, done: q === 0 };
        }
        return { lift: 0, a: 1, done: true };
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var M = models[s.m];
        var cy = Math.cos(s.yaw), sy = Math.sin(s.yaw), cp = Math.cos(PITCH), sp = Math.sin(PITCH);
        var span = 2 * M.R + 1;
        var scale = Math.min(w * 0.82 / span, h * 0.7 / (M.top * cp + span * sp));
        var ox = w / 2, oy = h / 2 + (M.top * cp) / 2 * scale;
        function proj(x, y, z) {
          var X = x * cy - y * sy, Y = x * sy + y * cy;
          return [ox + X * scale, oy + (Y * sp - z * cp) * scale, Y * cp + z * sp];
        }
        // A soft shadow on the ground.
        ctx.save();
        ctx.translate(ox, oy);
        ctx.scale(1, sp);
        var g = ctx.createRadialGradient(0, 0, 0, 0, 0, M.R * scale * 1.15);
        g.addColorStop(0, 'rgba(60,20,110,.22)');
        g.addColorStop(1, 'rgba(60,20,110,0)');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(0, 0, M.R * scale * 1.15, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();

        var list = [];
        M.vox.forEach(function (v) {
          var st = state(v, M);
          if (st.a <= 0) return;
          var z = v.z + st.lift;
          var c = proj(v.x + 0.5, v.y + 0.5, z + 0.5);
          list.push({ v: v, st: st, z: z, depth: c[2] });
        });
        list.sort(function (a, b) { return a.depth - b.depth; });
        ctx.lineJoin = 'round';
        ctx.lineWidth = Math.max(0.5, scale * 0.04);
        list.forEach(function (o) {
          var v = o.v;
          ctx.globalAlpha = o.st.a;
          FACES.forEach(function (f) {
            var n = f[0];
            // Facing away from us?
            var nY = n[0] * sy + n[1] * cy;
            if (nY * cp + n[2] * sp <= 0.001) return;
            // Hidden behind a settled neighbour?
            var nb = M.cells[(v.gx + n[0]) + ',' + (v.gy + n[1]) + ',' + (v.gz + n[2])];
            if (nb && o.st.done && o.st.a >= 1 && s.phase !== 'leave' && state(nb, M).done) return;
            var light = n[2] ? 1.12 : 0.62 + 0.26 * (n[0] * cy - n[1] * sy + 1) / 2;
            ctx.fillStyle = tint(v.c, light);
            ctx.strokeStyle = tint(v.c, light * 0.86);
            ctx.beginPath();
            f[1].forEach(function (k, i) {
              var p = proj(v.x + k[0], v.y + k[1], o.z + k[2]);
              if (i) ctx.lineTo(p[0], p[1]); else ctx.moveTo(p[0], p[1]);
            });
            ctx.closePath();
            ctx.fill();
            ctx.stroke();
          });
        });
        ctx.globalAlpha = 1;
      }

      resize();
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        s.phase = 'hold';
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          s.yaw += dt / 1000 * 0.45 * speed;
          s.t += dt;
          if (s.phase === 'build' && s.t > build + 200) { s.phase = 'hold'; s.t = 0; }
          else if (s.phase === 'hold' && loop && s.t > hold) { s.phase = 'leave'; s.t = 0; }
          else if (s.phase === 'leave' && s.t > 900 + 100) {
            s.phase = 'build'; s.t = 0;
            s.m = (s.m + 1) % models.length;
          }
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-voxel-build]').forEach(function (el) {
        if (el._vxb) return;
        el._vxb = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-voxel-build data-loop style="height: 320px"></div>

<!-- or your own model: -->
<div data-voxel-build data-voxels="0,0,0,#7c3aed;1,0,0,#7c3aed;0,0,1,#a78bfa" style="height: 320px"></div>