Files
hyperframes/docs/catalog/blocks/exploded-stack.mdx
T
github-actions[bot] faa4913c9e chore(catalog): publish generated catalog (#4340)
* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

* chore(catalog): publish generated catalog

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-10 19:13:01 +00:00

481 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Exploded Stack"
description: "Isometric layer plates explode apart with a label beside each layer, hold, then collapse back into one stack. Built for architecture and system diagrams."
mode: "frame"
---
import { CatalogDetail, CatalogSlot } from "/snippets/catalog-detail.jsx";
import { InstallCommand } from "/snippets/install-command.jsx";
<CatalogDetail
previewSrc="/public/catalog/blocks/exploded-stack.json"
compositionId="exploded-stack"
compositionSrc="compositions/exploded-stack.html"
title="Exploded Stack"
description="Isometric layer plates explode apart with a label beside each layer, hold, then collapse back into one stack. Built for architecture and system diagrams."
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"}]}
meta={{"duration":5,"width":1920,"height":1080,"category":"3D objects","badge":"Stable","codeLines":409}}
attribution={{"path":"registry/blocks/exploded-stack","tags":["3d-object","three-js","webgl","isometric","exploded-view","layers","diagram","architecture"]}}
hasCode
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/blocks/exploded-stack/exploded-stack.html"
>
<CatalogSlot slot="code">
```html exploded-stack.html
<!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>
```
</CatalogSlot>
<CatalogSlot slot="install">
<InstallCommand command="npx hyperframes add exploded-stack" item="exploded-stack" />
That writes one file: `compositions/exploded-stack.html`.
</CatalogSlot>
<CatalogSlot slot="docs">
## Add it to your video
It runs for 5 seconds at 1920×1080. Paste this into your composition:
```html index.html
<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) for the rest.
{/* hf:generated-footer */}
Tagged `3d-object` `three-js` `webgl` `isometric` `exploded-view` `layers` `diagram` `architecture`.
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
- [Build a richer composition](/go-further)
</CatalogSlot>
</CatalogDetail>