07파동 반경 값이 번지는 스프링 격자 로더
격자로 이은 점들을 스프링으로 연결해두고 진행률을 중심에서 퍼지는 파동 반경 값으로 삼아, 반경이 지나간 점들만 튕겨 올라 물결치는 격자가 남는다.
쓰임히어로 섹션 프리로더·인터랙티브 랜딩 대기 화면
STIFFNESSDAMPripple

<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>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="스프링 격자 파동 실험판" 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">파동</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스프링 격자 로더 — 파동 반경이 번지며 닿은 격자점마다 감쇠 스프링(강성 상수 STIFFNESS)으로 튕긴다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-orange-deep;
@include stage($stage-orange);
.ld {
--paper: #{$subject-cream};
--line: #{rgba($subject-ink, .22)};
--node: #{rgba($subject-ink, .38)};
--node-hot: #{$subject-orange-deep};
--ring: #{rgba($subject-orange-deep, .3)};
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-orange-deep; }
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-orange-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, .78);
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; }
}
// reduce — 루프를 시작하지 않고 진행률 62% 언저리에서 멈춘 한 장만 그린다.
@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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--paper: #fff7e6;
--line: rgba(23, 20, 26, 0.22);
--node: rgba(23, 20, 26, 0.38);
--node-hot: #c93000;
--ring: rgba(201, 48, 0, 0.3);
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: #c93000;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #c93000;
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.78);
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;
}
}// 스프링 격자 로더 — 파동 반경이 중심에서 바깥으로 번지고, 파동이 각 격자점에 닿는 순간
// 그 점은 감쇠 스프링(강성 상수 spring-mesh-stiffness, 닫힌해 SHM)으로 튕겼다가 가라앉는다.
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 css = getComputedStyle(root);
var PAPER = css.getPropertyValue('--paper').trim();
var LINE = css.getPropertyValue('--line').trim();
var NODE = css.getPropertyValue('--node').trim();
var NODE_HOT = css.getPropertyValue('--node-hot').trim();
var RING = css.getPropertyValue('--ring').trim();
var GX = 9, GY = 6;
var STIFFNESS = 9; // 스프링 강성 상수(훅 대상) — spring-mesh-stiffness, 값이 클수록 빨리 흔들린다
var DAMP = 3.4; // 감쇠 계수 — 흔들림이 잦아드는 속도
var AMP = 9; // 최대 변위(px)
var LOOP = 2.2;
var W = 0, H = 0, dpr = 1, cxp = 0, cyp = 0, maxR = 0, pts = [];
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);
cxp = W * 0.5; cyp = H * 0.5;
maxR = Math.max(cxp, cyp) * 1.05 + Math.min(W, H) * 0.1;
var mx = W * 0.1, my = H * 0.14;
pts = [];
for (var gy = 0; gy < GY; gy++) {
for (var gx = 0; gx < GX; gx++) {
var px = mx + (W - mx * 2) * (gx / (GX - 1));
var py = my + (H - my * 2) * (gy / (GY - 1));
var dx = px - cxp, dy = py - cyp;
var dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
pts.push({ px: px, py: py, ux: dx / dist, uy: dy / dist, dist: dist });
}
}
}
function ripple(dt) {
if (dt < 0) return 0;
return AMP * Math.exp(-DAMP * dt) * Math.cos(STIFFNESS * dt);
}
function draw(sec) {
var t = sec % LOOP;
var p = t / LOOP;
var frontR = p * maxR;
var speed = maxR / LOOP;
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = RING;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(cxp, cyp, Math.max(2, frontR), 0, Math.PI * 2);
ctx.stroke();
var disp = new Array(pts.length);
for (var i = 0; i < pts.length; i++) {
var q = pts[i];
var dt = (t - q.dist / speed);
disp[i] = ripple(dt);
}
ctx.strokeStyle = LINE;
ctx.lineWidth = 1.2;
for (var gy = 0; gy < GY; gy++) {
for (var gx = 0; gx < GX; gx++) {
var idx = gy * GX + gx;
var q2 = pts[idx];
var x1 = q2.px + q2.ux * disp[idx];
var y1 = q2.py + q2.uy * disp[idx];
if (gx < GX - 1) {
var idxR = idx + 1;
var qR = pts[idxR];
var x2 = qR.px + qR.ux * disp[idxR];
var y2 = qR.py + qR.uy * disp[idxR];
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
if (gy < GY - 1) {
var idxD = idx + GX;
var qD = pts[idxD];
var x3 = qD.px + qD.ux * disp[idxD];
var y3 = qD.py + qD.uy * disp[idxD];
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x3, y3); ctx.stroke();
}
}
}
for (var k = 0; k < pts.length; k++) {
var qq = pts[k];
var x = qq.px + qq.ux * disp[k];
var y = qq.py + qq.uy * disp[k];
var heat = Math.min(1, Math.abs(disp[k]) / AMP);
ctx.fillStyle = heat > 0.18 ? NODE_HOT : NODE;
ctx.beginPath();
ctx.arc(x, y, 2.6 + heat * 2.4, 0, Math.PI * 2);
ctx.fill();
}
pctEl.textContent = Math.round(p * 100) + '%';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var paused = false;
var t0 = 0;
var raf = 0;
function togglePause() {
paused = !paused;
root.classList.toggle('is-paused', paused);
}
function restart() {
t0 = performance.now();
draw(0);
}
field.addEventListener('click', function () { togglePause(); });
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
});
window.addEventListener('resize', function () {
fit();
if (paused || reduce) draw(((performance.now() - t0) / 1000));
});
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
t0 = performance.now();
fit();
if (reduce) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}// SpringMesh — vanilla 판(run/395/demo/07-canvas-loader-spring-mesh-ripple-radius/index.html) 의 React 포트.
// 파동 반경이 중심에서 번지고, 파동이 닿은 격자점마다 감쇠 스프링(닫힌해 SHM)으로 튕겼다가 가라앉는다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SpringMesh.module.scss";
export type SpringMeshProps = {
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 Pt = { px: number; py: number; ux: number; uy: number; dist: number };
const GX = 9, GY = 6;
const STIFFNESS = 9; // 스프링 강성 상수(훅 대상) — spring-mesh-stiffness, 값이 클수록 빨리 흔들린다
const DAMP = 3.4;
const AMP = 9;
const LOOP = 2.2;
const FIELD_LABEL = "스프링 격자 파동 실험판";
function ripple(dt: number): number {
if (dt < 0) return 0;
return AMP * Math.exp(-DAMP * dt) * Math.cos(STIFFNESS * dt);
}
export default function SpringMesh({
duration = "2.2s",
easing = "cubic-bezier(.2, 1.4, .4, 1)", // vanilla $easing: $ease-pop
color = "#c93000", // vanilla $color: $subject-orange-deep
}: SpringMeshProps) {
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, cx: 0, cy: 0, maxR: 0 }) as Box<{ w: number; h: number; dpr: number; cx: number; cy: number; maxR: number }>;
const ptsRef = useRef<Pt[]>([]) as Box<Pt[]>;
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;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const size = sizeRef.current;
size.w = r.width; size.h = r.height; size.dpr = dpr;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
size.cx = r.width * 0.5; size.cy = r.height * 0.5;
size.maxR = Math.max(size.cx, size.cy) * 1.05 + Math.min(r.width, r.height) * 0.1;
const mx = r.width * 0.1, my = r.height * 0.14;
const list: Pt[] = [];
for (let gy = 0; gy < GY; gy++) {
for (let gx = 0; gx < GX; gx++) {
const px = mx + (r.width - mx * 2) * (gx / (GX - 1));
const py = my + (r.height - my * 2) * (gy / (GY - 1));
const dx = px - size.cx, dy = py - size.cy;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
list.push({ px, py, ux: dx / dist, uy: dy / dist, dist });
}
}
ptsRef.current = list;
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const size = sizeRef.current;
const W = size.w, H = size.h;
const t = sec % LOOP;
const p = t / LOOP;
const frontR = p * size.maxR;
const speed = size.maxR / LOOP;
const pts = ptsRef.current;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER = cssv("--paper"), LINE = cssv("--line"), NODE = cssv("--node"), NODE_HOT = cssv("--node-hot"), RING = cssv("--ring");
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = RING;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(size.cx, size.cy, Math.max(2, frontR), 0, Math.PI * 2);
ctx.stroke();
const disp = new Array(pts.length);
for (let i = 0; i < pts.length; i++) {
const q = pts[i];
const dt = t - q.dist / speed;
disp[i] = ripple(dt);
}
ctx.strokeStyle = LINE;
ctx.lineWidth = 1.2;
for (let gy = 0; gy < GY; gy++) {
for (let gx = 0; gx < GX; gx++) {
const idx = gy * GX + gx;
const q2 = pts[idx];
const x1 = q2.px + q2.ux * disp[idx];
const y1 = q2.py + q2.uy * disp[idx];
if (gx < GX - 1) {
const idxR = idx + 1;
const qR = pts[idxR];
const x2 = qR.px + qR.ux * disp[idxR];
const y2 = qR.py + qR.uy * disp[idxR];
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
if (gy < GY - 1) {
const idxD = idx + GX;
const qD = pts[idxD];
const x3 = qD.px + qD.ux * disp[idxD];
const y3 = qD.py + qD.uy * disp[idxD];
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x3, y3); ctx.stroke();
}
}
}
for (let k = 0; k < pts.length; k++) {
const qq = pts[k];
const x = qq.px + qq.ux * disp[k];
const y = qq.py + qq.uy * disp[k];
const heat = Math.min(1, Math.abs(disp[k]) / AMP);
ctx.fillStyle = heat > 0.18 ? NODE_HOT : NODE;
ctx.beginPath();
ctx.arc(x, y, 2.6 + heat * 2.4, 0, Math.PI * 2);
ctx.fill();
}
if (pctRef.current) pctRef.current.textContent = Math.round(p * 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>
);
}07 파동 반경 값이 번지는 스프링 격자 로더 — 측정 누적면적 22.819% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 로딩 애니메이션 효과 9가지 — 코드 그대로
이 데모가 속한 글
캔버스 로딩 애니메이션 효과 9가지 — 코드 그대로로딩 애니메이션 효과 를 캔버스로 만들 때 값 하나만 정해두면 나머지는 계산이 다 합니다. 감쇠 상수 0.92 가 중력 로더의 튕김 높이를 줄이고, 셀룰러 오토마타는 생존 규칙 값 하나로 세대마다 채워진 셀 비율을 이 글의 데모 전부 보기


