04Neural-node Pulse That Hops Along a Delay Value
Across a small grid of nodes and lines, each line carries its own delay value so a light signal appears to hop from node to node in sequence rather than flashing all at once.
Best forAI model-inference indicators, complex backend-job progress screens
STEPEDGE_MSPULSE_W

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>Click or press the space bar to pause or restart</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="Neural-node pulse test panel" tabindex="0">
<canvas class="ld__cv" id="cv"></canvas>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">0%</span><span class="ld__badgeUnit">Propagate</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// neural-node-pulse — 각 선마다 다른 지연(delay) 값을 줘 빛 신호가 마디에서 마디로
// 순서대로 건너가듯 퍼진다. 격자 거리 기반 지연 계산 + 캔버스 rAF — @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ld {
--paper: #{$stage-paper};
--line-dim: #{rgba($subject-ink, .16)};
--line-lit: #{$subject-blue-deep};
--node-dim: #{rgba($subject-ink, .28)};
--node-lit: #{$subject-blue-deep};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-deep; }
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .14);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ld__cv { display: block; width: 100%; height: 100%; }
.ld__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .82);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $stage-yellow; }
.ld__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
.ld__badgeNum { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__badge { opacity: .92 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--paper: #ffffff;
--line-dim: rgba(23, 20, 26, 0.16);
--line-lit: #0a4fe8;
--node-dim: rgba(23, 20, 26, 0.28);
--node-lit: #0a4fe8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #0a4fe8;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.14);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ld__cv {
display: block;
width: 100%;
height: 100%;
}
.ld__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ld__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
.ld__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
var LOOP = 2.0;
var COLS = 4, ROWS = 3;
var STEP = 0.22; // 마디 지연(delay) 값 1칸 간격(초)
var EDGE_MS = 0.34; // 선 하나가 밝아지는 데 걸리는 시간(초)
var PULSE_W = 0.30; // 마디 펄스 폭(초)
var root = document.getElementById('ld');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var pctEl = document.getElementById('pct');
var paused = false;
var t0 = performance.now();
var raf = 0;
var W = 0, H = 0, DPR = 1;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function seeded(i) { var v = Math.sin(i * 12.9898) * 43758.5453; return v - Math.floor(v); }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
var nodes = [];
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var dist = r + c; // 원점에서 이 마디까지 격자 거리
nodes.push({ r: r, c: c, delay: dist * STEP + seeded(r * COLS + c) * 0.04 });
}
}
function idx(r, c) { return r * COLS + c; }
var edges = [];
for (var r2 = 0; r2 < ROWS; r2++) {
for (var c2 = 0; c2 < COLS; c2++) {
if (c2 < COLS - 1) edges.push({ a: idx(r2, c2), b: idx(r2, c2 + 1) });
if (r2 < ROWS - 1) edges.push({ a: idx(r2, c2), b: idx(r2 + 1, c2) });
}
}
for (var e = 0; e < edges.length; e++) {
var ea = nodes[edges[e].a], eb = nodes[edges[e].b];
edges[e].delay = Math.min(ea.delay, eb.delay);
}
var maxDelay = 0;
for (var m = 0; m < nodes.length; m++) if (nodes[m].delay > maxDelay) maxDelay = nodes[m].delay;
function fit() {
var r = field.getBoundingClientRect();
DPR = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * DPR);
cv.height = Math.round(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
function pos(n) {
var padX = W * 0.14, padY = H * 0.18;
return {
x: padX + (W - padX * 2) * (n.c / (COLS - 1)),
y: padY + (H - padY * 2) * (n.r / (ROWS - 1))
};
}
var cs = getComputedStyle(root);
var PAPER = cs.getPropertyValue('--paper').trim();
var LINE_DIM = cs.getPropertyValue('--line-dim').trim();
var LINE_LIT = cs.getPropertyValue('--line-lit').trim();
var NODE_DIM = cs.getPropertyValue('--node-dim').trim();
var NODE_LIT = cs.getPropertyValue('--node-lit').trim();
function draw(sec) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
var t = sec % LOOP;
for (var i = 0; i < edges.length; i++) {
var ed = edges[i];
var pa = pos(nodes[ed.a]), pb = pos(nodes[ed.b]);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.strokeStyle = LINE_DIM;
ctx.lineWidth = 2;
ctx.stroke();
var local = t - ed.delay;
if (local > 0) {
var frac = clamp01(local / EDGE_MS);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pa.x + (pb.x - pa.x) * frac, pa.y + (pb.y - pa.y) * frac);
ctx.strokeStyle = LINE_LIT;
ctx.lineWidth = 3;
ctx.stroke();
}
}
for (var j = 0; j < nodes.length; j++) {
var n = nodes[j];
var p = pos(n);
var localN = t - n.delay;
var glow = 0;
if (localN >= 0) {
glow = localN < PULSE_W ? (localN / PULSE_W) : Math.max(0.32, 1 - (localN - PULSE_W) / 0.7);
}
ctx.beginPath();
ctx.arc(p.x, p.y, 5 + glow * 5, 0, Math.PI * 2);
ctx.fillStyle = glow > 0 ? NODE_LIT : NODE_DIM;
if (glow > 0) { ctx.shadowColor = NODE_LIT; ctx.shadowBlur = 14 * glow; }
ctx.fill();
ctx.shadowBlur = 0;
}
var pct = Math.round(clamp01(t / (maxDelay + EDGE_MS)) * 100);
pctEl.textContent = pct + '%';
}
function togglePause() { paused = !paused; root.classList.toggle('is-paused', paused); }
function restart() { t0 = performance.now(); draw(0); }
function onKey(ev) {
if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); togglePause(); }
else if (ev.key === 'r' || ev.key === 'R') { ev.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
fit();
if (paused || reduced()) draw((performance.now() - t0) / 1000);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
fit();
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// NeuralPulse — vanilla 판(run/403/demo/04-ai-processing-loading-neural-node-pulse/index.html) 의 React 포트.
// 각 선마다 다른 지연(delay) 값을 줘 빛 신호가 마디에서 마디로 순서대로 건너가듯 퍼진다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./NeuralPulse.module.scss";
export type NeuralPulseProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Node = { r: number; c: number; delay: number };
type Edge = { a: number; b: number; delay: number };
const LOOP = 2.0;
const COLS = 4, ROWS = 3;
const STEP = 0.22;
const EDGE_MS = 0.34;
const PULSE_W = 0.30;
const FIELD_LABEL = "신경망 마디 펄스 실험판";
function seeded(i: number): number { const v = Math.sin(i * 12.9898) * 43758.5453; return v - Math.floor(v); }
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
function idx(r: number, c: number): number { return r * COLS + c; }
const NODES: Node[] = (() => {
const arr: Node[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const dist = r + c;
arr.push({ r, c, delay: dist * STEP + seeded(r * COLS + c) * 0.04 });
}
}
return arr;
})();
const EDGES: Edge[] = (() => {
const arr: Edge[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (c < COLS - 1) arr.push({ a: idx(r, c), b: idx(r, c + 1), delay: 0 });
if (r < ROWS - 1) arr.push({ a: idx(r, c), b: idx(r + 1, c), delay: 0 });
}
}
for (const ed of arr) {
ed.delay = Math.min(NODES[ed.a].delay, NODES[ed.b].delay);
}
return arr;
})();
let MAX_DELAY = 0;
for (const n of NODES) if (n.delay > MAX_DELAY) MAX_DELAY = n.delay;
export default function NeuralPulse({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#0a4fe8",
}: NeuralPulseProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<{ w: number; h: number; dpr: number }>;
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
sizeRef.current.dpr = dpr;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function pos(n: Node): { x: number; y: number } {
const W = sizeRef.current.w, H = sizeRef.current.h;
const padX = W * 0.14, padY = H * 0.18;
return {
x: padX + (W - padX * 2) * (n.c / (COLS - 1)),
y: padY + (H - padY * 2) * (n.r / (ROWS - 1)),
};
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const pctEl = pctRef.current;
if (!ctx || !root) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER = cssv("--paper"), LINE_DIM = cssv("--line-dim"), LINE_LIT = cssv("--line-lit");
const NODE_DIM = cssv("--node-dim"), NODE_LIT = cssv("--node-lit");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
const t = sec % LOOP;
for (const ed of EDGES) {
const pa = pos(NODES[ed.a]), pb = pos(NODES[ed.b]);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.strokeStyle = LINE_DIM;
ctx.lineWidth = 2;
ctx.stroke();
const local = t - ed.delay;
if (local > 0) {
const frac = clamp01(local / EDGE_MS);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pa.x + (pb.x - pa.x) * frac, pa.y + (pb.y - pa.y) * frac);
ctx.strokeStyle = LINE_LIT;
ctx.lineWidth = 3;
ctx.stroke();
}
}
for (const n of NODES) {
const p = pos(n);
const localN = t - n.delay;
let glow = 0;
if (localN >= 0) {
glow = localN < PULSE_W ? (localN / PULSE_W) : Math.max(0.32, 1 - (localN - PULSE_W) / 0.7);
}
ctx.beginPath();
ctx.arc(p.x, p.y, 5 + glow * 5, 0, Math.PI * 2);
ctx.fillStyle = glow > 0 ? NODE_LIT : NODE_DIM;
if (glow > 0) { ctx.shadowColor = NODE_LIT; ctx.shadowBlur = 14 * glow; }
ctx.fill();
ctx.shadowBlur = 0;
}
if (pctEl) pctEl.textContent = Math.round(clamp01(t / (MAX_DELAY + EDGE_MS)) * 100) + "%";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
fit();
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
t0Ref.current = performance.now();
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(LOOP * 0.62);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>0%</span><span className={styles.badgeUnit}>전파</span></b>
</div>
</div>
</main>
);
}04 Neural-node Pulse That Hops Along a Delay Value — Measured area 11.583% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · AI Loading Animation: 9 Patterns by One Value
The post this demo lives in
AI Loading Animation: 9 Patterns by One ValueOne ai loading animation value: A high orbit-radius value scatters particles wide; near zero they snap onto the core, hu See all demos in this post
If you like this effect - similar demos
Loaders09 Reduced-motion static download notice — with reduced motion, only the progress value's label changes instead of animatingDownload Progress Bar: 9 Loading UI Patterns
Loaders09 Widget Where an Idle-time Value Computes a Delay via Lazy LoadingLazy Loading Images: 9 CSS Placeholder Patterns
Loaders07 Loader That Recomputes Front-to-Back Order Every Time a Panel FlipsCSS 3D Cube Animation Loaders, 9 Patterns