DIY — Exploded View Animation
Scroll-driven exploded view of a fictional 68-key mechanical keyboard, drawn as five stacked SVG layers on a blueprint grid. A sticky viewport pulls keycaps, switches, plate, PCB and case apart as you scroll, fading in mono callouts with leader lines while a progress rail tracks explode percent. Clickable labels open spec cards with material, part count and a fun fact; Explode and Assemble buttons plus a scrub slider drive the sequence, with a reduced-motion fallback.
MCP
Code
:root {
--blueprint: #0f2a4a;
--blueprint-d: #0a1e36;
--blueprint-l: #16385f;
--grid: rgba(140, 180, 220, 0.14);
--grid-major: rgba(140, 180, 220, 0.26);
--orange: #ff6b35;
--orange-d: #e0521f;
--orange-soft: #ffe3d6;
--kraft: #d9c7a7;
--kraft-l: #efe6d4;
--ink: #1c2733;
--ink-2: #3d4a57;
--muted: #6b7885;
--bg: #f4f1ea;
--white: #ffffff;
--line: rgba(28, 39, 51, 0.12);
--line-2: rgba(28, 39, 51, 0.2);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--shadow-sm: 0 2px 8px rgba(28, 39, 51, 0.08);
--shadow-md: 0 10px 30px rgba(10, 30, 54, 0.18);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
}
/* ---------- blueprint grid texture ---------- */
.hero,
.viewport {
background-color: var(--blueprint);
background-image:
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px),
linear-gradient(var(--grid-major) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-major) 1px, transparent 1px);
background-size: 8px 8px, 8px 8px, 80px 80px, 80px 80px;
}
/* ---------- badges ---------- */
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.badge-mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
color: var(--kraft);
border: 1px dashed rgba(217, 199, 167, 0.55);
background: rgba(217, 199, 167, 0.08);
}
.badge-orange {
color: #fff;
background: var(--orange);
box-shadow: 0 2px 10px rgba(255, 107, 53, 0.4);
}
/* ---------- hero ---------- */
.hero {
color: var(--white);
padding: 72px 24px 96px;
position: relative;
}
.hero-inner {
max-width: 860px;
margin: 0 auto;
text-align: center;
}
.hero-badges {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 26px;
}
.hero-title {
margin: 0 0 14px;
font-size: clamp(2.2rem, 6vw, 4rem);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.05;
}
.hero-title-sub {
display: block;
margin-top: 8px;
font-size: clamp(0.9rem, 2vw, 1.15rem);
font-weight: 600;
letter-spacing: 0.28em;
color: var(--orange);
}
.hero-lede {
max-width: 560px;
margin: 0 auto 22px;
color: rgba(239, 230, 212, 0.85);
font-size: 1.02rem;
}
.hero-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--kraft);
}
.hero-meta strong {
color: var(--white);
font-weight: 700;
}
.dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--orange);
}
.scroll-hint {
margin-top: 44px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(239, 230, 212, 0.6);
}
.scroll-hint-arrow {
display: inline-block;
color: var(--orange);
animation: bob 1.6s ease-in-out infinite;
}
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(5px); }
}
/* ---------- sticky stage ---------- */
.scroll-stage {
height: 420vh;
position: relative;
}
body.reduced .scroll-stage {
height: auto;
}
.stage-sticky {
position: sticky;
top: 0;
height: 100vh;
height: 100dvh;
display: grid;
grid-template-columns: 320px 1fr;
gap: 0;
overflow: hidden;
background: var(--bg);
}
body.reduced .stage-sticky {
position: static;
height: auto;
min-height: 100vh;
}
/* ---------- sidebar ---------- */
.sidebar {
padding: 22px 20px;
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
background:
linear-gradient(rgba(28, 39, 51, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(28, 39, 51, 0.035) 1px, transparent 1px),
var(--kraft-l);
background-size: 20px 20px, 20px 20px, auto;
}
.sidebar-kicker {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.22em;
color: var(--orange-d);
}
.sidebar-title {
margin: 4px 0 0;
font-size: 1.15rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.layer-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.layer-item {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--white);
font: inherit;
color: var(--ink);
cursor: pointer;
text-align: left;
transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.layer-item:hover {
border-color: var(--orange);
transform: translateX(3px);
box-shadow: var(--shadow-sm);
}
.layer-item:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.layer-item .layer-num {
font-size: 11px;
font-weight: 700;
color: var(--muted);
}
.layer-item .layer-name {
font-weight: 600;
font-size: 0.92rem;
}
.layer-item .layer-part {
font-size: 10px;
color: var(--muted);
letter-spacing: 0.04em;
}
.layer-item.is-active {
background: var(--blueprint);
border-color: var(--blueprint);
color: var(--white);
}
.layer-item.is-active .layer-num { color: var(--orange); }
.layer-item.is-active .layer-part { color: var(--kraft); }
.layer-item.is-selected {
border-color: var(--orange);
box-shadow: inset 3px 0 0 var(--orange), var(--shadow-sm);
}
/* ---------- info card ---------- */
.info-card {
border: 1px dashed var(--line-2);
border-radius: var(--r-md);
background: var(--white);
padding: 14px 16px;
box-shadow: var(--shadow-sm);
}
.info-card.flash {
animation: card-flash 0.5s ease;
}
@keyframes card-flash {
0% { box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.45); }
100% { box-shadow: var(--shadow-sm); }
}
.info-card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.info-part {
font-size: 11px;
font-weight: 700;
color: var(--blueprint);
background: rgba(15, 42, 74, 0.08);
border-radius: 4px;
padding: 2px 8px;
letter-spacing: 0.06em;
}
.info-count {
font-size: 12px;
font-weight: 700;
color: var(--orange-d);
}
.info-name {
margin: 0 0 4px;
font-size: 1.02rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-material {
margin: 0 0 8px;
font-size: 0.82rem;
color: var(--ink-2);
}
.info-fact {
margin: 0;
font-size: 0.78rem;
color: var(--muted);
border-top: 1px dashed var(--line);
padding-top: 8px;
}
/* ---------- controls ---------- */
.controls {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.controls-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.btn {
font: inherit;
font-weight: 700;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 11px 14px;
border-radius: var(--r-sm);
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.btn-primary {
border: none;
background: var(--orange);
color: #fff;
box-shadow: 0 3px 12px rgba(255, 107, 53, 0.35);
}
.btn-primary:hover {
background: var(--orange-d);
}
.btn-ghost {
border: 1.5px solid var(--line-2);
background: transparent;
color: var(--ink);
}
.btn-ghost:hover {
border-color: var(--ink);
background: rgba(28, 39, 51, 0.05);
}
.scrub-label {
display: flex;
justify-content: space-between;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
.scrub-label .mono {
color: var(--orange-d);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--orange) var(--fill, 0%), rgba(28, 39, 51, 0.15) var(--fill, 0%));
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--white);
border: 3px solid var(--orange);
box-shadow: var(--shadow-sm);
transition: transform 0.15s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.12);
}
input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--white);
border: 3px solid var(--orange);
box-shadow: var(--shadow-sm);
}
input[type="range"]:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 4px;
}
.rm-note {
margin: 0;
font-size: 0.75rem;
color: var(--warn);
font-weight: 600;
}
/* ---------- viewport ---------- */
.viewport {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 420px;
}
#exploded-svg {
width: min(92%, 780px);
height: auto;
max-height: 96%;
}
.viewport-corner {
position: absolute;
font-size: 10px;
letter-spacing: 0.18em;
color: rgba(217, 199, 167, 0.65);
pointer-events: none;
}
.viewport-corner.tl { top: 14px; left: 18px; }
.viewport-corner.tr { top: 14px; right: 62px; }
.viewport-corner.bl { bottom: 14px; left: 18px; }
.viewport-status {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.24em;
color: var(--orange);
border: 1px dashed rgba(255, 107, 53, 0.5);
border-radius: 4px;
padding: 4px 12px;
background: rgba(10, 30, 54, 0.6);
pointer-events: none;
}
/* ---------- SVG layers ---------- */
.layer {
will-change: transform;
cursor: pointer;
outline: none;
}
.layer:focus-visible .face-front {
stroke: var(--orange);
stroke-width: 2.5;
}
.layer.is-selected .face-front {
stroke: var(--orange);
stroke-width: 2;
}
.layer:hover .face-front {
filter: brightness(1.06);
}
.etch {
font-size: 9px;
letter-spacing: 0.2em;
fill: rgba(239, 230, 212, 0.55);
}
/* callouts (labels + leader lines) */
.callout {
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.callout.is-visible {
opacity: 1;
pointer-events: auto;
}
.callout line {
stroke: rgba(217, 199, 167, 0.8);
stroke-width: 1.2;
stroke-dasharray: 3 4;
}
.callout circle {
fill: var(--orange);
}
.callout rect {
fill: rgba(10, 30, 54, 0.85);
stroke: rgba(217, 199, 167, 0.5);
stroke-width: 1;
}
.callout text {
font-size: 11px;
font-weight: 600;
fill: var(--kraft-l);
letter-spacing: 0.08em;
}
.layer:hover .callout rect,
.layer.is-selected .callout rect {
stroke: var(--orange);
fill: rgba(15, 42, 74, 0.95);
}
/* ---------- progress rail ---------- */
.rail {
position: absolute;
right: 18px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.rail-track {
width: 8px;
height: min(300px, 40vh);
border-radius: 999px;
background: rgba(140, 180, 220, 0.18);
border: 1px solid rgba(140, 180, 220, 0.3);
display: flex;
align-items: flex-end;
overflow: hidden;
}
.rail-fill {
width: 100%;
height: 0%;
background: linear-gradient(180deg, var(--orange-soft), var(--orange));
border-radius: 999px;
transition: height 0.1s linear;
}
.rail-cap {
font-size: 9px;
color: rgba(217, 199, 167, 0.6);
}
.rail-pct {
font-size: 13px;
font-weight: 700;
color: var(--orange);
}
.rail-label {
font-size: 9px;
letter-spacing: 0.3em;
color: rgba(217, 199, 167, 0.6);
writing-mode: vertical-rl;
}
/* ---------- outro ---------- */
.outro {
border-top: 2px dashed var(--line-2);
padding: 64px 24px 80px;
background:
linear-gradient(rgba(28, 39, 51, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(28, 39, 51, 0.04) 1px, transparent 1px),
var(--kraft-l);
background-size: 20px 20px, 20px 20px, auto;
}
.outro-inner {
max-width: 620px;
margin: 0 auto;
text-align: center;
}
.outro .badge-mono {
color: var(--ink-2);
border-color: var(--line-2);
background: rgba(28, 39, 51, 0.04);
}
.outro-title {
margin: 16px 0 10px;
font-size: clamp(1.3rem, 3.4vw, 1.9rem);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.outro-copy {
margin: 0;
color: var(--ink-2);
font-size: 0.95rem;
}
.outro-copy .mono {
font-weight: 700;
color: var(--orange-d);
}
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
background: var(--ink);
color: var(--kraft-l);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
padding: 10px 18px;
border-radius: var(--r-sm);
border-left: 4px solid var(--orange);
box-shadow: var(--shadow-md);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 50;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
.scroll-hint-arrow {
animation: none;
}
.layer,
.rail-fill,
.callout {
transition: none;
}
}
/* ---------- responsive ---------- */
@media (max-width: 900px) {
.stage-sticky {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
order: 2;
border-right: none;
border-top: 1px solid var(--line);
max-height: 46vh;
}
.viewport {
order: 1;
min-height: 320px;
}
.layer-list {
display: grid;
grid-template-columns: 1fr 1fr;
}
.rail {
right: 10px;
}
}
@media (max-width: 520px) {
.hero {
padding: 52px 18px 64px;
}
.layer-list {
grid-template-columns: 1fr;
}
.layer-item .layer-part {
display: none;
}
.viewport {
min-height: 260px;
}
#exploded-svg {
width: 96%;
}
.rail-track {
height: 140px;
}
.viewport-corner.tr {
display: none;
}
.controls-row {
grid-template-columns: 1fr;
}
}/* DIY — Exploded View Animation
Scroll-driven (sticky stage) with slider scrub, Explode/Assemble buttons,
clickable callouts and a reduced-motion fallback. Vanilla JS only. */
(function () {
"use strict";
// ---------- data ----------
const LAYERS = [
{
id: "keycaps",
name: "Keycaps",
part: "KC-PBT-68",
count: 68,
material: "Dye-sub PBT plastic, 1.4 mm walls, Cherry profile.",
fact: "PBT resists finger-oil shine roughly 4× longer than ABS — that is why the legends still look freshly stenciled after a year.",
offset: -150, // px in SVG units at full explode
fadeStart: 0.08,
band: [0.06, 0.24],
},
{
id: "switches",
name: "Switches",
part: "SW-TAC-62",
count: 68,
material: "POM stem, nylon housing, 62 g tactile spring.",
fact: "Each switch is factory-lubed on the spring only — the tactile leg stays dry so the bump keeps its snap.",
offset: -80,
fadeStart: 0.26,
band: [0.24, 0.42],
},
{
id: "plate",
name: "Plate",
part: "PL-ALU-15",
count: 1,
material: "1.5 mm aluminum 5052, bead-blasted, 68 cutouts.",
fact: "The half-slot relief cuts around the spacebar let the plate flex ~0.3 mm, softening bottom-out without losing alignment.",
offset: -20,
fadeStart: 0.44,
band: [0.42, 0.6],
},
{
id: "pcb",
name: "PCB",
part: "PCB-HS-R3",
count: 1,
material: "FR-4, 1.6 mm, hot-swap sockets, USB-C daughterboard.",
fact: "Every socket is rated for 100+ swaps — the whole switch layer comes out with zero soldering.",
offset: 50,
fadeStart: 0.62,
band: [0.6, 0.78],
},
{
id: "case",
name: "Case",
part: "CS-CNC-2P",
count: 2,
material: "CNC aluminum 6063, sandblasted + anodized, 2-piece.",
fact: "Top and bottom halves are gasket-mounted — eight silicone strips mean no metal ever touches metal.",
offset: 130,
fadeStart: 0.8,
band: [0.78, 1.01],
},
];
const TOTAL_PARTS = 141;
// ---------- elements ----------
const stage = document.getElementById("scroll-stage");
const slider = document.getElementById("scrub");
const readout = document.getElementById("scrub-readout");
const railFill = document.getElementById("rail-fill");
const railPct = document.getElementById("rail-pct");
const status = document.getElementById("stage-status");
const btnExplode = document.getElementById("btn-explode");
const btnAssemble = document.getElementById("btn-assemble");
const infoCard = document.getElementById("info-card");
const infoPart = document.getElementById("info-part");
const infoCount = document.getElementById("info-count");
const infoName = document.getElementById("info-name");
const infoMaterial = document.getElementById("info-material");
const infoFact = document.getElementById("info-fact");
const toastEl = document.getElementById("toast");
const groups = {};
const callouts = {};
const listItems = {};
LAYERS.forEach((l) => {
groups[l.id] = document.getElementById("g-" + l.id);
callouts[l.id] = document.getElementById("lbl-" + l.id);
listItems[l.id] = document.querySelector('.layer-item[data-layer="' + l.id + '"]');
});
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---------- state ----------
let progress = 0; // 0..1
let animRaf = null;
let scrollRaf = null;
let selectedId = null;
let toastTimer = null;
// ---------- helpers ----------
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2200);
}
// ---------- render ----------
function render(p) {
progress = clamp(p, 0, 1);
const pct = Math.round(progress * 100);
LAYERS.forEach((l, i) => {
// slight per-layer stagger for a mechanical, sequential feel
const lp = clamp(progress * 1.12 - i * 0.03, 0, 1);
const y = l.offset * easeInOut(lp);
groups[l.id].style.transform = "translateY(" + y + "px)";
const visible = progress >= l.fadeStart;
callouts[l.id].classList.toggle("is-visible", visible);
});
// progress rail + slider + readouts
railFill.style.height = pct + "%";
railPct.textContent = pct + "%";
readout.textContent = pct + "%";
slider.value = String(Math.round(progress * 1000));
slider.style.setProperty("--fill", pct + "%");
slider.setAttribute("aria-valuetext", pct + " percent exploded");
// status + active layer highlight
let activeId = null;
if (progress >= 0.99) {
status.textContent = "FULLY EXPLODED";
} else if (progress <= 0.02) {
status.textContent = "ASSEMBLED";
} else {
const active = LAYERS.find((l) => progress >= l.band[0] && progress < l.band[1]);
if (active) {
activeId = active.id;
status.textContent = "SEPARATING · " + active.name.toUpperCase();
}
}
LAYERS.forEach((l) => {
listItems[l.id].classList.toggle("is-active", l.id === activeId);
});
}
// ---------- info card ----------
function selectLayer(id) {
const layer = LAYERS.find((l) => l.id === id);
if (!layer) return;
selectedId = id;
infoPart.textContent = layer.part;
infoCount.textContent = "×" + layer.count;
infoName.textContent = String(LAYERS.indexOf(layer) + 1).padStart(2, "0") + " · " + layer.name;
infoMaterial.textContent = layer.material;
infoFact.textContent = layer.fact;
LAYERS.forEach((l) => {
listItems[l.id].classList.toggle("is-selected", l.id === id);
groups[l.id].classList.toggle("is-selected", l.id === id);
});
infoCard.classList.remove("flash");
// restart the flash animation
void infoCard.offsetWidth;
infoCard.classList.add("flash");
}
function resetInfo() {
selectedId = null;
infoPart.textContent = "FR-68";
infoCount.textContent = "×" + TOTAL_PARTS;
infoName.textContent = "Full assembly";
infoMaterial.textContent = "Select a layer or callout to inspect its specs.";
infoFact.textContent = "Every part in this drawing is serviceable with a single T6 driver and a keycap puller.";
LAYERS.forEach((l) => {
listItems[l.id].classList.remove("is-selected");
groups[l.id].classList.remove("is-selected");
});
}
// ---------- scroll driving ----------
function scrollProgress() {
const rect = stage.getBoundingClientRect();
const range = stage.offsetHeight - window.innerHeight;
if (range <= 0) return 0;
return clamp(-rect.top / range, 0, 1);
}
function onScroll() {
if (animRaf) return; // button animation owns the scroll right now
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = null;
render(scrollProgress());
});
}
function scrollTargetFor(p) {
const top = stage.getBoundingClientRect().top + window.scrollY;
const range = stage.offsetHeight - window.innerHeight;
return top + p * range;
}
// ---------- button animation ----------
function animateTo(target, label) {
if (animRaf) cancelAnimationFrame(animRaf);
if (reduced) {
render(target);
toast(label);
return;
}
// Animate window scroll so scroll position and progress stay in sync.
const fromY = window.scrollY;
const toY = scrollTargetFor(target);
const dur = clamp(Math.abs(toY - fromY) * 0.45, 500, 1600);
const t0 = performance.now();
function step(now) {
const t = clamp((now - t0) / dur, 0, 1);
const y = fromY + (toY - fromY) * easeInOut(t);
window.scrollTo(0, y);
render(scrollProgress());
if (t < 1) {
animRaf = requestAnimationFrame(step);
} else {
animRaf = null;
toast(label);
}
}
animRaf = requestAnimationFrame(step);
}
btnExplode.addEventListener("click", () => animateTo(1, "FULLY EXPLODED · 5 LAYERS APART"));
btnAssemble.addEventListener("click", () => animateTo(0, "REASSEMBLED · TORQUE TO 0.4 N·M"));
// ---------- slider scrub ----------
slider.addEventListener("input", () => {
if (animRaf) {
cancelAnimationFrame(animRaf);
animRaf = null;
}
const p = Number(slider.value) / 1000;
if (reduced) {
render(p);
} else {
// keep scroll position consistent with the scrubbed progress
window.scrollTo(0, scrollTargetFor(p));
render(scrollProgress());
}
});
// ---------- layer selection (sidebar + SVG callouts/groups) ----------
Object.keys(listItems).forEach((id) => {
listItems[id].addEventListener("click", () => {
selectLayer(id);
toast(LAYERS.find((l) => l.id === id).part + " · INSPECTING");
});
});
Object.keys(groups).forEach((id) => {
const g = groups[id];
g.addEventListener("click", () => selectLayer(id));
g.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectLayer(id);
}
});
});
// double-click the info card resets it to the full assembly
infoCard.addEventListener("dblclick", resetInfo);
// ---------- init ----------
if (reduced) {
document.body.classList.add("reduced");
document.getElementById("rm-note").hidden = false;
render(0);
} else {
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
render(scrollProgress());
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DIY — Exploded View Animation</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- ============ HERO ============ -->
<header class="hero">
<div class="hero-inner">
<div class="hero-badges">
<span class="badge badge-mono">DWG-EXP-068</span>
<span class="badge badge-orange">TEARDOWN SHEET</span>
<span class="badge badge-mono">REV C · 68-KEY</span>
</div>
<h1 class="hero-title">Foundry FR-68<span class="hero-title-sub">Exploded assembly view</span></h1>
<p class="hero-lede">
Scroll to pull the keyboard apart, layer by layer — keycaps, switches, plate,
PCB and case. Click any callout for material specs and part counts.
</p>
<div class="hero-meta">
<span><strong class="mono">5</strong> layers</span>
<span class="dot" aria-hidden="true"></span>
<span><strong class="mono">141</strong> parts</span>
<span class="dot" aria-hidden="true"></span>
<span>Bolt & Solder Workshop Co.</span>
</div>
<div class="scroll-hint" aria-hidden="true">
<span class="scroll-hint-arrow">▼</span> scroll to explode
</div>
</div>
</header>
<!-- ============ STICKY STAGE ============ -->
<section class="scroll-stage" id="scroll-stage" aria-label="Exploded view of the Foundry FR-68 keyboard">
<div class="stage-sticky">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-head">
<span class="sidebar-kicker mono">PARTS INDEX</span>
<h2 class="sidebar-title">Layer stack</h2>
</div>
<ol class="layer-list" id="layer-list">
<li>
<button class="layer-item" data-layer="keycaps" type="button">
<span class="layer-num mono">01</span>
<span class="layer-name">Keycaps</span>
<span class="layer-part mono">KC-PBT-68</span>
</button>
</li>
<li>
<button class="layer-item" data-layer="switches" type="button">
<span class="layer-num mono">02</span>
<span class="layer-name">Switches</span>
<span class="layer-part mono">SW-TAC-62</span>
</button>
</li>
<li>
<button class="layer-item" data-layer="plate" type="button">
<span class="layer-num mono">03</span>
<span class="layer-name">Plate</span>
<span class="layer-part mono">PL-ALU-15</span>
</button>
</li>
<li>
<button class="layer-item" data-layer="pcb" type="button">
<span class="layer-num mono">04</span>
<span class="layer-name">PCB</span>
<span class="layer-part mono">PCB-HS-R3</span>
</button>
</li>
<li>
<button class="layer-item" data-layer="case" type="button">
<span class="layer-num mono">05</span>
<span class="layer-name">Case</span>
<span class="layer-part mono">CS-CNC-2P</span>
</button>
</li>
</ol>
<!-- Info card -->
<div class="info-card" id="info-card" role="region" aria-live="polite" aria-label="Selected layer details">
<div class="info-card-top">
<span class="info-part mono" id="info-part">FR-68</span>
<span class="info-count mono" id="info-count">×141</span>
</div>
<h3 class="info-name" id="info-name">Full assembly</h3>
<p class="info-material" id="info-material">Select a layer or callout to inspect its specs.</p>
<p class="info-fact" id="info-fact">Every part in this drawing is serviceable with a single T6 driver and a keycap puller.</p>
</div>
<!-- Controls -->
<div class="controls">
<div class="controls-row">
<button class="btn btn-primary" id="btn-explode" type="button">Explode</button>
<button class="btn btn-ghost" id="btn-assemble" type="button">Assemble</button>
</div>
<label class="scrub-label" for="scrub">
<span>Scrub</span>
<span class="mono" id="scrub-readout">0%</span>
</label>
<input type="range" id="scrub" min="0" max="1000" value="0" step="1"
aria-label="Explode progress" aria-valuetext="0 percent exploded" />
<p class="rm-note" id="rm-note" hidden>Reduced motion is on — use the slider or buttons.</p>
</div>
</aside>
<!-- Blueprint viewport -->
<div class="viewport">
<div class="viewport-corner tl mono" aria-hidden="true">FIG. 1</div>
<div class="viewport-corner tr mono" aria-hidden="true">SCALE 1:1</div>
<div class="viewport-corner bl mono" aria-hidden="true">SHEET 3 / 7</div>
<div class="viewport-status mono" id="stage-status">ASSEMBLED</div>
<svg id="exploded-svg" viewBox="0 0 640 620" role="img"
aria-label="Exploded diagram: keycaps, switches, plate, PCB and case separating vertically">
<defs>
<pattern id="p-keys" width="30" height="26" patternUnits="userSpaceOnUse">
<rect x="3" y="3" width="24" height="20" rx="4" fill="rgba(255,255,255,0.55)" stroke="rgba(28,39,51,0.28)" stroke-width="1.2" />
</pattern>
<pattern id="p-stems" width="30" height="22" patternUnits="userSpaceOnUse">
<rect x="10" y="4" width="10" height="8" rx="2" fill="#ff6b35" opacity="0.9" />
<rect x="7" y="12" width="16" height="7" rx="2" fill="rgba(28,39,51,0.35)" />
</pattern>
<pattern id="p-traces" width="34" height="12" patternUnits="userSpaceOnUse">
<path d="M0 6 H12 L18 2 H34" fill="none" stroke="#ff6b35" stroke-width="1.1" opacity="0.7" />
<circle cx="12" cy="6" r="1.6" fill="#ffe3d6" opacity="0.8" />
</pattern>
</defs>
<!-- 05 · CASE -->
<g class="layer" id="g-case" data-layer="case" tabindex="0" role="button" aria-label="Case layer — open details">
<path class="face-top" d="M130,328 L160,312 L460,312 L430,328 Z" fill="#4c5a68" />
<path class="face-side" d="M430,328 L460,312 L460,358 L430,374 Z" fill="#2e3944" />
<rect class="face-front" x="130" y="328" width="300" height="46" rx="6" fill="#3d4a57" />
<rect x="130" y="362" width="300" height="12" rx="5" fill="#2e3944" />
<circle cx="152" cy="350" r="4" fill="#1c2733" stroke="#8fa1b0" stroke-width="1" />
<circle cx="408" cy="350" r="4" fill="#1c2733" stroke="#8fa1b0" stroke-width="1" />
<text x="280" y="354" class="etch mono" text-anchor="middle">FOUNDRY FR-68 · CNC 6063</text>
<g class="callout" id="lbl-case">
<line x1="436" y1="351" x2="478" y2="351" />
<circle cx="436" cy="351" r="3" />
<rect x="478" y="339" width="118" height="24" rx="5" />
<text x="537" y="355" text-anchor="middle" class="mono">05 · CASE</text>
</g>
</g>
<!-- 04 · PCB -->
<g class="layer" id="g-pcb" data-layer="pcb" tabindex="0" role="button" aria-label="PCB layer — open details">
<path class="face-top" d="M138,314 L166,299 L454,299 L426,314 Z" fill="#1d4675" />
<path class="face-side" d="M426,314 L454,299 L454,311 L426,326 Z" fill="#0a1e36" />
<rect class="face-front" x="138" y="314" width="288" height="12" rx="3" fill="#16385f" />
<rect x="138" y="314" width="288" height="12" rx="3" fill="url(#p-traces)" />
<g class="callout" id="lbl-pcb">
<line x1="432" y1="320" x2="478" y2="320" />
<circle cx="432" cy="320" r="3" />
<rect x="478" y="308" width="118" height="24" rx="5" />
<text x="537" y="324" text-anchor="middle" class="mono">04 · PCB</text>
</g>
</g>
<!-- 03 · PLATE -->
<g class="layer" id="g-plate" data-layer="plate" tabindex="0" role="button" aria-label="Plate layer — open details">
<path class="face-top" d="M134,302 L163,286 L457,286 L428,302 Z" fill="#dbe3ea" />
<path class="face-side" d="M428,302 L457,286 L457,296 L428,312 Z" fill="#93a3b1" />
<rect class="face-front" x="134" y="302" width="294" height="10" rx="3" fill="#b8c4cf" />
<line x1="150" y1="307" x2="412" y2="307" stroke="rgba(28,39,51,0.25)" stroke-width="1" stroke-dasharray="10 8" />
<g class="callout" id="lbl-plate">
<line x1="434" y1="307" x2="478" y2="307" />
<circle cx="434" cy="307" r="3" />
<rect x="478" y="295" width="118" height="24" rx="5" />
<text x="537" y="311" text-anchor="middle" class="mono">03 · PLATE</text>
</g>
</g>
<!-- 02 · SWITCHES -->
<g class="layer" id="g-switches" data-layer="switches" tabindex="0" role="button" aria-label="Switches layer — open details">
<path class="face-top" d="M132,278 L161,262 L459,262 L430,278 Z" fill="#ffd9c6" />
<path class="face-side" d="M430,278 L459,262 L459,284 L430,300 Z" fill="#f0b89e" />
<rect class="face-front" x="132" y="278" width="298" height="22" rx="4" fill="#ffe3d6" />
<rect x="132" y="278" width="298" height="22" rx="4" fill="url(#p-stems)" />
<g class="callout" id="lbl-switches">
<line x1="436" y1="289" x2="478" y2="289" />
<circle cx="436" cy="289" r="3" />
<rect x="478" y="277" width="118" height="24" rx="5" />
<text x="537" y="293" text-anchor="middle" class="mono">02 · SWITCHES</text>
</g>
</g>
<!-- 01 · KEYCAPS -->
<g class="layer" id="g-keycaps" data-layer="keycaps" tabindex="0" role="button" aria-label="Keycaps layer — open details">
<path class="face-top" d="M130,250 L160,234 L460,234 L430,250 Z" fill="#f7f1e4" />
<path class="face-side" d="M430,250 L460,234 L460,260 L430,276 Z" fill="#d9c7a7" />
<rect class="face-front" x="130" y="250" width="300" height="26" rx="5" fill="#efe6d4" />
<rect x="130" y="250" width="300" height="26" rx="5" fill="url(#p-keys)" />
<g class="callout" id="lbl-keycaps">
<line x1="436" y1="263" x2="478" y2="263" />
<circle cx="436" cy="263" r="3" />
<rect x="478" y="251" width="118" height="24" rx="5" />
<text x="537" y="267" text-anchor="middle" class="mono">01 · KEYCAPS</text>
</g>
</g>
<!-- centerline -->
<line x1="280" y1="30" x2="280" y2="590" stroke="rgba(140,180,220,0.22)" stroke-width="1" stroke-dasharray="4 10" aria-hidden="true" />
</svg>
<!-- Progress rail -->
<div class="rail" aria-hidden="true">
<span class="rail-cap mono">100</span>
<div class="rail-track"><div class="rail-fill" id="rail-fill"></div></div>
<span class="rail-cap mono">0</span>
<span class="rail-pct mono" id="rail-pct">0%</span>
<span class="rail-label mono">EXPLODE</span>
</div>
</div>
</div>
</section>
<!-- ============ OUTRO ============ -->
<footer class="outro">
<div class="outro-inner">
<span class="badge badge-mono">END OF SHEET 3</span>
<h2 class="outro-title">Reassembly is the reverse of removal</h2>
<p class="outro-copy">
Torque case screws to <span class="mono">0.4 N·m</span>, reseat all
<span class="mono">68</span> switches before dropping the plate, and never force a keycap —
pull straight up with the wire puller in bag <span class="mono">B</span>.
</p>
</div>
</footer>
<div class="toast mono" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Exploded View Animation
A teardown sheet for the fictional Foundry FR-68 keyboard, rendered as a scroll-driven exploded diagram. A tall stage with a position: sticky viewport keeps the blueprint in frame while five SVG layers — keycaps, switches, plate, PCB and case — translate apart with a small per-layer stagger and cubic easing. As each layer separates, its callout (dashed leader line plus a mono label chip) fades in, and a vertical progress rail on the right reports the explode percentage in real time.
Everything on the sheet is interactive. The sidebar lists the five layers with part numbers and highlights whichever one is currently separating; clicking a list item or an SVG callout opens a spec card showing the material, a mono part count and a workshop fun fact. The safety-orange Explode and Assemble buttons animate the whole sequence by smoothly driving the scroll position, so the diagram, rail and slider stay perfectly in sync, while the range slider scrubs the teardown manually to any frame.
When prefers-reduced-motion is set, the sticky scroll stage collapses to a static page: the scroll binding is skipped entirely and the slider and buttons update the diagram directly with no animation. Layers and callouts are keyboard-focusable buttons, status text is announced via aria-live, and a small toast helper confirms each action in workshop voice.
Illustrative UI only — always follow real safety guidance when working with tools, electronics, or repairs.