06布の揺れ
上端を固定した点のグリッドが、カーソルが通った場所に合わせて揺れ、ばねで元の場所へ戻ります。点ごとに translate オフセット一つだけを使い、隣接の引き合いが波を作ります。
使いどころヒーローコピーの背景、ギャラリーホバーのさざ波
requestAnimationFramepointermovetranslate

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-hand" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216 116v36a80 80 0 0 1-80 80c-44.18 0-55.81-24-93.32-90a20 20 0 0 1 34.64-20L96 152V44a20 20 0 0 1 40 0v56a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M196 88a27.86 27.86 0 0 0-13.35 3.39A28 28 0 0 0 144 74.7V44a28 28 0 0 0-56 0v80l-3.82-6.13A28 28 0 0 0 35.73 146l4.67 8.23C74.81 214.89 89.05 240 136 240a88.1 88.1 0 0 0 88-88v-36a28 28 0 0 0-28-28m12 64a72.08 72.08 0 0 1-72 72c-37.63 0-47.84-18-81.68-77.68l-4.69-8.27V138A12 12 0 0 1 54 121.61a11.9 11.9 0 0 1 6-1.6a12 12 0 0 1 10.41 6a2 2 0 0 0 .14.23l18.67 30A8 8 0 0 0 104 152V44a12 12 0 0 1 24 0v68a8 8 0 0 0 16 0v-12a12 12 0 0 1 24 0v20a8 8 0 0 0 16 0v-4a12 12 0 0 1 24 0Z"/></g></symbol>
<symbol id="ico-reset" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="cl is-demo" id="cl">
<p class="cl__hint"><svg class="cl__ico" aria-hidden="true"><use href="#ico-hand"/></svg><span>板をなぞると点のグリッドが揺れ、隣の点の引き合いで波が広がります</span></p>
<div class="cl__field" id="field" role="application" aria-label="布のグリッド盤" tabindex="0">
<span class="cl__pin" aria-hidden="true"></span>
<span class="cl__cursor" id="cursor" aria-hidden="true"><svg class="cl__cursorIco"><use href="#ico-hand"/></svg></span>
<span class="cl__dots" id="dots" aria-hidden="true"></span>
</div>
<div class="cl__bar">
<button class="cl__reset" id="reset" type="button">
<svg class="cl__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>そのままにする</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 천 출렁이기 — 맨 윗줄이 고정점(핀)인 점 격자. 커서 반경 안의 점을 밀고,
// 이웃 변위 끌림(가로 실 두 개 + 윗점) 이 파동을 번지게 하며 제자리 스프링이 잡아당긴다.
// 점마다 translate 오프셋 하나만 쓴다. 격자 미리보기(.is-demo)는 행별 위상이 어긋난 사인 물결을
// 키프레임으로 재생한다 — 아래 행일수록 진폭이 커지는 게 고정점에서 멀수록 출렁이는 천의 모양새다.
$duration: $dur-loop;
$easing: ease-in-out;
$color: $subject-blue;
@include stage($stage-paper);
.cl { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.cl__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.cl__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.cl__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($subject-blue, .05);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .12);
overflow: hidden; touch-action: none;
}
.cl__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.cl__pin {
position: absolute; left: 0; right: 0; top: 10px; height: 4px; margin: 0 $sp-2;
border-radius: $r-pill; background: rgba($subject-ink, .3);
}
.cl__dot {
position: absolute; left: 0; top: 0; width: 8px; height: 8px; margin: -4px 0 0 -4px;
border-radius: 50%; background: $color;
transform: translate(var(--hx), var(--hy));
}
// 고정점(첫 행)은 진폭 0 — 핀에 꽂힌 천의 윗변
.cl__dot--pin { background: $subject-ink; }
.cl__cursor {
position: absolute; left: 0; top: 0; width: 28px; height: 28px;
display: grid; place-items: center; border-radius: 50%;
background: rgba($subject-ink, .85); color: $stage-paper;
transform: translate(-60px, -60px);
pointer-events: none;
}
.cl__cursorIco { width: 14px; height: 14px; }
.cl__bar { display: flex; align-items: center; justify-content: flex-end; gap: $sp-2; flex-wrap: wrap; }
.cl__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $color; color: $stage-paper; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.cl__reset .cl__ico { color: $stage-paper; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 행 r 의 진폭(--amp) 과 위상(-r * 90ms) 으로 아래로 갈수록 크고 늦게 흔들리는 물결.
// 12fps 표본에서 2초에 두 번 왕복이라 광과민성 한계(초당 3회) 아래다.
.cl.is-demo .cl__dot:not(.cl__dot--pin) {
animation-name: clWave; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: calc(var(--r) * -90ms);
--amp: calc(var(--r) * 5px);
}
@keyframes clWave {
0% { transform: translate(var(--hx), var(--hy)); }
25% { transform: translate(var(--hx), calc(var(--hy) - var(--amp))); }
50% { transform: translate(var(--hx), var(--hy)); }
75% { transform: translate(var(--hx), calc(var(--hy) + var(--amp))); }
100% { transform: translate(var(--hx), var(--hy)); }
}
// 손 커서는 물결을 따라 천 위를 한 바퀴 쓸고 지나간다 — 좌우 왕복이라 경계가 닫혀 있다
.cl.is-demo .cl__cursor {
animation-name: clSweep; animation-duration: $duration; animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes clSweep {
0% { transform: translate(20px, 120px); }
42% { transform: translate(380px, 60px); }
50% { transform: translate(380px, 60px); }
92% { transform: translate(20px, 120px); }
100% { transform: translate(20px, 120px); }
}
@media (max-width: 340px) {
.cl { gap: $sp-2; }
.cl__field { height: 104px; }
.cl__dot { width: 6px; height: 6px; margin: -3px 0 0 -3px; }
.cl__cursor { width: 22px; height: 22px; }
.cl__cursorIco { width: 12px; height: 12px; }
}
// reduce 에서는 가만히 멈춘 격자 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.cl.is-demo .cl__dot,
.cl.is-demo .cl__cursor { animation: none !important; }
.cl.is-demo .cl__dot { transform: translate(var(--hx), var(--hy)) !important; }
.cl.is-demo .cl__cursor { transform: translate(20px, 40px) !important; opacity: .5 !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);
}
.cl {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.cl__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.cl__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.cl__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(47, 109, 246, 0.05);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.12);
overflow: hidden;
touch-action: none;
}
.cl__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.cl__pin {
position: absolute;
left: 0;
right: 0;
top: 10px;
height: 4px;
margin: 0 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.3);
}
.cl__dot {
position: absolute;
left: 0;
top: 0;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
border-radius: 50%;
background: #2f6df6;
transform: translate(var(--hx), var(--hy));
}
.cl__dot--pin {
background: #17141a;
}
.cl__cursor {
position: absolute;
left: 0;
top: 0;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(23, 20, 26, 0.85);
color: #ffffff;
transform: translate(-60px, -60px);
pointer-events: none;
}
.cl__cursorIco {
width: 14px;
height: 14px;
}
.cl__bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.cl__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #2f6df6;
color: #ffffff;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.cl__reset .cl__ico {
color: #ffffff;
}
.cl.is-demo .cl__dot:not(.cl__dot--pin) {
animation-name: clWave;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-delay: calc(var(--r) * -90ms);
--amp: calc(var(--r) * 5px);
}
@keyframes clWave {
0% {
transform: translate(var(--hx), var(--hy));
}
25% {
transform: translate(var(--hx), calc(var(--hy) - var(--amp)));
}
50% {
transform: translate(var(--hx), var(--hy));
}
75% {
transform: translate(var(--hx), calc(var(--hy) + var(--amp)));
}
100% {
transform: translate(var(--hx), var(--hy));
}
}
.cl.is-demo .cl__cursor {
animation-name: clSweep;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes clSweep {
0% {
transform: translate(20px, 120px);
}
42% {
transform: translate(380px, 60px);
}
50% {
transform: translate(380px, 60px);
}
92% {
transform: translate(20px, 120px);
}
100% {
transform: translate(20px, 120px);
}
}
@media (max-width: 340px) {
.cl {
gap: 8px;
}
.cl__field {
height: 104px;
}
.cl__dot {
width: 6px;
height: 6px;
margin: -3px 0 0 -3px;
}
.cl__cursor {
width: 22px;
height: 22px;
}
.cl__cursorIco {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl.is-demo .cl__dot,
.cl.is-demo .cl__cursor {
animation: none !important;
}
.cl.is-demo .cl__dot {
transform: translate(var(--hx), var(--hy)) !important;
}
.cl.is-demo .cl__cursor {
transform: translate(20px, 40px) !important;
opacity: 0.5 !important;
}
}var root = document.getElementById('cl');
var field = document.getElementById('field');
var dotsBox = document.getElementById('dots');
var cursor = document.getElementById('cursor');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 점 격자 12×5 — 맨 윗줄은 고정점(핀). 나머지는 제자리 스프링 + 이웃 끌림으로 파동이 번진다
var COLS = 12, ROWS = 5, dots = [];
function homeXY(c, r) {
return {
x: (c + 0.5) * field.clientWidth / COLS,
y: 14 + r * (field.clientHeight - 22) / (ROWS - 1)
};
}
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var el = document.createElement('i');
var home = homeXY(c, r);
el.className = 'cl__dot' + (r === 0 ? ' cl__dot--pin' : '');
el.style.setProperty('--hx', home.x.toFixed(1) + 'px');
el.style.setProperty('--hy', home.y.toFixed(1) + 'px');
el.style.setProperty('--r', String(r));
dotsBox.appendChild(el);
dots.push({ el: el, c: c, r: r, hx: home.x, hy: home.y, ox: 0, oy: 0, vx: 0, vy: 0 });
}
}
var ptr = { x: -60, y: -60 }, last = 0, running = false, idleAt = 0;
function paint() {
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
d.el.style.transform = 'translate(calc(var(--hx) + ' + d.ox.toFixed(1) + 'px), calc(var(--hy) + ' + d.oy.toFixed(1) + 'px))';
}
cursor.style.transform = 'translate(' + ptr.x + 'px, ' + ptr.y + 'px)';
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) { paint(); return false; }
idleAt = performance.now();
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
return true;
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var lively = now - idleAt < 1400, allCalm = true;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
if (d.r === 0) { continue; } // 고정점은 안 흔든다
var x = d.hx + d.ox, y = d.hy + d.oy;
// 커서 반경 36px 안의 점을 밀어낸다 — 커서가 지나가는 길이 파동의 시작점이다
var dx = x - ptr.x, dy = y - ptr.y, dist2 = dx * dx + dy * dy;
if (dist2 < 1296 && dist2 > 0.01) {
var dist = Math.sqrt(dist2), push = (1 - dist / 36) * 900;
d.vx += (dx / dist) * push * dt;
d.vy += (dy / dist) * push * dt;
allCalm = false;
}
// 이웃 끌림 — 左·右·위 점의 현재 변위를 조금 따라간다 (천의 가로 실)
var L = dots[d.r * COLS + Math.max(0, d.c - 1)];
var R = dots[d.r * COLS + Math.min(COLS - 1, d.c + 1)];
var U = dots[Math.max(0, d.r - 1) * COLS + d.c];
d.vx += ((L.ox + R.ox) / 2 - d.ox) * 60 * dt;
d.vy += ((L.oy + R.oy + U.oy) / 3 - d.oy) * 60 * dt;
// 제자리 스프링 + 減衰
d.vx += -d.ox * 90 * dt; d.vy += -d.oy * 90 * dt;
d.vx *= 0.9; d.vy *= 0.9;
d.ox += d.vx * dt; d.oy += d.vy * dt;
if (Math.abs(d.ox) + Math.abs(d.oy) > 0.25 || Math.abs(d.vx) + Math.abs(d.vy) > 2) { allCalm = false; }
}
paint();
if (!lively && allCalm) { running = false; return; }
requestAnimationFrame(step);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
ptr.x = e.clientX - r.left - 14; ptr.y = e.clientY - r.top - 14;
wake();
});
field.addEventListener('pointerleave', function () { ptr.x = -60; ptr.y = -60; paint(); });
field.addEventListener('keydown', function (e) {
var JUMP = 24;
if (e.key === 'ArrowLeft') { ptr.x -= JUMP; }
else if (e.key === 'ArrowRight') { ptr.x += JUMP; }
else if (e.key === 'ArrowUp') { ptr.y -= JUMP; }
else if (e.key === 'ArrowDown') { ptr.y += JUMP; }
else { return; }
e.preventDefault();
wake();
});
document.getElementById('reset').addEventListener('click', function () {
for (var i = 0; i < dots.length; i++) {
dots[i].ox = 0; dots[i].oy = 0; dots[i].vx = 0; dots[i].vy = 0;
}
paint();
});
window.addEventListener('resize', function () {
for (var i = 0; i < dots.length; i++) {
var home = homeXY(dots[i].c, dots[i].r);
dots[i].hx = home.x; dots[i].hy = home.y;
dots[i].el.style.setProperty('--hx', home.x.toFixed(1) + 'px');
dots[i].el.style.setProperty('--hy', home.y.toFixed(1) + 'px');
}
paint();
});
paint();// ClothRipple — vanilla 판(run/292/demo/06-physics-cloth-rope/index.html) 의 React 포트.
// 맨 윗줄이 고정점(핀)인 12x5 점 격자. 커서 반경 36px 안의 점을 밀고, 이웃 변위 끌림
// (가로 실 두 개 + 윗점) 이 파동을 번지게 하며 제자리 스프링(90)과 감쇠(0.9)가 잡아당긴다.
// 물리 상수·식은 vanilla 와 같은 값이다. 격자 미리보기(isDemo)는 module.scss 의 키프레임이 돌린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ClothRipple.module.scss";
export type ClothRippleProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DotVars = CSSProperties & { "--hx"?: string; "--hy"?: string; "--r"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Dot = { c: number; r: number; hx: number; hy: number; ox: number; oy: number; vx: number; vy: number };
type Home = { hx: number; hy: number; r: number };
const COLS = 12;
const ROWS = 5;
const JUMP = 24; // 키보드 한 번에 커서가 옮겨 가는 거리
const HINT = "판을 문지르면 점 격자가 출렁이고 이웃 점 끌림으로 파동이 퍼집니다";
const FIELD_LABEL = "천 격자판";
const RESET_LABEL = "가만히 두기";
function HandIcon({ className }: { className: string }) {
return (
<svg viewBox="0 0 256 256" className={className} aria-hidden="true" fill="currentColor"><g><path d="M216 116v36a80 80 0 0 1-80 80c-44.18 0-55.81-24-93.32-90a20 20 0 0 1 34.64-20L96 152V44a20 20 0 0 1 40 0v56a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M196 88a27.86 27.86 0 0 0-13.35 3.39A28 28 0 0 0 144 74.7V44a28 28 0 0 0-56 0v80l-3.82-6.13A28 28 0 0 0 35.73 146l4.67 8.23C74.81 214.89 89.05 240 136 240a88.1 88.1 0 0 0 88-88v-36a28 28 0 0 0-28-28m12 64a72.08 72.08 0 0 1-72 72c-37.63 0-47.84-18-81.68-77.68l-4.69-8.27V138A12 12 0 0 1 54 121.61a11.9 11.9 0 0 1 6-1.6a12 12 0 0 1 10.41 6a2 2 0 0 0 .14.23l18.67 30A8 8 0 0 0 104 152V44a12 12 0 0 1 24 0v68a8 8 0 0 0 16 0v-12a12 12 0 0 1 24 0v20a8 8 0 0 0 16 0v-4a12 12 0 0 1 24 0Z"/></g></svg>
);
}
function ResetIcon({ className }: { className: string }) {
return (
<svg viewBox="0 0 256 256" className={className} aria-hidden="true" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></svg>
);
}
export default function ClothRipple({
duration = 2000,
easing = "ease-in-out",
color = "#2f6df6",
}: ClothRippleProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cursorRef = useRef<HTMLSpanElement | null>(null);
const elsBox = useRef<Array<HTMLElement | null>>([]) as Box<Array<HTMLElement | null>>;
const dotsBox = useRef<Dot[]>([]) as Box<Dot[]>;
const ptrBox = useRef({ x: -60, y: -60 }) as Box<{ x: number; y: number }>;
const lastBox = useRef(0) as Box<number>;
const idleBox = useRef(0) as Box<number>;
const runningBox = useRef(false) as Box<boolean>;
const frameBox = useRef(0) as Box<number>;
const [homes, setHomes] = useState<Home[]>([]);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function homeXY(c: number, r: number, w: number, h: number): { x: number; y: number } {
return { x: ((c + 0.5) * w) / COLS, y: 14 + (r * (h - 22)) / (ROWS - 1) };
}
// 점 격자를 판 크기에 맞춰 놓는다 — 첫 마운트와 창 크기 변화에서 같은 함수를 쓴다
function layout(): void {
const field = fieldRef.current;
if (!field) { return; }
const w = field.clientWidth;
const h = field.clientHeight;
const dots = dotsBox.current;
const fresh = dots.length === 0;
const next: Home[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const home = homeXY(c, r, w, h);
if (fresh) {
dots.push({ c: c, r: r, hx: home.x, hy: home.y, ox: 0, oy: 0, vx: 0, vy: 0 });
} else {
const d = dots[r * COLS + c];
d.hx = home.x;
d.hy = home.y;
}
next.push({ hx: home.x, hy: home.y, r: r });
}
}
setHomes(next);
}
function paint(): void {
const dots = dotsBox.current;
const els = elsBox.current;
for (let i = 0; i < dots.length; i++) {
const el = els[i];
if (!el) { continue; }
const d = dots[i];
el.style.transform =
"translate(calc(var(--hx) + " + d.ox.toFixed(1) + "px), calc(var(--hy) + " + d.oy.toFixed(1) + "px))";
}
const cursor = cursorRef.current;
if (cursor) {
cursor.style.transform = "translate(" + ptrBox.current.x + "px, " + ptrBox.current.y + "px)";
}
}
function wake(): boolean {
setDemo(false);
if (reduced()) { paint(); return false; }
idleBox.current = performance.now();
if (!runningBox.current) {
runningBox.current = true;
lastBox.current = performance.now();
frameBox.current = requestAnimationFrame(step);
}
return true;
}
function step(now: number): void {
const dt = Math.min((now - lastBox.current) / 1000, 0.033);
lastBox.current = now;
const lively = now - idleBox.current < 1400;
let allCalm = true;
const dots = dotsBox.current;
const ptr = ptrBox.current;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
if (d.r === 0) { continue; } // 고정점은 안 흔든다
const x = d.hx + d.ox;
const y = d.hy + d.oy;
// 커서 반경 36px 안의 점을 밀어낸다 — 커서가 지나가는 길이 파동의 시작점이다
const dx = x - ptr.x;
const dy = y - ptr.y;
const dist2 = dx * dx + dy * dy;
if (dist2 < 1296 && dist2 > 0.01) {
const dist = Math.sqrt(dist2);
const push = (1 - dist / 36) * 900;
d.vx += (dx / dist) * push * dt;
d.vy += (dy / dist) * push * dt;
allCalm = false;
}
// 이웃 끌림 — 왼쪽·오른쪽·위 점의 현재 변위를 조금 따라간다 (천의 가로 실)
const left = dots[d.r * COLS + Math.max(0, d.c - 1)];
const right = dots[d.r * COLS + Math.min(COLS - 1, d.c + 1)];
const up = dots[Math.max(0, d.r - 1) * COLS + d.c];
d.vx += ((left.ox + right.ox) / 2 - d.ox) * 60 * dt;
d.vy += ((left.oy + right.oy + up.oy) / 3 - d.oy) * 60 * dt;
// 제자리 스프링 + 감쇠
d.vx += -d.ox * 90 * dt;
d.vy += -d.oy * 90 * dt;
d.vx *= 0.9;
d.vy *= 0.9;
d.ox += d.vx * dt;
d.oy += d.vy * dt;
if (Math.abs(d.ox) + Math.abs(d.oy) > 0.25 || Math.abs(d.vx) + Math.abs(d.vy) > 2) { allCalm = false; }
}
paint();
if (!lively && allCalm) { runningBox.current = false; return; }
frameBox.current = requestAnimationFrame(step);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) { return; }
const r = field.getBoundingClientRect();
ptrBox.current.x = event.clientX - r.left - 14;
ptrBox.current.y = event.clientY - r.top - 14;
wake();
}
function onPointerLeave(): void {
ptrBox.current.x = -60;
ptrBox.current.y = -60;
paint();
}
function onKeyDown(event: KeyEvt): void {
const ptr = ptrBox.current;
if (event.key === "ArrowLeft") { ptr.x -= JUMP; }
else if (event.key === "ArrowRight") { ptr.x += JUMP; }
else if (event.key === "ArrowUp") { ptr.y -= JUMP; }
else if (event.key === "ArrowDown") { ptr.y += JUMP; }
else { return; }
event.preventDefault();
wake();
}
function onReset(): void {
const dots = dotsBox.current;
for (let i = 0; i < dots.length; i++) {
dots[i].ox = 0;
dots[i].oy = 0;
dots[i].vx = 0;
dots[i].vy = 0;
}
paint();
}
useEffect(() => {
layout();
const onResize = (): void => { layout(); paint(); };
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(frameBox.current);
};
}, []);
// 점이 새로 그려진 뒤에 현재 변위를 한 번 칠한다
useEffect(() => { paint(); }, [homes]);
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<p className={styles.hint}><HandIcon className={styles.ico} /><span>{HINT}</span></p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<span className={styles.pin} aria-hidden="true"></span>
<span className={styles.cursor} ref={cursorRef} aria-hidden="true">
<HandIcon className={styles.cursorIco} />
</span>
<span aria-hidden="true">
{homes.map((home, i) => (
<i
key={i}
className={home.r === 0 ? `${styles.dot} ${styles.dotPin}` : styles.dot}
ref={(el: HTMLElement | null) => { elsBox.current[i] = el; }}
style={{
"--hx": home.hx.toFixed(1) + "px",
"--hy": home.hy.toFixed(1) + "px",
"--r": String(home.r),
} as DotVars}
/>
))}
</span>
</div>
<div className={styles.bar}>
<button className={styles.reset} type="button" onClick={onReset}>
<ResetIcon className={styles.ico} /><span>{RESET_LABEL}</span>
</button>
</div>
</div>
</main>
);
}06 布の揺れ — 計測 累積面積 12.947% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 物理シミュレーション 作り方 9選 — バネから慣性スクラブまで
このデモが属する記事
物理シミュレーション 作り方 9選 — バネから慣性スクラブまで「物理シミュレーション 作り方」の基本は、requestAnimationFrame が毎フレーム速度と位置を計算し、その結果を transform だけで画面に反映することです。ライブラリを使わない9つのデモは、ばね追従・重力・振り子から この記事のデモをすべて見る


