DIY — Blueprint Line-Draw Effect
A self-drawing technical blueprint plate for a fictional wall-mounted shelf bracket, rendered on a CSS-gradient grid in navy and orange. Outlines ink themselves in via stroke-dashoffset, then section hatching, centre lines, extension lines, arrow-headed dimension lines, mono labels and a corner title block arrive on staggered delays. Controls cover replay, a speed multiplier, layer-by-layer step-through and visibility toggles, with IntersectionObserver triggering, hover-linked edge highlighting and a reduced-motion instant render.
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;
--sh-1:0 1px 2px rgba(15,42,74,.06), 0 4px 14px rgba(15,42,74,.07);
--sh-2:0 12px 40px rgba(10,30,54,.18);
/* drawing ink colours (on blueprint) */
--dwg-ink:#dceaff; --dwg-thin:rgba(220,234,255,.62);
--dwg-dim:#ffb894; --dwg-hatch:rgba(180,210,245,.55);
--dwg-centre:rgba(160,200,240,.7);
--speed:1;
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{
margin:0; background:var(--bg); color:var(--ink);
font-family:Inter,system-ui,sans-serif; line-height:1.5;
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
}
.mono{font-family:"JetBrains Mono",ui-monospace,monospace}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
:focus-visible{outline:3px solid var(--orange);outline-offset:2px;border-radius:var(--r-sm)}
/* ── topbar ───────────────────────────────── */
.topbar{
display:flex;align-items:center;justify-content:space-between;gap:16px;
padding:14px clamp(16px,4vw,40px);
background:var(--blueprint-d);color:#e8f1ff;
border-bottom:3px solid var(--orange);
}
.brand{display:flex;align-items:center;gap:12px}
.brand-mark{color:var(--orange);display:flex}
.brand-text strong{display:block;font-size:15px;font-weight:700;letter-spacing:-.01em}
.brand-text small{display:block;font-size:11.5px;color:rgba(220,234,255,.65)}
.brand-meta{display:flex;gap:8px;flex-wrap:wrap}
.chip{
font-family:"JetBrains Mono",monospace;font-size:10.5px;font-weight:600;letter-spacing:.06em;
padding:4px 9px;border-radius:999px;border:1px solid var(--line-2);
background:rgba(255,255,255,.08);color:#dceaff;white-space:nowrap;
}
.chip-ok{background:rgba(47,158,111,.2);border-color:rgba(47,158,111,.5);color:#8ff0c6}
.panel .chip{color:var(--ink-2);background:var(--kraft-l);border-color:var(--line)}
/* ── layout ───────────────────────────────── */
.wrap{max-width:1180px;margin:0 auto;padding:clamp(22px,4vw,44px) clamp(14px,4vw,32px) 72px}
.intro{max-width:70ch;margin-bottom:26px}
.eyebrow{font-size:11px;font-weight:700;letter-spacing:.14em;color:var(--orange-d);margin:0 0 6px}
h1{font-size:clamp(26px,4.4vw,40px);font-weight:800;letter-spacing:-.03em;margin:0 0 10px;line-height:1.12}
.pn{
font-size:.5em;font-weight:700;vertical-align:middle;
background:var(--blueprint);color:#dceaff;padding:5px 10px;border-radius:var(--r-sm);
letter-spacing:.04em;
}
.lede{margin:0;color:var(--ink-2);font-size:15.5px}
.panel{
background:var(--white);border:1px solid var(--line);border-radius:var(--r-lg);
box-shadow:var(--sh-1);overflow:hidden;
}
.panel + .grid2,.panel + .panel{margin-top:22px}
.panel-head{
display:flex;align-items:center;justify-content:space-between;gap:12px;
padding:14px 18px;border-bottom:1px solid var(--line);background:var(--kraft-l);
}
.panel-head h2{margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em}
.status{
font-size:11px;font-weight:600;letter-spacing:.1em;color:var(--ink-2);
padding:4px 10px;border-radius:999px;background:var(--white);border:1px solid var(--line);
}
.status[data-live="1"]{color:var(--orange-d);border-color:var(--orange);background:var(--orange-soft)}
.stage{display:grid;grid-template-columns:1fr 210px;gap:0;align-items:stretch}
/* ── blueprint plate ──────────────────────── */
.plate{
position:relative;padding:10px;
background:
linear-gradient(rgba(140,180,220,.26) 1px,transparent 1px) 0 0/80px 80px,
linear-gradient(90deg,rgba(140,180,220,.26) 1px,transparent 1px) 0 0/80px 80px,
linear-gradient(var(--grid) 1px,transparent 1px) 0 0/8px 8px,
linear-gradient(90deg,var(--grid) 1px,transparent 1px) 0 0/8px 8px,
radial-gradient(120% 100% at 25% 0%,var(--blueprint-l),var(--blueprint) 45%,var(--blueprint-d));
}
.plate::after{
content:"";position:absolute;inset:14px;border:1px solid rgba(160,200,240,.28);
border-radius:var(--r-sm);pointer-events:none;
}
.dwg{display:block;width:100%;height:auto}
/* layers hidden until played */
.layer{opacity:1}
.plate[data-state="idle"] .layer{visibility:hidden}
.layer[hidden]{display:none}
.ink{fill:none;stroke:var(--dwg-ink);stroke-width:2.4;stroke-linejoin:round;stroke-linecap:round;
filter:drop-shadow(0 0 4px rgba(160,210,255,.35))}
.ink.thin{stroke-width:1.2;stroke:var(--dwg-thin)}
.hole{stroke-width:1.8;stroke:var(--dwg-thin)}
.hatchfill{fill:url(#hatch);stroke:none;opacity:0}
.centre{fill:none;stroke:var(--dwg-centre);stroke-width:1.1;stroke-dasharray:14 4 3 4}
.ext,.dim{fill:none;stroke:var(--dwg-dim);stroke-width:1.3}
.dim{marker-start:url(#arw);marker-end:url(#arw)}
.ext{opacity:.75}
.lbl rect{fill:rgba(10,30,54,.86);stroke:var(--dwg-dim);stroke-width:1}
.lbl text{
font-family:"JetBrains Mono",monospace;font-size:13px;font-weight:600;
fill:var(--dwg-dim);text-anchor:middle;
}
.lbl{cursor:pointer}
.lbl:hover rect,.lbl:focus-visible rect{fill:var(--orange);stroke:var(--white)}
.lbl:hover text,.lbl:focus-visible text{fill:#12243c}
.note{font-size:11px;fill:rgba(220,234,255,.6);letter-spacing:.06em}
/* highlight of the measured edge */
.ink.is-hot{stroke:var(--orange);stroke-width:3.4;filter:drop-shadow(0 0 8px rgba(255,107,53,.65))}
.ext.is-hot,.dim.is-hot{stroke:var(--orange);stroke-width:2.2;opacity:1}
.tb{fill:rgba(10,30,54,.8);stroke:var(--dwg-ink);stroke-width:1.4}
.tb-l{stroke:rgba(220,234,255,.45);stroke-width:1}
.tb-k{font-family:"JetBrains Mono",monospace;font-size:7px;letter-spacing:.12em;fill:rgba(220,234,255,.55)}
.tb-v{font-family:"JetBrains Mono",monospace;font-size:9px;font-weight:700;fill:#dceaff}
/* ── drawing animation ────────────────────── */
.plate[data-state="drawing"] .ink,
.plate[data-state="drawing"] .centre,
.plate[data-state="drawing"] .ext,
.plate[data-state="drawing"] .dim,
.plate[data-state="done"] .ink,
.plate[data-state="done"] .centre,
.plate[data-state="done"] .ext,
.plate[data-state="done"] .dim{visibility:visible}
.stroking{
stroke-dashoffset:var(--len);
animation:draw calc(var(--dur,900ms) / var(--speed)) cubic-bezier(.65,.02,.32,1) var(--delay,0ms) forwards;
}
@keyframes draw{to{stroke-dashoffset:0}}
.fading{opacity:0;animation:fade calc(420ms / var(--speed)) ease var(--delay,0ms) forwards}
@keyframes fade{to{opacity:1}}
.hatchfill.fading{animation-name:fadeHatch}
@keyframes fadeHatch{to{opacity:1}}
.tblayer.fading,.lbl.fading,.note.fading{transform-origin:center}
/* ── legend ───────────────────────────────── */
.legend{
list-style:none;margin:0;padding:14px;display:flex;flex-direction:column;gap:6px;
border-left:1px solid var(--line);background:var(--kraft-l);
}
.legend li{
display:flex;align-items:center;gap:9px;font-size:12px;color:var(--muted);
padding:7px 9px;border-radius:var(--r-sm);border:1px solid transparent;
font-family:"JetBrains Mono",monospace;letter-spacing:.02em;transition:.18s;
}
.legend li .dot{width:8px;height:8px;border-radius:50%;background:var(--line-2);flex:none;transition:.18s}
.legend li[data-on="1"]{background:var(--white);border-color:var(--line);color:var(--ink)}
.legend li[data-on="1"] .dot{background:var(--ok)}
.legend li[data-cur="1"]{border-color:var(--orange);background:var(--orange-soft);color:var(--orange-d)}
.legend li[data-cur="1"] .dot{background:var(--orange)}
.legend li[data-off="1"]{opacity:.42;text-decoration:line-through}
/* ── controls ─────────────────────────────── */
.controls{padding:16px 18px;display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--line)}
.ctl-row{display:flex;flex-wrap:wrap;align-items:center;gap:12px}
.btn{
font-family:inherit;font-size:13.5px;font-weight:600;cursor:pointer;
padding:9px 15px;border-radius:var(--r-sm);border:1px solid var(--line-2);
background:var(--white);color:var(--ink);transition:transform .12s,background .18s,box-shadow .18s;
}
.btn:hover{background:var(--kraft-l)}
.btn:active{transform:translateY(1px)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn-primary{background:var(--orange);border-color:var(--orange-d);color:#fff;box-shadow:0 2px 0 var(--orange-d)}
.btn-primary:hover{background:var(--orange-d)}
.btn-primary:active{box-shadow:0 0 0 var(--orange-d)}
.btn-block{margin:12px 18px 18px;display:block;width:calc(100% - 36px)}
.stepper{display:flex;align-items:center;gap:6px;background:var(--kraft-l);padding:4px;border-radius:var(--r-md)}
.step-count{font-size:12px;font-weight:700;color:var(--ink-2);min-width:46px;text-align:center}
.switch{display:inline-flex;align-items:center;gap:9px;cursor:pointer;font-size:13.5px;font-weight:500}
.switch input{position:absolute;opacity:0;width:1px;height:1px}
.switch .track{
width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;transition:.2s;flex:none;
}
.switch .track::after{
content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;
background:#fff;transition:.2s;box-shadow:0 1px 3px rgba(0,0,0,.25);
}
.switch input:checked + .track{background:var(--orange)}
.switch input:checked + .track::after{transform:translateX(18px)}
.switch input:focus-visible + .track{outline:3px solid var(--orange);outline-offset:2px}
.slider{display:flex;align-items:center;gap:10px;font-size:11px;letter-spacing:.1em;color:var(--muted)}
.slider input[type=range]{width:150px;accent-color:var(--orange)}
#speedOut{font-size:12px;font-weight:700;color:var(--ink);min-width:38px}
.toggles{display:flex;flex-wrap:wrap;gap:8px;border:0;margin:0;padding:0}
.tgl{
display:inline-flex;align-items:center;gap:7px;font-size:12.5px;cursor:pointer;
padding:6px 11px;border:1px solid var(--line);border-radius:999px;background:var(--white);transition:.18s;
}
.tgl:hover{border-color:var(--line-2);background:var(--kraft-l)}
.tgl input{accent-color:var(--orange);margin:0}
.tgl:has(input:checked){background:var(--blueprint);color:#dceaff;border-color:var(--blueprint)}
/* ── secondary grid ───────────────────────── */
.grid2{display:grid;grid-template-columns:1.15fr .85fr;gap:22px;margin-top:22px}
.plate-sm{margin:0;border-radius:0}
.bom{list-style:none;margin:0;padding:8px 0}
.bom li{
display:flex;align-items:center;gap:12px;padding:11px 18px;border-bottom:1px solid var(--line);
font-size:13.5px;transition:background .16s;
}
.bom li:last-child{border-bottom:0}
.bom li:hover{background:var(--kraft-l)}
.qty{font-size:12px;font-weight:700;color:var(--orange-d);min-width:26px}
.bom-n{flex:1}
.bom-n em{font-style:normal;color:var(--muted);font-size:12.5px}
.tag{font-size:10.5px;font-weight:600;padding:3px 8px;border-radius:var(--r-sm);background:var(--blueprint);color:#dceaff}
.tag-w{background:var(--kraft);color:var(--ink)}
.foot{font-size:10.5px;letter-spacing:.08em;color:var(--muted);padding:12px 18px;border-top:1px solid var(--line);margin:0}
/* ── toast ────────────────────────────────── */
.toast{
position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);
background:var(--ink);color:#fff;font-size:13px;font-weight:500;
padding:11px 18px;border-radius:var(--r-md);box-shadow:var(--sh-2);
opacity:0;pointer-events:none;transition:opacity .22s,transform .22s;z-index:60;max-width:88vw;
}
.toast[data-show="1"]{opacity:1;transform:translate(-50%,0)}
/* ── responsive ───────────────────────────── */
@media (max-width:900px){
.stage{grid-template-columns:1fr}
.legend{border-left:0;border-top:1px solid var(--line);flex-direction:row;flex-wrap:wrap}
.grid2{grid-template-columns:1fr}
}
@media (max-width:520px){
.topbar{flex-direction:column;align-items:flex-start;gap:10px}
.wrap{padding-top:18px}
h1{font-size:24px}
.pn{display:inline-block;margin-top:6px}
.lede{font-size:14.5px}
.plate{padding:4px}
.plate::after{inset:6px}
.controls{padding:14px}
.ctl-row{gap:10px}
.btn,.stepper,.switch{width:100%;justify-content:center}
.btn-block{width:calc(100% - 28px);margin:12px 14px 14px}
.slider{width:100%}
.slider input[type=range]{flex:1;width:auto}
.legend li{font-size:11px;padding:6px 8px}
.bom li{flex-wrap:wrap;gap:8px}
}
@media (prefers-reduced-motion:reduce){
.stroking,.fading{animation:none!important;stroke-dashoffset:0!important;opacity:1!important}
.btn,.tgl,.bom li,.toast{transition:none}
}/* Northgate Fabrication — blueprint line-draw effect
Vanilla JS. Sequences SVG layers: outlines → hatch → centre lines →
extension lines → dimension lines → labels → title block. */
(() => {
"use strict";
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)");
const LAYERS = [
{ id: 1, name: "Outlines", mode: "stroke", dur: 1400, gap: 900 },
{ id: 2, name: "Section hatch", mode: "fade", dur: 420, gap: 320, key: "hatch" },
{ id: 3, name: "Centre lines", mode: "stroke", dur: 600, gap: 420, key: "centre" },
{ id: 4, name: "Extension lines", mode: "stroke", dur: 520, gap: 380, key: "dim" },
{ id: 5, name: "Dimension lines", mode: "stroke", dur: 520, gap: 400, key: "dim" },
{ id: 6, name: "Dimension text", mode: "fade", dur: 420, gap: 340, key: "dim" },
{ id: 7, name: "Title block", mode: "fade", dur: 420, gap: 300, key: "title" }
];
/* ── toast ─────────────────────────────── */
const toastEl = document.getElementById("toast");
let toastT;
function toast(msg) {
toastEl.textContent = msg;
toastEl.dataset.show = "1";
clearTimeout(toastT);
toastT = setTimeout(() => { toastEl.dataset.show = "0"; }, 2200);
}
/* ── drawing controller ────────────────── */
function makePlate(plateEl, svgEl, opts = {}) {
const groups = new Map();
LAYERS.forEach(l => {
const g = svgEl.querySelector(`.layer[data-layer="${l.id}"]`);
if (g) groups.set(l.id, g);
});
// measure every strokeable path once
const measured = new WeakMap();
function lengthOf(el) {
if (measured.has(el)) return measured.get(el);
let len = 0;
try { len = el.getTotalLength(); } catch (e) { len = 0; }
if (!len || !isFinite(len)) {
const b = el.getBBox();
len = (b.width + b.height) * 2 || 200;
}
measured.set(el, len);
return len;
}
const visible = { hatch: true, centre: true, dim: true, title: true };
let speed = 1;
let timers = [];
let shown = 0; // how many layers are currently inked
let stepping = false;
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
function reset() {
clearTimers();
shown = 0;
plateEl.dataset.state = "idle";
groups.forEach(g => {
g.style.visibility = "";
g.querySelectorAll(".stroking, .fading").forEach(el => {
el.classList.remove("stroking", "fading");
el.style.removeProperty("stroke-dasharray");
el.style.removeProperty("stroke-dashoffset");
el.style.removeProperty("--len");
el.style.removeProperty("--dur");
el.style.removeProperty("--delay");
el.style.removeProperty("opacity");
});
});
applyVisibility();
report();
}
/* ink one layer; delay is only used in continuous mode */
function inkLayer(layer, delayMs) {
const g = groups.get(layer.id);
if (!g) return 0;
g.style.visibility = "visible";
if (layer.mode === "stroke") {
const items = [...g.querySelectorAll("path, circle, line, rect")];
const per = items.length ? layer.dur / items.length : layer.dur;
items.forEach((el, i) => {
const len = lengthOf(el);
el.style.strokeDasharray = `${len} ${len}`;
el.style.setProperty("--len", len);
el.style.setProperty("--dur", `${Math.max(layer.dur * 0.55, per * 2.2)}ms`);
el.style.setProperty("--delay", `${delayMs + i * per * 0.55}ms`);
el.classList.add("stroking");
});
} else {
const items = [...g.children];
const per = items.length ? Math.min(120, layer.dur / items.length) : 0;
items.forEach((el, i) => {
el.style.setProperty("--delay", `${delayMs + i * per}ms`);
el.classList.add("fading");
});
}
return layer.dur;
}
function instant() {
clearTimers();
plateEl.dataset.state = "done";
shown = LAYERS.length;
LAYERS.forEach(l => {
const g = groups.get(l.id);
if (!g) return;
g.style.visibility = "visible";
g.querySelectorAll("*").forEach(el => {
el.style.removeProperty("stroke-dasharray");
el.style.opacity = "1";
});
g.querySelectorAll(".hatchfill").forEach(el => { el.style.opacity = "1"; });
});
applyVisibility();
report();
}
function play() {
reset();
if (REDUCED.matches) { instant(); return; }
plateEl.dataset.state = "drawing";
let t = 0;
LAYERS.forEach((layer) => {
if (!isOn(layer)) return;
const at = t;
timers.push(setTimeout(() => { shown = layer.id; report(); }, at / speed));
inkLayer(layer, at);
t += layer.gap + layer.dur * 0.55;
});
timers.push(setTimeout(() => {
plateEl.dataset.state = "done";
shown = LAYERS.length;
report();
}, t / speed + 500));
}
/* step-through */
function stepNext() {
if (shown === 0) {
clearTimers();
plateEl.dataset.state = "drawing";
}
let n = shown + 1;
while (n <= LAYERS.length && !isOn(LAYERS[n - 1])) n++;
if (n > LAYERS.length) { toast("Drawing complete — all layers inked."); return; }
inkLayer(LAYERS[n - 1], 0);
shown = n;
if (shown >= LAYERS.length) plateEl.dataset.state = "done";
report();
}
function stepPrev() {
if (shown === 0) return;
let n = shown;
const g = groups.get(LAYERS[n - 1].id);
if (g) {
g.style.visibility = "hidden";
g.querySelectorAll(".stroking, .fading").forEach(el => {
el.classList.remove("stroking", "fading");
el.style.removeProperty("opacity");
});
}
n--;
while (n > 0 && !isOn(LAYERS[n - 1])) n--;
shown = n;
plateEl.dataset.state = shown === 0 ? "idle" : "drawing";
report();
}
function isOn(layer) { return !layer.key || visible[layer.key]; }
function applyVisibility() {
LAYERS.forEach(l => {
const g = groups.get(l.id);
if (!g) return;
g.hidden = !isOn(l);
});
}
function setVisible(key, on) {
visible[key] = on;
applyVisibility();
report();
}
function setSpeed(v) {
speed = v;
plateEl.style.setProperty("--speed", v);
}
const listeners = [];
function report() { listeners.forEach(fn => fn({ shown, visible, isOn })); }
return {
play, reset, instant, stepNext, stepPrev, setVisible, setSpeed,
onChange: fn => { listeners.push(fn); fn({ shown, visible, isOn }); },
get shown() { return shown; },
get stepping() { return stepping; },
set stepping(v) { stepping = v; },
svg: svgEl
};
}
/* ── main plate wiring ─────────────────── */
const plate = makePlate(document.getElementById("plate"), document.getElementById("dwg"));
const legendEl = document.getElementById("legend");
LAYERS.forEach(l => {
const li = document.createElement("li");
li.dataset.layer = l.id;
li.innerHTML = `<span class="dot"></span><span>${String(l.id).padStart(2, "0")} ${l.name}</span>`;
legendEl.appendChild(li);
});
const statusEl = document.getElementById("status");
const stepCount = document.getElementById("stepCount");
plate.onChange(({ shown, isOn }) => {
LAYERS.forEach(l => {
const li = legendEl.querySelector(`li[data-layer="${l.id}"]`);
const on = isOn(l);
li.dataset.off = on ? "0" : "1";
li.dataset.on = on && shown >= l.id ? "1" : "0";
li.dataset.cur = on && shown === l.id ? "1" : "0";
});
const active = LAYERS.filter(isOn).length;
const done = LAYERS.filter(l => isOn(l) && shown >= l.id).length;
stepCount.textContent = `${done} / ${active}`;
statusEl.textContent =
shown === 0 ? "IDLE" : done >= active ? "DRAWING COMPLETE" : `INKING · ${LAYERS[shown - 1].name.toUpperCase()}`;
statusEl.dataset.live = shown > 0 && done < active ? "1" : "0";
});
document.getElementById("replay").addEventListener("click", () => {
if (stepModeEl.checked) { plate.reset(); toast("Reset — press Next to ink layer 01."); }
else { plate.play(); toast("Redrawing sheet NG-4821…"); }
});
const stepModeEl = document.getElementById("stepMode");
const prevBtn = document.getElementById("prev");
const nextBtn = document.getElementById("next");
function syncStepUi() {
const on = stepModeEl.checked;
prevBtn.disabled = !on;
nextBtn.disabled = !on;
}
stepModeEl.addEventListener("change", () => {
syncStepUi();
plate.reset();
if (stepModeEl.checked) toast("Step-through on — advance one layer at a time.");
else { plate.play(); }
});
syncStepUi();
prevBtn.addEventListener("click", () => plate.stepPrev());
nextBtn.addEventListener("click", () => plate.stepNext());
const speedEl = document.getElementById("speed");
const speedOut = document.getElementById("speedOut");
speedEl.addEventListener("input", () => {
const v = parseFloat(speedEl.value);
speedOut.textContent = v.toFixed(1) + "×";
plate.setSpeed(v);
});
plate.setSpeed(1);
const toggleMap = { tDim: "dim", tHatch: "hatch", tCentre: "centre", tTitle: "title" };
Object.keys(toggleMap).forEach(id => {
const el = document.getElementById(id);
el.addEventListener("change", () => {
plate.setVisible(toggleMap[id], el.checked);
toast(`${el.parentElement.textContent.trim()} ${el.checked ? "shown" : "hidden"}`);
});
});
/* ── label ↔ edge highlighting ─────────── */
const dwg = document.getElementById("dwg");
function hot(edge, on) {
dwg.querySelectorAll(`[data-edge="${edge}"]`).forEach(el => el.classList.toggle("is-hot", on));
}
dwg.querySelectorAll(".lbl[data-edge]").forEach(lbl => {
const edge = lbl.dataset.edge;
["mouseenter", "focus"].forEach(ev => lbl.addEventListener(ev, () => hot(edge, true)));
["mouseleave", "blur"].forEach(ev => lbl.addEventListener(ev, () => hot(edge, false)));
lbl.addEventListener("click", () => toast(`${lbl.getAttribute("aria-label")} — measured on the highlighted edge.`));
lbl.addEventListener("keydown", e => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); lbl.dispatchEvent(new Event("click")); }
});
});
/* ── secondary detail plate ────────────── */
const plate2 = makePlate(document.getElementById("plate2"), document.getElementById("dwg2"));
plate2.setSpeed(1.3);
document.getElementById("replay2").addEventListener("click", () => {
plate2.setSpeed(parseFloat(speedEl.value) * 1.3);
plate2.play();
toast("Redrawing Detail B…");
});
/* ── IntersectionObserver triggers ─────── */
function observe(target, ctrl, once) {
if (!("IntersectionObserver" in window)) { ctrl.instant(); return; }
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
if (REDUCED.matches) ctrl.instant();
else if (!stepModeEl.checked || ctrl !== plate) ctrl.play();
if (once) io.unobserve(e.target);
});
}, { threshold: 0.35 });
io.observe(target);
}
observe(document.getElementById("plate"), plate, true);
observe(document.getElementById("plate2"), plate2, true);
REDUCED.addEventListener("change", () => { if (REDUCED.matches) { plate.instant(); plate2.instant(); } });
/* keyboard shortcuts */
document.addEventListener("keydown", e => {
if (e.target.matches("input, textarea, select")) return;
if (e.key === "r" || e.key === "R") { plate.play(); toast("Replay (R)"); }
if (stepModeEl.checked && e.key === "ArrowRight") plate.stepNext();
if (stepModeEl.checked && e.key === "ArrowLeft") plate.stepPrev();
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Northgate Fabrication — Blueprint Line-Draw</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>
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="32" height="32"><path d="M4 26 L16 6 L28 26 Z" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/><path d="M10 26 L16 16 L22 26" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
</span>
<span class="brand-text">
<strong>Northgate Fabrication</strong>
<small>Shop drawings · revision C</small>
</span>
</div>
<div class="brand-meta mono">
<span class="chip">JOB NG-4821</span>
<span class="chip chip-ok">RELEASED</span>
</div>
</header>
<main class="wrap">
<section class="intro">
<p class="eyebrow mono">SHEET 01 / 04</p>
<h1>Wall-Mounted Shelf Bracket <span class="pn mono">SB-140</span></h1>
<p class="lede">A self-drawing technical plate. Outlines ink themselves in first, then dimension lines,
extension lines, labels and finally the title block — the same order a drafter would work in.
Scroll it into view to trigger the sequence, or drive it yourself layer by layer.</p>
</section>
<section class="panel" aria-labelledby="plate-h">
<div class="panel-head">
<h2 id="plate-h">Front & side elevation</h2>
<p class="mono status" id="status" role="status" aria-live="polite">IDLE</p>
</div>
<div class="stage" id="stage">
<div class="plate" id="plate" data-state="idle">
<svg class="dwg" id="dwg" viewBox="0 0 900 520" role="img"
aria-label="Technical drawing of shelf bracket SB-140, front elevation 240 by 180 millimetres and side elevation 40 millimetre plate.">
<defs>
<pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="8" stroke="var(--dwg-hatch)" stroke-width="1.1"/>
</pattern>
<marker id="arw" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="var(--dwg-dim)"/>
</marker>
</defs>
<!-- ── LAYER 1 : outlines ─────────────────────────── -->
<g class="layer" data-layer="1" data-name="Outlines">
<!-- front elevation: L bracket with gusset -->
<path class="ink" data-edge="height"
d="M120 120 L120 360 L360 360 L360 320 L160 320 L160 120 Z"/>
<path class="ink" data-edge="width" d="M120 120 L160 120"/>
<!-- gusset -->
<path class="ink thin" d="M160 300 L340 300 M160 300 L160 320 M340 300 L340 320"/>
<path class="ink" d="M160 200 L300 320"/>
<!-- bolt holes -->
<circle class="ink hole" cx="140" cy="160" r="10"/>
<circle class="ink hole" cx="140" cy="230" r="10"/>
<circle class="ink hole" cx="140" cy="300" r="10"/>
<circle class="ink hole" cx="320" cy="340" r="8"/>
<!-- side elevation -->
<path class="ink" data-edge="depth" d="M600 120 L640 120 L640 360 L600 360 Z"/>
<path class="ink" d="M640 320 L800 320 L800 360 L640 360"/>
<path class="ink thin" d="M600 140 L640 140 M600 340 L640 340"/>
</g>
<!-- ── LAYER 2 : hatching ─────────────────────────── -->
<g class="layer hatchlayer" data-layer="2" data-name="Section hatch">
<path class="hatchfill" d="M600 120 L640 120 L640 360 L600 360 Z"/>
<path class="hatchfill" d="M640 320 L800 320 L800 360 L640 360 Z"/>
</g>
<!-- ── LAYER 3 : centre lines ─────────────────────── -->
<g class="layer centrelayer" data-layer="3" data-name="Centre lines">
<path class="centre" d="M140 100 L140 380"/>
<path class="centre" d="M100 340 L360 340"/>
<path class="centre" d="M620 100 L620 380"/>
</g>
<!-- ── LAYER 4 : extension lines ──────────────────── -->
<g class="layer dimlayer" data-layer="4" data-name="Extension lines">
<path class="ext" d="M120 120 L120 70" data-edge="width"/>
<path class="ext" d="M360 360 L360 70" data-edge="width"/>
<path class="ext" d="M120 120 L70 120" data-edge="height"/>
<path class="ext" d="M120 360 L70 360" data-edge="height"/>
<path class="ext" d="M600 120 L600 70" data-edge="depth"/>
<path class="ext" d="M640 120 L640 70" data-edge="depth"/>
<path class="ext" d="M800 320 L800 400" data-edge="arm"/>
<path class="ext" d="M640 360 L640 400" data-edge="arm"/>
</g>
<!-- ── LAYER 5 : dimension lines ──────────────────── -->
<g class="layer dimlayer" data-layer="5" data-name="Dimension lines">
<path class="dim" d="M120 84 L360 84" data-edge="width"/>
<path class="dim" d="M84 120 L84 360" data-edge="height"/>
<path class="dim" d="M600 84 L640 84" data-edge="depth"/>
<path class="dim" d="M640 388 L800 388" data-edge="arm"/>
</g>
<!-- ── LAYER 6 : labels ───────────────────────────── -->
<g class="layer lbllayer" data-layer="6" data-name="Dimension text">
<g class="lbl" data-edge="width" tabindex="0" role="button" aria-label="Overall width 240 millimetres">
<rect x="204" y="62" width="72" height="24" rx="4"/>
<text x="240" y="79">240 mm</text>
</g>
<g class="lbl" data-edge="height" tabindex="0" role="button" aria-label="Overall height 180 millimetres">
<rect x="46" y="228" width="76" height="24" rx="4"/>
<text x="84" y="245">180 mm</text>
</g>
<g class="lbl" data-edge="depth" tabindex="0" role="button" aria-label="Plate thickness 40 millimetres">
<rect x="584" y="62" width="72" height="24" rx="4"/>
<text x="620" y="79">40 mm</text>
</g>
<g class="lbl" data-edge="arm" tabindex="0" role="button" aria-label="Shelf arm 160 millimetres">
<rect x="684" y="376" width="72" height="24" rx="4"/>
<text x="720" y="393">160 mm</text>
</g>
<text class="note mono" x="120" y="430">Ø10 × 3 — M8 CLEARANCE, EQ. SPACED 70</text>
<text class="note mono" x="600" y="440">SECTION A–A</text>
</g>
<!-- ── LAYER 7 : title block ──────────────────────── -->
<g class="layer tblayer" data-layer="7" data-name="Title block">
<rect class="tb" x="560" y="452" width="320" height="52" rx="3"/>
<line class="tb-l" x1="672" y1="452" x2="672" y2="504"/>
<line class="tb-l" x1="784" y1="452" x2="784" y2="504"/>
<line class="tb-l" x1="560" y1="478" x2="880" y2="478"/>
<text class="tb-k" x="568" y="467">DWG NO</text><text class="tb-v" x="568" y="474">NG-4821-SB140</text>
<text class="tb-k" x="680" y="467">SCALE</text><text class="tb-v" x="680" y="474">1 : 2</text>
<text class="tb-k" x="792" y="467">MATERIAL</text><text class="tb-v" x="792" y="474">S275 STEEL 4mm</text>
<text class="tb-k" x="568" y="493">DRAWN BY</text><text class="tb-v" x="568" y="500">R. OKONKWO</text>
<text class="tb-k" x="680" y="493">DATE</text><text class="tb-v" x="680" y="500">2026-07-30</text>
<text class="tb-k" x="792" y="493">REV</text><text class="tb-v" x="792" y="500">C</text>
</g>
</svg>
</div>
<ol class="legend" id="legend" aria-label="Drawing layers"></ol>
</div>
<div class="controls">
<div class="ctl-row">
<button class="btn btn-primary" id="replay" type="button">
<span aria-hidden="true">↻</span> Replay draw
</button>
<div class="stepper" role="group" aria-label="Step through layers">
<button class="btn btn-ghost" id="prev" type="button" aria-label="Previous layer">◀ Prev</button>
<span class="mono step-count" id="stepCount">0 / 7</span>
<button class="btn btn-ghost" id="next" type="button" aria-label="Next layer">Next ▶</button>
</div>
<label class="switch">
<input type="checkbox" id="stepMode">
<span class="track" aria-hidden="true"></span>
<span class="switch-t">Step-through mode</span>
</label>
</div>
<div class="ctl-row">
<label class="slider">
<span class="mono">SPEED</span>
<input type="range" id="speed" min="0.4" max="2.6" step="0.1" value="1"
aria-label="Drawing speed multiplier">
<output class="mono" id="speedOut">1.0×</output>
</label>
<fieldset class="toggles">
<legend class="sr-only">Drawing layer visibility</legend>
<label class="tgl"><input type="checkbox" id="tDim" checked><span>Dimensions</span></label>
<label class="tgl"><input type="checkbox" id="tHatch" checked><span>Hatching</span></label>
<label class="tgl"><input type="checkbox" id="tCentre" checked><span>Centre lines</span></label>
<label class="tgl"><input type="checkbox" id="tTitle" checked><span>Title block</span></label>
</fieldset>
</div>
</div>
</section>
<section class="grid2">
<article class="panel panel-sm" aria-labelledby="fast-h">
<div class="panel-head">
<h2 id="fast-h">Exploded fastener callout</h2>
<span class="chip mono">DETAIL B</span>
</div>
<div class="plate plate-sm" id="plate2" data-state="idle">
<svg class="dwg" id="dwg2" viewBox="0 0 420 260" role="img"
aria-label="Exploded view of M8 fastener stack: bolt, washer, bracket plate, nut.">
<g class="layer" data-layer="1">
<!-- bolt -->
<path class="ink" d="M40 110 L40 150 L64 150 L64 110 Z"/>
<path class="ink" d="M64 122 L160 122 L160 138 L64 138 Z"/>
<path class="ink thin" d="M78 122 L78 138 M92 122 L92 138 M106 122 L106 138 M120 122 L120 138 M134 122 L134 138"/>
<!-- washer -->
<path class="ink" d="M196 100 L206 100 L206 160 L196 160 Z"/>
<!-- plate -->
<path class="ink" d="M244 84 L262 84 L262 176 L244 176 Z"/>
<!-- nut -->
<path class="ink" d="M304 104 L322 96 L340 104 L340 156 L322 164 L304 156 Z"/>
</g>
<g class="layer hatchlayer" data-layer="2">
<path class="hatchfill" d="M244 84 L262 84 L262 176 L244 176 Z"/>
<path class="hatchfill" d="M304 104 L322 96 L340 104 L340 156 L322 164 L304 156 Z"/>
</g>
<g class="layer centrelayer" data-layer="3">
<path class="centre" d="M20 130 L400 130"/>
</g>
<g class="layer dimlayer" data-layer="4">
<path class="ext" d="M64 110 L64 62"/><path class="ext" d="M160 122 L160 62"/>
</g>
<g class="layer dimlayer" data-layer="5">
<path class="dim" d="M64 74 L160 74"/>
</g>
<g class="layer lbllayer" data-layer="6">
<g class="lbl" data-edge="thread"><rect x="76" y="52" width="72" height="22" rx="4"/><text x="112" y="68">M8 × 45</text></g>
<text class="note mono" x="196" y="200">1 WASHER DIN 125</text>
<text class="note mono" x="196" y="216">2 PLATE 4mm S275</text>
<text class="note mono" x="196" y="232">3 NYLOC NUT M8</text>
</g>
<g class="layer tblayer" data-layer="7">
<rect class="tb" x="20" y="196" width="150" height="44" rx="3"/>
<line class="tb-l" x1="20" y1="218" x2="170" y2="218"/>
<text class="tb-k" x="28" y="209">DETAIL</text><text class="tb-v" x="28" y="216">B — FASTENER STACK</text>
<text class="tb-k" x="28" y="231">SCALE</text><text class="tb-v" x="28" y="238">1 : 1</text>
</g>
</svg>
</div>
<button class="btn btn-ghost btn-block" id="replay2" type="button">↻ Redraw detail</button>
</article>
<article class="panel panel-sm" aria-labelledby="spec-h">
<div class="panel-head"><h2 id="spec-h">Cut list & hardware</h2><span class="chip mono">BOM</span></div>
<ul class="bom">
<li><span class="mono qty">1×</span><span class="bom-n">Upright plate <em>240 × 180 × 4</em></span><span class="mono tag">S275</span></li>
<li><span class="mono qty">1×</span><span class="bom-n">Shelf arm <em>160 × 40 × 4</em></span><span class="mono tag">S275</span></li>
<li><span class="mono qty">1×</span><span class="bom-n">Diagonal gusset <em>180 × 30 × 3</em></span><span class="mono tag">S275</span></li>
<li><span class="mono qty">3×</span><span class="bom-n">Hex bolt <em>M8 × 45</em></span><span class="mono tag tag-w">A2-70</span></li>
<li><span class="mono qty">3×</span><span class="bom-n">Nyloc nut <em>M8</em></span><span class="mono tag tag-w">A2-70</span></li>
<li><span class="mono qty">6×</span><span class="bom-n">Form A washer <em>Ø8.4</em></span><span class="mono tag tag-w">A2-70</span></li>
</ul>
<p class="foot mono">EST. LOAD 45 kg @ 120 mm CANTILEVER · FICTIONAL DATA</p>
</article>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Blueprint Line-Draw Effect
The sheet presents shop drawing NG-4821 for a fictional shelf bracket: a front elevation with
bolt holes and a diagonal gusset, plus a side elevation shown in section. Nothing is visible
until the plate scrolls into view — then an IntersectionObserver starts the sequence and the
drawing inks itself in the order a drafter actually works: outlines, section hatch, centre lines,
extension lines, dimension lines, dimension text, title block.
Every layer is addressable. A speed slider scales the whole timeline through a CSS custom property, so slowing to 0.4× stretches the strokes rather than restarting them. Flipping step-through mode hands the sequence to the Next/Prev buttons (or the arrow keys) so you can walk one layer at a time and watch the legend on the right mark each stage as pending, current or inked. Four toggles drop hatching, centre lines, the dimension set or the title block out of the sequence entirely, and the step counter re-bases itself on whatever remains.
Dimension labels are focusable SVG groups: hovering or tabbing to “240 mm” lights the exact edge it measures along with its extension and dimension lines, so the reader can tie a number to geometry without a legend. A second, smaller plate reuses the same controller for an exploded M8 fastener callout, and a matching cut list sits beside it.
How this look is built
The paper is pure CSS — four layered linear gradients over a radial base give a fine 8px grid
with an 80px major line, so there is no image to fetch and it stays crisp at any zoom. The draw
itself measures each shape with getTotalLength(), writes the result into stroke-dasharray
and a --len custom property, then lets a single @keyframes draw rule animate
stroke-dashoffset to zero; per-element --delay and a shared --dur / var(--speed) division
produce the stagger and the speed control without any JS animation loop or per-frame work. That
beats the old JS-timer approach because the strokes run on the compositor and a slider change is
a one-line custom-property write instead of a rebuild. Watch two things: getTotalLength()
forces layout, so measure once and cache it (this build uses a WeakMap); and under
prefers-reduced-motion the controller skips animation entirely and paints the finished state,
which is also the fallback when IntersectionObserver is missing.
Illustrative UI only — always follow real safety guidance when working with tools, electronics, or repairs.