> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# Exploded Stack

> Isometric layer plates explode apart with a label beside each layer, hold, then collapse back into one stack. Built for architecture and system diagrams.

# Exploded Stack

Isometric layer plates explode apart with a label beside each layer, hold, then collapse back into one stack. Built for architecture and system diagrams.

**Exploded Stack**

```html exploded-stack.html theme={null}
<!doctype html>
<!--
  Exploded Stack: isometric layer plates sit as one stack, explode apart along the vertical
  axis with labels drawing in beside each layer, hold, then collapse back, so the clip loops.
  Seek-safe: every frame is render(t), driven by one linear GSAP clock.
-->
<html
  lang="en"
  data-composition-variables='[
    { "id": "title", "type": "string", "role": "content", "label": "Title", "default": "How a request moves", "maxLength": 32 },
    { "id": "layer_1", "type": "string", "role": "content", "label": "Top layer", "default": "Interface", "maxLength": 22 },
    { "id": "layer_2", "type": "string", "role": "content", "label": "Layer 2", "default": "API gateway", "maxLength": 22 },
    { "id": "layer_3", "type": "string", "role": "content", "label": "Layer 3", "default": "Services", "maxLength": 22 },
    { "id": "layer_4", "type": "string", "role": "content", "label": "Bottom layer", "default": "Data", "maxLength": 22 },
    { "id": "accent", "type": "color", "role": "style", "label": "Accent", "default": "#0a84ff" },
    { "id": "background", "type": "color", "role": "style", "label": "Backdrop", "default": "#f4f4f5" }
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Exploded Stack</title>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <style>
      @font-face {
        font-family: "ES Inter";
        font-weight: 500;
        src: url(https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-500-normal.woff2)
          format("woff2");
      }
      @font-face {
        font-family: "ES Inter";
        font-weight: 700;
        src: url(https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-700-normal.woff2)
          format("woff2");
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: #f4f4f5;
      }
      #es-root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--es-bg, #f4f4f5);
        font-family: "ES Inter", system-ui, sans-serif;
      }
      #es-canvas,
      #es-leaders {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
        display: block;
      }
      #es-title {
        position: absolute;
        left: 120px;
        top: 110px;
        font-size: 60px;
        font-weight: 700;
        letter-spacing: -0.02em;
        color: #14161c;
      }
      .es-label {
        position: absolute;
        left: 0;
        top: 0;
        display: flex;
        align-items: center;
        gap: 18px;
        font-size: 34px;
        font-weight: 500;
        color: #1b1e27;
        white-space: nowrap;
        opacity: 0;
      }
      .es-index {
        font-size: 20px;
        font-weight: 700;
        letter-spacing: 0.08em;
      }
      #es-leaders line {
        stroke: rgba(27, 30, 39, 0.35);
        stroke-width: 2;
      }
    </style>
  </head>
  <body>
    <div
      id="es-root"
      data-composition-id="exploded-stack"
      data-start="0"
      data-duration="5"
      data-width="1920"
      data-height="1080"
    >
      <canvas
        id="es-canvas"
        class="clip"
        data-start="0"
        data-duration="5"
        data-track-index="1"
      ></canvas>
      <svg id="es-leaders" viewBox="0 0 1920 1080" aria-hidden="true"></svg>
      <div id="es-title"></div>
      <div id="es-labels"></div>
    </div>

    <script>
      (function () {
        var html = document.documentElement;
        var defaults = {};
        try {
          JSON.parse(html.getAttribute("data-composition-variables") || "[]").forEach(function (v) {
            defaults[v.id] = v.default;
          });
        } catch (error) {
          /* malformed declaration: the scene module falls back to its own defaults */
        }
        var scoped =
          (window.__hyperframes &&
            window.__hyperframes.getVariables &&
            window.__hyperframes.getVariables()) ||
          {};
        var vars = Object.assign({}, defaults, window.__hfVariables || {}, scoped);
        var hex = function (v, fallback) {
          return /^#[0-9a-f]{6}$/i.test(String(v)) ? String(v) : fallback;
        };
        var root = document.getElementById("es-root");
        var duration = Math.max(0.001, parseFloat(root.dataset.duration) || 5);
        root.style.setProperty("--es-bg", hex(vars.background, "#f4f4f5"));
        document.getElementById("es-title").textContent = vars.title || "";

        var names = [vars.layer_1, vars.layer_2, vars.layer_3, vars.layer_4].map(function (n, i) {
          return String(n || ["Interface", "API gateway", "Services", "Data"][i]);
        });
        var labels = document.getElementById("es-labels");
        var leaders = document.getElementById("es-leaders");
        var svg = "http://www.w3.org/2000/svg";
        names.forEach(function (name, i) {
          var el = document.createElement("div");
          el.className = "es-label";
          el.innerHTML = '<span class="es-index">0' + (i + 1) + "</span><span></span>";
          el.lastChild.textContent = name;
          labels.appendChild(el);
          var line = document.createElementNS(svg, "line");
          var dot = document.createElementNS(svg, "circle");
          dot.setAttribute("r", "6");
          leaders.appendChild(line);
          leaders.appendChild(dot);
        });

        var clock = { t: 0 };
        var scene = {
          vars: vars,
          names: names,
          duration: duration,
          clock: clock,
          hex: hex,
          render: null,
        };
        window.__explodedStack = scene;
        window.__hf = window.__hf || {};
        window.__hf.buildReady = window.__hf.buildReady || {};
        window.__hf.buildReady["exploded-stack"] = new Promise(function (resolve) {
          scene.ready = resolve;
        });

        var tl = gsap.timeline({ paused: true });
        tl.to(clock, {
          t: duration,
          duration: duration,
          ease: "none",
          onUpdate: function () {
            if (scene.render) scene.render(clock.t);
          },
        });
        window.__timelines = window.__timelines || {};
        window.__timelines["exploded-stack"] = tl;
      })();
    </script>

    <script type="module">
      import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.184.0/+esm";
      import { RoundedBoxGeometry } from "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/geometries/RoundedBoxGeometry.js/+esm";

      const host = window.__explodedStack;
      const V = host.vars;
      const hex = host.hex;
      const accent = new THREE.Color(hex(V.accent, "#0a84ff"));
      const D = host.duration;

      const renderer = new THREE.WebGLRenderer({
        canvas: document.getElementById("es-canvas"),
        antialias: true,
        alpha: true,
        preserveDrawingBuffer: true,
      });
      renderer.setPixelRatio(1);
      renderer.setSize(1920, 1080, false);
      renderer.outputColorSpace = THREE.SRGBColorSpace;

      const scene = new THREE.Scene();
      // True isometric: orthographic, 45 degrees around Y, arctan(1/sqrt 2) down.
      const view = 5.9;
      const camera = new THREE.OrthographicCamera(
        (-view * 16) / 9,
        (view * 16) / 9,
        view,
        -view,
        0.1,
        100,
      );
      camera.position.set(10, 10.7, 10);
      camera.lookAt(0, 0.7, 0);
      // Slide along the camera's right axis so the stack sits left and labels get the right.
      camera.position.add(new THREE.Vector3(1.9, 0, -1.9));
      camera.updateMatrixWorld();

      scene.add(new THREE.HemisphereLight(0xffffff, 0xd6d9e4, 2.2));
      const sun = new THREE.DirectionalLight(0xffffff, 1.4);
      sun.position.set(-4, 10, 6);
      scene.add(sun);

      const texture = (c) => {
        const tex = new THREE.CanvasTexture(c);
        tex.colorSpace = THREE.SRGBColorSpace;
        tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
        return tex;
      };
      // White face with a hairline where the flat top meets the rounded edge.
      function faceTexture() {
        const c = document.createElement("canvas");
        c.width = c.height = 512;
        const g = c.getContext("2d");
        g.fillStyle = "#ffffff";
        g.fillRect(0, 0, 512, 512);
        g.strokeStyle = "#d6d8dd";
        g.lineWidth = 3;
        g.strokeRect(1.5, 1.5, 509, 509);
        return texture(c);
      }
      // White glyphs on transparent; the material colour tints them per frame.
      function glyphTexture(i) {
        const c = document.createElement("canvas");
        c.width = c.height = 512;
        const g = c.getContext("2d");
        g.strokeStyle = g.fillStyle = "#ffffff";
        g.lineWidth = 6;
        // Each layer gets its own motif, so the plates read as different systems.
        if (i === 0) {
          for (let k = 0; k < 3; k++) g.strokeRect(70 + k * 130, 150, 110, 212);
        } else if (i === 1) {
          for (let k = 0; k < 5; k++) {
            g.beginPath();
            g.moveTo(80, 110 + k * 72);
            g.lineTo(432, 110 + k * 72);
            g.stroke();
          }
        } else if (i === 2) {
          for (let y = 0; y < 3; y++)
            for (let x = 0; x < 3; x++) g.fillRect(96 + x * 120, 96 + y * 120, 80, 80);
        } else {
          for (let k = 0; k < 3; k++) {
            g.beginPath();
            g.ellipse(256, 150 + k * 100, 150, 40, 0, 0, Math.PI * 2);
            g.stroke();
          }
        }
        return texture(c);
      }

      const LAYERS = 4;
      const THICK = 0.22;
      const GAP = 1.55;
      const edge = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.6 });
      const shadowCanvas = document.createElement("canvas");
      shadowCanvas.width = shadowCanvas.height = 256;
      const sg = shadowCanvas.getContext("2d");
      const sgrad = sg.createRadialGradient(128, 128, 40, 128, 128, 128);
      sgrad.addColorStop(0, "rgba(20,24,40,0.22)");
      sgrad.addColorStop(1, "rgba(20,24,40,0)");
      sg.fillStyle = sgrad;
      sg.fillRect(0, 0, 256, 256);
      const shadow = new THREE.Mesh(
        new THREE.PlaneGeometry(7, 7),
        new THREE.MeshBasicMaterial({
          map: new THREE.CanvasTexture(shadowCanvas),
          transparent: true,
          depthWrite: false,
        }),
      );
      shadow.rotation.x = -Math.PI / 2;
      shadow.position.y = -2.4;
      scene.add(shadow);
      const top = new THREE.MeshStandardMaterial({ map: faceTexture(), roughness: 0.7 });
      const plates = [];
      const glyphs = [];
      for (let i = 0; i < LAYERS; i++) {
        const plate = new THREE.Mesh(new RoundedBoxGeometry(4.2, THICK, 4.2, 4, 0.08), edge);
        // 4.04 = the flat top inside the 0.08 rounded edge.
        const face = new THREE.Mesh(new THREE.PlaneGeometry(4.04, 4.04), top);
        face.rotation.x = -Math.PI / 2;
        face.position.y = THICK / 2 + 0.002;
        const glyph = new THREE.MeshStandardMaterial({
          map: glyphTexture(i),
          roughness: 0.7,
          transparent: true,
          depthWrite: false,
        });
        const motif = new THREE.Mesh(new THREE.PlaneGeometry(4.04, 4.04), glyph);
        motif.rotation.x = -Math.PI / 2;
        motif.position.y = THICK / 2 + 0.004;
        plate.add(face, motif);
        scene.add(plate);
        plates.push(plate);
        glyphs.push(glyph);
      }

      // Beats in seconds at the 5 s default; another duration scales them.
      const BASE = 5;
      const OPEN_AT = 0.3,
        OPEN_FOR = 0.9,
        OPEN_STAGGER = 0.06;
      const LAND_AT = 1.25,
        LAND_EVERY = 0.7,
        DRAW = 0.3,
        FULL = 0.4,
        SETTLE = 0.3;
      const CLOSE_AT = LAND_AT + (LAYERS - 1) * LAND_EVERY + 0.7;
      const CLOSE_FOR = 0.6,
        CLOSE_STAGGER = 0.05,
        LIFT = 0.08;
      const clamp01 = (x) => Math.min(1, Math.max(0, x));
      const ease = (x) => {
        const u = clamp01(x);
        return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
      };
      const easeOut = (x) => 1 - Math.pow(1 - clamp01(x), 3);
      // Ease-out with a soft first frame, so the separation reads from OPEN_AT on.
      const easeOpen = (x) => easeOut(Math.pow(clamp01(x), 1.4));
      // 0 at rest, 1 from the moment layer i's label lands until FULL later.
      const focus = (T, i) => {
        const land = LAND_AT + i * LAND_EVERY;
        return easeOut((T - land + DRAW) / DRAW) * (1 - ease((T - land - FULL) / SETTLE));
      };
      const GLYPH_REST = new THREE.Color(0xc9ccd3);
      const INK_REST = new THREE.Color(0x8b909a);
      const ink = new THREE.Color();

      const labelEls = [...document.querySelectorAll(".es-label")];
      const indexEls = [...document.querySelectorAll(".es-index")];
      const lines = [...document.querySelectorAll("#es-leaders line")];
      const dots = [...document.querySelectorAll("#es-leaders circle")];
      const anchor = new THREE.Vector3();
      const toScreen = (v) => {
        const p = v.clone().project(camera);
        return [(p.x * 0.5 + 0.5) * 1920, (-p.y * 0.5 + 0.5) * 1080];
      };

      host.render = (t) => {
        const T = (t / D) * BASE;
        for (let i = 0; i < LAYERS; i++) {
          // Bottom plate stays put; the top plate travels furthest and leaves first.
          const order = LAYERS - 1 - i;
          const open =
            easeOpen((T - OPEN_AT - i * OPEN_STAGGER) / OPEN_FOR) -
            ease((T - CLOSE_AT - 0.05 - order * CLOSE_STAGGER) / CLOSE_FOR);
          const w = focus(T, i);
          const y = order * (THICK + 0.02 + open * GAP) - 2.2 + LIFT * w;
          plates[i].position.y = y;
          glyphs[i].color.copy(GLYPH_REST).lerp(accent, w);
          const inkHex = `#${ink.copy(INK_REST).lerp(accent, w).getHexString()}`;
          indexEls[i].style.color = inkHex;
          dots[i].style.fill = inkHex;

          const shown =
            easeOut((T - LAND_AT - i * LAND_EVERY + DRAW) / DRAW) *
            (1 - clamp01((T - CLOSE_AT) / 0.2));
          // The plate's right-hand corner, where a label has open space beside it.
          anchor.set(2.1, y, -2.1);
          const [ax, ay] = toScreen(anchor);
          const lx = ax + 90;
          labelEls[i].style.opacity = shown.toFixed(3);
          // Whole pixels: a fractional offset rasterises text differently by frame order.
          labelEls[i].style.transform =
            `translate(${Math.round(lx + 24 + (1 - shown) * 16)}px, ${Math.round(ay - 22)}px)`;
          lines[i].setAttribute("x1", ax.toFixed(1));
          lines[i].setAttribute("y1", ay.toFixed(1));
          lines[i].setAttribute("x2", (ax + 90 * shown).toFixed(1));
          lines[i].setAttribute("y2", ay.toFixed(1));
          lines[i].style.opacity = shown > 0 ? 1 : 0;
          dots[i].setAttribute("cx", ax.toFixed(1));
          dots[i].setAttribute("cy", ay.toFixed(1));
          dots[i].style.opacity = shown.toFixed(3);
        }
        renderer.render(scene, camera);
      };
      host.render(host.clock.t);
      host.ready();
    </script>
  </body>
</html>
```

Copy npx hyperframes add exploded-stack to the clipboard

That writes one file: `compositions/exploded-stack.html`.

## Add it to your video

It runs for 5 seconds at 1920×1080. Paste this into your composition:

```html index.html theme={null}
<div
  data-composition-id="exploded-stack"
  data-composition-src="compositions/exploded-stack.html"
  data-start="0"
  data-duration="5"
  data-track-index="1"
  data-width="1920"
  data-height="1080"
></div>
```

Move it in time with `data-start`. Put it on a different timeline row with
`data-track-index`. See [data attributes](/concepts/data-attributes.md) for the rest.

Tagged `3d-object` `three-js` `webgl` `isometric` `exploded-view` `layers` `diagram` `architecture`.

## Related topics

* [Browse the complete Catalog](/catalog.md)
* [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks.md)
* [Put blocks together into one video](/concepts/compositions.md)


## Related topics

- [Notification Stack](/catalog/components/notification-stack.md)
- [Keyframe Scrub Stack](/catalog/components/keyframe-scrub-stack.md)
- [Thread Message Stack](/catalog/blocks/thread-message-stack.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.