物理シミュレーション 作り方 9選 — バネから慣性スクラブまで
「物理シミュレーション 作り方」の基本は、requestAnimationFrame が毎フレーム速度と位置を計算し、その結果を transform だけで画面に反映することです。ライブラリを使わない9つのデモは、ばね追従・重力・振り子から慣性スクラブまで、コピペすればそのまま指の動きに反応します。
01 physics-spring-follow
02 physics-gravity-drop
03 physics-pendulum-swing
04 physics-magnet-particles
05 physics-paddle-bounce
06 physics-cloth-rope
07 physics-flow-field
08 physics-inertia-box
09 physics-inertia-scrub
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9つの並びは派手さではなく、物理シミュレーション で力がどこから来るかの順です。まず、力が1つだけの3つ — 目標へ引くばね(01)、下へ引く重力(02)、角度が高さに変わる振り子(03)です。次は、多数の粒が同じ場に反応する4つ — カーソルが磁石になる鉄粉(04)、反射でスコアを積むパドル(05)、隣どうしが引っ張り合う布(06)、進む向きに姿勢をそろえる流れ場(07)です。最後の2つは人の手そのものが力になる場面で、箱を振る慣性(08)と、離した速度で滑る慣性スクラブ(09)で締めます。舞台の色はインク4(01・05・08・09)・黄2(02・07)・橙1(03)・紙2(04・06)の配分で巡らせています。ポインタの位置を角度に変える場面はマウス追従で作る3D場面9選、音を絵に変える場面はオーディオビジュアライザー、線が伸びていく動きはSVGチャートの描画にあります。04の鉄粉のように多数の点を1つの規則で並べつつ、動きではなく模様として固定する側はcss 背景 パターン ジェネレーターがシード1つで解きます。
01スプリング追従
何が見えるか
クリックした場所が目標になると、チップはばね定数と減衰の値どおりにそこを通り過ぎ、戻って落ち着きます。位置は translate だけで動かしており、プレビューは累積面積 11.9%・移動フレーム 23/23 を記録しました。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-target" viewBox="0 0 256 256" fill="currentColor"><g><path d="M213.66 201L201 213.66a8 8 0 0 1-11.31 0l-51.31-51.31a8 8 0 0 0-13 2.46l-17.82 46.41a8 8 0 0 1-14.85-.71L40.41 50.44a8 8 0 0 1 10-10l160.1 52.24a8 8 0 0 1 .71 14.85l-46.41 17.82a8 8 0 0 0-2.46 13l51.31 51.31a8 8 0 0 1 0 11.34" opacity=".2"/><path d="M88 24v-8a8 8 0 0 1 16 0v8a8 8 0 0 1-16 0m-72 80h8a8 8 0 0 0 0-16h-8a8 8 0 0 0 0 16m108.42-64.84a8 8 0 0 0 10.74-3.58l8-16a8 8 0 0 0-14.31-7.16l-8 16a8 8 0 0 0 3.57 10.74m-96 81.69l-16 8a8 8 0 0 0 7.16 14.31l16-8a8 8 0 1 0-7.16-14.31M219.31 184a16 16 0 0 1 0 22.63l-12.68 12.68a16 16 0 0 1-22.63 0L132.7 168L115 214.09c0 .1-.08.21-.14.32a8 8 0 0 1-14.68-1.6L40.41 50.44a8 8 0 0 1 10-10l160.1 52.24c.1 0 .21.08.32.14a8 8 0 0 1-1.21 15.27l-46.41 17.82Z"/></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="sf is-demo" id="sf">
<p class="sf__hint"><svg class="sf__ico" aria-hidden="true"><use href="#ico-target"/></svg><span>板をクリックするか方向キーで目標を動かすと、チップがばねのように追いかけます</span></p>
<div class="sf__field" id="field" role="application" aria-label="ばねの実験台" tabindex="0">
<span class="sf__ring sf__ring--home" aria-hidden="true"></span>
<span class="sf__ring sf__ring--away" aria-hidden="true"></span>
<span class="sf__ghost" style="--g:0" aria-hidden="true"></span>
<span class="sf__ghost" style="--g:1" aria-hidden="true"></span>
<span class="sf__ghost" style="--g:2" aria-hidden="true"></span>
<span class="sf__chip" id="chip" aria-hidden="true"><svg class="sf__chipIco" aria-hidden="true"><use href="#ico-target"/></svg></span>
</div>
<div class="sf__bar">
<label class="sf__dial">ばね定数
<input type="range" id="stiff" min="1" max="6" step="1" value="3" aria-label="ばね定数">
<b class="sf__val" id="stiffVal">3</b>
</label>
<label class="sf__dial">減衰
<input type="range" id="damp" min="4" max="12" step="1" value="8" aria-label="減衰">
<b class="sf__val" id="dampVal">8</b>
</label>
<button class="sf__reset" id="reset" type="button">
<svg class="sf__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>最初へ</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스프링 따라오기 — 클릭한 목표를 강성(k)·감쇠(c) 값대로 지나쳤다 돌아와 붙는 준암시적 오일러 적분.
// 위치는 칩·잔상 전부 translate 하나로만 옮긴다 (레이아웃 재계산 금지, blog-demo 규칙 1).
// 격자 미리보기(.is-demo)는 실제 시뮬레이션 궤적을 표본 추출한 키프레임을 재생한다 —
// rAF 시뮬은 렌더러의 WAAPI 애니메이션 시계로 움직이지 않으므로(291 실측) 미리보기는 CSS 가 맡는다.
// 사람이 판을 클릭하면 JS 가 .is-demo 를 떼고 진짜 적분 경로로 넘어간다.
$duration: $dur-loop;
$easing: linear; // 감쇠 진동의 속도 변화는 키프레임 표본 자체에 들어 있으므로 구간은 균등 분할
$color: $stage-yellow;
@include stage($stage-ink);
.sf { --k: 1; display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.sf__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.sf__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.sf__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($subject-cream, .06);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: pointer; overflow: hidden; touch-action: none;
}
.sf__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 목표 링 — 목표가 홈인지 다른 곳인지에 따라 하나만 켜진다 (JS 가 .is-on 토글)
.sf__ring {
position: absolute; left: 0; top: 0; width: 20px; height: 20px; border-radius: 50%;
border: 2px dashed rgba($subject-cream, .65); opacity: 0;
}
.sf__ring.is-on { opacity: 1; }
// 칩과 잔상 — 같은 40px 칸, 잔상은 뒤에 깔리고 투명도만 다르다
.sf__ghost, .sf__chip { position: absolute; left: 0; top: 0; width: 40px; height: 40px; border-radius: $r-control; }
.sf__ghost { background: rgba($color, .30); }
.sf__ghost[style*="--g:1"] { background: rgba($color, .18); }
.sf__ghost[style*="--g:2"] { background: rgba($color, .10); }
.sf__chip {
display: grid; place-items: center;
background: $color; color: $subject-ink; box-shadow: $shadow-press;
}
.sf__chipIco { width: 20px; height: 20px; }
.sf__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.sf__dial {
display: inline-flex; align-items: center; gap: $sp-1;
font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-cream, .82);
}
.sf__dial input { width: 64px; accent-color: $color; }
.sf__val {
min-width: 12px; text-align: center; border-radius: $r-xs;
font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); color: $subject-cream;
}
.sf__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $color; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.sf__reset .sf__ico { color: $subject-ink; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 오른쪽 목표로 스프링 → 과충격 두 번 → 정착(0~48%) → 목표가 홈으로 바뀌고(링 컷) 되돌아가 정착(52~90%).
// 100% 좌표를 0% 와 같게 두어 루프 경계에서 순간이동이 없다.
.sf.is-demo .sf__chip {
animation-name: sfChase; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.sf.is-demo .sf__ghost {
animation-name: sfChase; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
// 음수 지연 = 잔상이 칩보다 그만큼 과거 위치에 있다. 음수라 루프 끝이 열려 있지 않다(양수 지연 순간이동 함정)
animation-delay: calc(var(--g) * -80ms - 80ms);
}
@keyframes sfChase {
0% { transform: translate(24px, 24px); }
10% { transform: translate(calc(var(--k) * 236px), calc(var(--k) * 58px)); }
20% { transform: translate(calc(var(--k) * 352px), calc(var(--k) * 66px)); }
30% { transform: translate(calc(var(--k) * 296px), calc(var(--k) * 96px)); }
38% { transform: translate(calc(var(--k) * 330px), calc(var(--k) * 74px)); }
46% { transform: translate(calc(var(--k) * 316px), calc(var(--k) * 84px)); }
50% { transform: translate(calc(var(--k) * 316px), calc(var(--k) * 84px)); }
58% { transform: translate(calc(var(--k) * 128px), calc(var(--k) * 4px)); }
64% { transform: translate(4px, 40px); }
72% { transform: translate(36px, 12px); }
80% { transform: translate(20px, 30px); }
88% { transform: translate(25px, 21px); }
90% { transform: translate(24px, 24px); }
100% { transform: translate(24px, 24px); }
}
// 목표 링 교체는 한 프레임 컷 — 반투명 겹침이 생기지 않는다
.sf.is-demo .sf__ring { opacity: 0; }
.sf.is-demo .sf__ring--away {
animation-name: sfRingAway; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
transform: translate(calc(var(--k) * 316px), calc(var(--k) * 84px));
}
.sf.is-demo .sf__ring--home {
animation-name: sfRingHome; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sfRingAway { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
@keyframes sfRingHome { 0%, 50% { opacity: 0; } 51%, 100% { opacity: 1; } }
@media (max-width: 340px) {
.sf { --k: .55; gap: $sp-2; }
.sf__field { height: 104px; }
.sf__ghost, .sf__chip { width: 32px; height: 32px; }
.sf__chipIco { width: 16px; height: 16px; }
.sf__dial input { width: 44px; }
}
// reduce 에서는 루프를 끄고 정지 상태(홈에 붙은 칩) 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.sf.is-demo .sf__chip,
.sf.is-demo .sf__ghost,
.sf.is-demo .sf__ring--away,
.sf.is-demo .sf__ring--home { animation: none !important; }
.sf.is-demo .sf__chip { transform: translate(24px, 24px) !important; }
.sf.is-demo .sf__ghost { opacity: 0 !important; }
.sf.is-demo .sf__ring--away { opacity: 0 !important; }
.sf.is-demo .sf__ring--home { opacity: 1 !important; transform: translate(24px, 24px) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sf {
--k: 1;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.sf__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.sf__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.sf__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: pointer;
overflow: hidden;
touch-action: none;
}
.sf__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.sf__ring {
position: absolute;
left: 0;
top: 0;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px dashed rgba(255, 247, 230, 0.65);
opacity: 0;
}
.sf__ring.is-on {
opacity: 1;
}
.sf__ghost, .sf__chip {
position: absolute;
left: 0;
top: 0;
width: 40px;
height: 40px;
border-radius: 12px;
}
.sf__ghost {
background: rgba(255, 210, 63, 0.3);
}
.sf__ghost[style*="--g:1"] {
background: rgba(255, 210, 63, 0.18);
}
.sf__ghost[style*="--g:2"] {
background: rgba(255, 210, 63, 0.1);
}
.sf__chip {
display: grid;
place-items: center;
background: #ffd23f;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.sf__chipIco {
width: 20px;
height: 20px;
}
.sf__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.sf__dial {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.sf__dial input {
width: 64px;
accent-color: #ffd23f;
}
.sf__val {
min-width: 12px;
text-align: center;
border-radius: 4px;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
color: #fff7e6;
}
.sf__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.sf__reset .sf__ico {
color: #17141a;
}
.sf.is-demo .sf__chip {
animation-name: sfChase;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sf.is-demo .sf__ghost {
animation-name: sfChase;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--g) * -80ms - 80ms);
}
@keyframes sfChase {
0% {
transform: translate(24px, 24px);
}
10% {
transform: translate(calc(var(--k) * 236px), calc(var(--k) * 58px));
}
20% {
transform: translate(calc(var(--k) * 352px), calc(var(--k) * 66px));
}
30% {
transform: translate(calc(var(--k) * 296px), calc(var(--k) * 96px));
}
38% {
transform: translate(calc(var(--k) * 330px), calc(var(--k) * 74px));
}
46% {
transform: translate(calc(var(--k) * 316px), calc(var(--k) * 84px));
}
50% {
transform: translate(calc(var(--k) * 316px), calc(var(--k) * 84px));
}
58% {
transform: translate(calc(var(--k) * 128px), calc(var(--k) * 4px));
}
64% {
transform: translate(4px, 40px);
}
72% {
transform: translate(36px, 12px);
}
80% {
transform: translate(20px, 30px);
}
88% {
transform: translate(25px, 21px);
}
90% {
transform: translate(24px, 24px);
}
100% {
transform: translate(24px, 24px);
}
}
.sf.is-demo .sf__ring {
opacity: 0;
}
.sf.is-demo .sf__ring--away {
animation-name: sfRingAway;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
transform: translate(calc(var(--k) * 316px), calc(var(--k) * 84px));
}
.sf.is-demo .sf__ring--home {
animation-name: sfRingHome;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sfRingAway {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
}
@keyframes sfRingHome {
0%, 50% {
opacity: 0;
}
51%, 100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.sf {
--k: .55;
gap: 8px;
}
.sf__field {
height: 104px;
}
.sf__ghost, .sf__chip {
width: 32px;
height: 32px;
}
.sf__chipIco {
width: 16px;
height: 16px;
}
.sf__dial input {
width: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.sf.is-demo .sf__chip,
.sf.is-demo .sf__ghost,
.sf.is-demo .sf__ring--away,
.sf.is-demo .sf__ring--home {
animation: none !important;
}
.sf.is-demo .sf__chip {
transform: translate(24px, 24px) !important;
}
.sf.is-demo .sf__ghost {
opacity: 0 !important;
}
.sf.is-demo .sf__ring--away {
opacity: 0 !important;
}
.sf.is-demo .sf__ring--home {
opacity: 1 !important;
transform: translate(24px, 24px) !important;
}
}var root = document.getElementById('sf');
var field = document.getElementById('field');
var chip = document.getElementById('chip');
var ghosts = Array.prototype.slice.call(root.querySelectorAll('.sf__ghost'));
var rings = { home: root.querySelector('.sf__ring--home'), away: root.querySelector('.sf__ring--away') };
var stiffIn = document.getElementById('stiff');
var dampIn = document.getElementById('damp');
var stiffVal = document.getElementById('stiffVal');
var dampVal = document.getElementById('dampVal');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var SIZE = 40, HOME = { x: 24, y: 24 };
var pos = { x: HOME.x, y: HOME.y };
var vel = { x: 0, y: 0 };
var target = { x: HOME.x, y: HOME.y };
var last = 0, frame = 0, running = false;
var trail = [ { x: HOME.x, y: HOME.y }, { x: HOME.x, y: HOME.y }, { x: HOME.x, y: HOME.y } ];
function clampTarget(t) {
var w = field.clientWidth - SIZE, h = field.clientHeight - SIZE;
t.x = Math.max(0, Math.min(w, t.x));
t.y = Math.max(0, Math.min(h, t.y));
}
function paint() {
chip.style.transform = 'translate(' + pos.x + 'px, ' + pos.y + 'px)';
for (var i = 0; i < ghosts.length; i++) {
ghosts[i].style.transform = 'translate(' + trail[i].x + 'px, ' + trail[i].y + 'px)';
}
rings.home.style.transform = 'translate(' + HOME.x + 'px, ' + HOME.y + 'px)';
rings.away.style.transform = 'translate(' + target.x + 'px, ' + target.y + 'px)';
rings.home.classList.toggle('is-on', target.x === HOME.x && target.y === HOME.y);
rings.away.classList.toggle('is-on', target.x !== HOME.x || target.y !== HOME.y);
}
function step(now) {
// 준암시적 오일러: 가속을 먼저 반영하고 위치를 옮긴다. dt 는 프레임 간격(초)이다.
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var k = Number(stiffIn.value) * 60;
var c = Number(dampIn.value) * 1.5;
vel.x += (k * (target.x - pos.x) - c * vel.x) * dt;
vel.y += (k * (target.y - pos.y) - c * vel.y) * dt;
pos.x += vel.x * dt;
pos.y += vel.y * dt;
frame++;
if (frame % 2 === 0) {
trail[2] = trail[1]; trail[1] = trail[0]; trail[0] = { x: pos.x, y: pos.y };
}
paint();
var settled = Math.abs(target.x - pos.x) < 0.5 && Math.abs(target.y - pos.y) < 0.5
&& Math.abs(vel.x) < 4 && Math.abs(vel.y) < 4;
if (settled) {
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
trail = [ { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y } ];
paint(); running = false; return;
}
requestAnimationFrame(step);
}
function go(t) {
clampTarget(t);
target.x = t.x; target.y = t.y;
root.classList.remove('is-demo');
if (reduce.matches) {
// 사용자가 움직임을 줄였을 때는 진동 없이 목표에 바로 붙인다
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
trail = [ { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y } ];
paint(); return;
}
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
}
field.addEventListener('pointerdown', function (e) {
var r = field.getBoundingClientRect();
go({ x: e.clientX - r.left - SIZE / 2, y: e.clientY - r.top - SIZE / 2 });
});
field.addEventListener('keydown', function (e) {
var JUMP = 24;
var next = { x: target.x, y: target.y };
if (e.key === 'ArrowLeft') { next.x -= JUMP; }
else if (e.key === 'ArrowRight') { next.x += JUMP; }
else if (e.key === 'ArrowUp') { next.y -= JUMP; }
else if (e.key === 'ArrowDown') { next.y += JUMP; }
else { return; }
e.preventDefault();
go(next);
});
document.getElementById('reset').addEventListener('click', function () { go({ x: HOME.x, y: HOME.y }); });
stiffIn.addEventListener('input', function () { stiffVal.textContent = stiffIn.value; });
dampIn.addEventListener('input', function () { dampVal.textContent = dampIn.value; });
window.addEventListener('resize', function () { clampTarget(target); paint(); });
paint();// SpringFollow — vanilla 판(run/292/demo/01-physics-spring-follow/index.html) 의 React 포트.
// 준암시적 오일러: 가속(강성 k = 강성값×60 으로 목표를 당기고 마찰 c = 감쇠값×1.5 로 깎는다)을 먼저 반영하고 위치를 옮긴다.
// 값·수치는 vanilla 와 같다. 사람이 판을 클릭하거나 방향키를 누르면 미리보기 루프를 떼고 진짜 적분 경로로 넘어간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./SpringFollow.module.scss";
export type SpringFollowProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type GhostVars = CSSProperties & { "--g"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type InputEvt = { target: { value: string } };
type Point = { x: number; y: number };
const SIZE = 40;
const HOME: Point = { x: 24, y: 24 };
const JUMP = 24; // 방향키 한 번에 옮기는 목표 거리
const FIELD_LABEL = "스프링 실험판";
export default function SpringFollow({
duration = 2000,
easing = "linear",
color = "#ffd23f",
}: SpringFollowProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const chipRef = useRef<HTMLSpanElement | null>(null);
const ghostARef = useRef<HTMLSpanElement | null>(null);
const ghostBRef = useRef<HTMLSpanElement | null>(null);
const ghostCRef = useRef<HTMLSpanElement | null>(null);
const ringHomeRef = useRef<HTMLSpanElement | null>(null);
const ringAwayRef = useRef<HTMLSpanElement | null>(null);
const posRef = useRef<Point>({ x: HOME.x, y: HOME.y }) as Box<Point>;
const velRef = useRef<Point>({ x: 0, y: 0 }) as Box<Point>;
const targetRef = useRef<Point>({ x: HOME.x, y: HOME.y }) as Box<Point>;
const trailRef = useRef<Point[]>([
{ x: HOME.x, y: HOME.y },
{ x: HOME.x, y: HOME.y },
{ x: HOME.x, y: HOME.y },
]) as Box<Point[]>;
const lastRef = useRef<number>(0) as Box<number>;
const tickRef = useRef<number>(0) as Box<number>; // 잔상을 두 프레임마다 밀기 위한 프레임 수
const rafRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const stiffRef = useRef<number>(3) as Box<number>;
const dampRef = useRef<number>(8) as Box<number>;
const [stiff, setStiff] = useState(3);
const [damp, setDamp] = useState(8);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function ghostEls(): (HTMLSpanElement | null)[] {
return [ghostARef.current, ghostBRef.current, ghostCRef.current];
}
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function clampTarget(t: Point): void {
const field = fieldRef.current;
if (!field) return;
const w = field.clientWidth - SIZE;
const h = field.clientHeight - SIZE;
t.x = Math.max(0, Math.min(w, t.x));
t.y = Math.max(0, Math.min(h, t.y));
}
function paint(): void {
const pos = posRef.current;
const target = targetRef.current;
const chip = chipRef.current;
if (chip) chip.style.transform = "translate(" + pos.x + "px, " + pos.y + "px)";
const ghosts = ghostEls();
for (let i = 0; i < ghosts.length; i++) {
const g = ghosts[i];
if (g) g.style.transform = "translate(" + trailRef.current[i].x + "px, " + trailRef.current[i].y + "px)";
}
const home = ringHomeRef.current;
const away = ringAwayRef.current;
if (home) {
home.style.transform = "translate(" + HOME.x + "px, " + HOME.y + "px)";
home.classList.toggle(styles.isOn, target.x === HOME.x && target.y === HOME.y);
}
if (away) {
away.style.transform = "translate(" + target.x + "px, " + target.y + "px)";
away.classList.toggle(styles.isOn, target.x !== HOME.x || target.y !== HOME.y);
}
}
function step(now: number): void {
// 준암시적 오일러: 가속을 먼저 반영하고 위치를 옮긴다. dt 는 프레임 간격(초)이다.
const dt = Math.min((now - lastRef.current) / 1000, 0.033);
lastRef.current = now;
const pos = posRef.current;
const vel = velRef.current;
const target = targetRef.current;
const k = stiffRef.current * 60;
const c = dampRef.current * 1.5;
vel.x += (k * (target.x - pos.x) - c * vel.x) * dt;
vel.y += (k * (target.y - pos.y) - c * vel.y) * dt;
pos.x += vel.x * dt;
pos.y += vel.y * dt;
tickRef.current++;
if (tickRef.current % 2 === 0) {
const trail = trailRef.current;
trail[2] = trail[1];
trail[1] = trail[0];
trail[0] = { x: pos.x, y: pos.y };
}
paint();
const settled =
Math.abs(target.x - pos.x) < 0.5 && Math.abs(target.y - pos.y) < 0.5 &&
Math.abs(vel.x) < 4 && Math.abs(vel.y) < 4;
if (settled) {
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
trailRef.current = [{ x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }];
paint();
runningRef.current = false;
return;
}
rafRef.current = requestAnimationFrame(step);
}
function go(t: Point): void {
clampTarget(t);
const target = targetRef.current;
target.x = t.x;
target.y = t.y;
setDemo(false);
if (reduced()) {
// 사용자가 움직임을 줄였을 때는 진동 없이 목표에 바로 붙인다
const pos = posRef.current;
const vel = velRef.current;
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
trailRef.current = [{ x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }];
paint();
return;
}
if (!runningRef.current) {
runningRef.current = true;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
}
useEffect(() => {
paint();
function onResize(): void {
clampTarget(targetRef.current);
paint();
}
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
go({ x: event.clientX - r.left - SIZE / 2, y: event.clientY - r.top - SIZE / 2 });
}
function onKeyDown(event: KeyEvt): void {
const target = targetRef.current;
const next: Point = { x: target.x, y: target.y };
if (event.key === "ArrowLeft") next.x -= JUMP;
else if (event.key === "ArrowRight") next.x += JUMP;
else if (event.key === "ArrowUp") next.y -= JUMP;
else if (event.key === "ArrowDown") next.y += JUMP;
else return;
event.preventDefault();
go(next);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const ghostVars = (g: number): GhostVars => ({ "--g": String(g) });
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-target" viewBox="0 0 256 256" fill="currentColor"><g><path d="M213.66 201L201 213.66a8 8 0 0 1-11.31 0l-51.31-51.31a8 8 0 0 0-13 2.46l-17.82 46.41a8 8 0 0 1-14.85-.71L40.41 50.44a8 8 0 0 1 10-10l160.1 52.24a8 8 0 0 1 .71 14.85l-46.41 17.82a8 8 0 0 0-2.46 13l51.31 51.31a8 8 0 0 1 0 11.34" opacity=".2"/><path d="M88 24v-8a8 8 0 0 1 16 0v8a8 8 0 0 1-16 0m-72 80h8a8 8 0 0 0 0-16h-8a8 8 0 0 0 0 16m108.42-64.84a8 8 0 0 0 10.74-3.58l8-16a8 8 0 0 0-14.31-7.16l-8 16a8 8 0 0 0 3.57 10.74m-96 81.69l-16 8a8 8 0 0 0 7.16 14.31l16-8a8 8 0 1 0-7.16-14.31M219.31 184a16 16 0 0 1 0 22.63l-12.68 12.68a16 16 0 0 1-22.63 0L132.7 168L115 214.09c0 .1-.08.21-.14.32a8 8 0 0 1-14.68-1.6L40.41 50.44a8 8 0 0 1 10-10l160.1 52.24c.1 0 .21.08.32.14a8 8 0 0 1-1.21 15.27l-46.41 17.82Z"/></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>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-target" /></svg>
<span>판을 클릭하거나 방향키로 목표를 옮기면 칩이 스프링처럼 따라옵니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
>
<span className={`${styles.ring} ${styles.ringHome}`} ref={ringHomeRef} aria-hidden="true"></span>
<span className={`${styles.ring} ${styles.ringAway}`} ref={ringAwayRef} aria-hidden="true"></span>
<span className={`${styles.ghost} ${styles.ghostA}`} ref={ghostARef} style={ghostVars(0)} aria-hidden="true"></span>
<span className={`${styles.ghost} ${styles.ghostB}`} ref={ghostBRef} style={ghostVars(1)} aria-hidden="true"></span>
<span className={`${styles.ghost} ${styles.ghostC}`} ref={ghostCRef} style={ghostVars(2)} aria-hidden="true"></span>
<span className={styles.chip} ref={chipRef} aria-hidden="true">
<svg className={styles.chipIco} aria-hidden="true"><use href="#ico-target" /></svg>
</span>
</div>
<div className={styles.bar}>
<label className={styles.dial}>강성
<input
type="range" min={1} max={6} step={1} value={stiff} aria-label="강성"
onChange={(event: InputEvt) => { const v = Number(event.target.value); stiffRef.current = v; setStiff(v); }}
/>
<b className={styles.val}>{stiff}</b>
</label>
<label className={styles.dial}>감쇠
<input
type="range" min={4} max={12} step={1} value={damp} aria-label="감쇠"
onChange={(event: InputEvt) => { const v = Number(event.target.value); dampRef.current = v; setDamp(v); }}
/>
<b className={styles.val}>{damp}</b>
</label>
<button className={styles.reset} type="button" onClick={() => go({ x: HOME.x, y: HOME.y })}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-reset" /></svg><span>처음으로</span>
</button>
</div>
</div>
</main>
);
}01 · スプリング追従 — 計測 累積面積 11.931% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ポインタを追うカーソルチップや、ドラッグした後に元の場所へ戻るツールに使えます。
function step(now) {
// 準陰的オイラー法: 先に加速度を反映してから位置を動かす。dt はフレーム間隔(秒)
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var k = Number(stiffIn.value) * 60;
var c = Number(dampIn.value) * 1.5;
vel.x += (k * (target.x - pos.x) - c * vel.x) * dt;
vel.y += (k * (target.y - pos.y) - c * vel.y) * dt;
pos.x += vel.x * dt;
pos.y += vel.y * dt;
frame++;
if (frame % 2 === 0) {
trail[2] = trail[1]; trail[1] = trail[0]; trail[0] = { x: pos.x, y: pos.y };
}
paint();
var settled = Math.abs(target.x - pos.x) < 0.5 && Math.abs(target.y - pos.y) < 0.5
&& Math.abs(vel.x) < 4 && Math.abs(vel.y) < 4;
if (settled) {
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
trail = [ { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y } ];
paint(); running = false; return;
}
requestAnimationFrame(step);
}
02重力ドロップ
物理シミュレーションとは どこから始まるか
4つのボールが落ちて床に当たり、反発係数スライダーの値の分だけ跳ね返ります。ボール同士は重なった分を押しのけるだけの簡易接触で、プレビューは累積面積 5.9%・移動フレーム 22/23 を記録しました。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-drop" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></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="gd is-demo" id="gd">
<p class="gd__hint"><svg class="gd__ico" aria-hidden="true"><use href="#ico-drop"/></svg><span>板をクリックすると4つのボールがもう一度落ち、反発スライダーの値だけ跳ね返ります</span></p>
<div class="gd__field" id="field" role="application" aria-label="重力の実験台" tabindex="0">
<span class="gd__ball gd__ball--a" aria-hidden="true"></span>
<span class="gd__ball gd__ball--b" aria-hidden="true"></span>
<span class="gd__ball gd__ball--c" aria-hidden="true"></span>
<span class="gd__ball gd__ball--d" aria-hidden="true"></span>
<span class="gd__floor" aria-hidden="true"></span>
</div>
<div class="gd__bar">
<label class="gd__dial">反発
<input type="range" id="rest" min="0.3" max="0.9" step="0.02" value="0.62" aria-label="反発係数">
<b class="gd__val" id="restVal">0.62</b>
</label>
<button class="gd__drop" id="drop" type="button">
<svg class="gd__ico" aria-hidden="true"><use href="#ico-drop"/></svg><span>もう一度落とす</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 중력 드롭 — 가속 1800px/s², 바닥 충돌은 반발계수(e) 만큼 되튕기는 준암시적 오일러 적분.
// 위치는 공마다 translate 하나뿐이고 공끼리는 겹침 밀어내기만 한다 (간이 충돌).
// 격자 미리보기(.is-demo)는 실제 시뮬레이션에서 표본 추출한 되튕김 곡선(높이가 e 배로 줄어드는 포물선 열)을
// 키프레임으로 재생한다. 루프의 시작과 끝을 바닥에 두어 경계 순간이동이 없다.
// 사람이 판을 클릭하면 JS 가 .is-demo 를 떼고 진짜 적분 경로로 넘어간다.
$duration: $dur-loop;
$easing: linear; // 포물선의 가감속은 키프레임 표본 자체에 들어 있다
$color: $stage-yellow;
@include stage($stage-yellow);
.gd { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.gd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.gd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-orange; }
.gd__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($subject-cream, .5);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer; overflow: hidden; touch-action: manipulation;
}
.gd__field:focus-visible { outline: 2px solid $stage-orange; outline-offset: 2px; }
.gd__floor { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: rgba($subject-ink, .35); }
.gd__ball { position: absolute; left: 0; top: 0; border-radius: 50%; background: $subject-ink; }
// --bx 처음 x, --by 바닥 안착 y, --h 되튕김 높이 (모두 px 길이). 미리보기 키프레임이 참조한다
.gd__ball--a { width: 28px; height: 28px; --bx: 34px; --by: 148px; --h: 140px; }
.gd__ball--b { width: 20px; height: 20px; background: $stage-orange; --bx: 132px; --by: 156px; --h: 104px; }
.gd__ball--c { width: 24px; height: 24px; background: $subject-blue; --bx: 230px; --by: 152px; --h: 122px; }
.gd__ball--d { width: 16px; height: 16px; --bx: 322px; --by: 160px; --h: 88px; }
.gd__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.gd__dial {
display: inline-flex; align-items: center; gap: $sp-1;
font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: $subject-ink;
}
.gd__dial input { width: 88px; accent-color: $stage-orange; }
.gd__val { min-width: 28px; text-align: center; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); }
.gd__drop {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $subject-ink; color: $color; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.gd__drop .gd__ico { color: $color; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 바닥(y 0)에서 크게 되튕어 올라갔다 e 배로 낮아지며 굴러떨어지는 모양.
// 공마다 음수 지연으로 위상만 민다 — 같은 키프레임을 공유하므로 루프는 닫혀 있다.
// 최고 높이는 표본 실측 기준 2초에 세 번 내려오는 수준이라 광과민성 기준(초당 3회) 아래다.
.gd.is-demo .gd__ball--a { animation: gdBounceA $duration $easing infinite; }
.gd.is-demo .gd__ball--b { animation: gdBounceA $duration $easing infinite; animation-delay: -660ms; }
.gd.is-demo .gd__ball--c { animation: gdBounceA $duration $easing infinite; animation-delay: -330ms; }
.gd.is-demo .gd__ball--d { animation: gdBounceA $duration $easing infinite; animation-delay: -880ms; }
@keyframes gdBounceA {
// 높이는 바닥 안착점(var(--by)) 에서 var(--h) 배수만큼 올라가는 상대값 — 공 크기와 떨어진 x 는 변수로 갈라 논다
0% { transform: translate(var(--bx), var(--by)); }
3% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .21)); }
6% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .57)); }
9% { transform: translate(var(--bx), calc(var(--by) - var(--h) * 1)); }
12% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .57)); }
15% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .21)); }
18% { transform: translate(var(--bx), var(--by)); }
24% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .4)); }
30% { transform: translate(var(--bx), var(--by)); }
34% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .23)); }
38% { transform: translate(var(--bx), var(--by)); }
41% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .11)); }
44% { transform: translate(var(--bx), var(--by)); }
46% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .04)); }
48% { transform: translate(var(--bx), var(--by)); }
100% { transform: translate(var(--bx), var(--by)); }
}
@media (max-width: 340px) {
.gd { gap: $sp-2; }
.gd__field { height: 104px; }
.gd__ball--a { width: 20px; height: 20px; --by: 84px; --h: 72px; }
.gd__ball--b { width: 16px; height: 16px; --by: 88px; --h: 56px; }
.gd__ball--c { width: 18px; height: 18px; --by: 86px; --h: 64px; }
.gd__ball--d { width: 12px; height: 12px; --by: 92px; --h: 48px; }
.gd__dial input { width: 56px; }
}
// reduce 에서는 바닥에 안착한 정지 상태 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.gd.is-demo .gd__ball--a,
.gd.is-demo .gd__ball--b,
.gd.is-demo .gd__ball--c,
.gd.is-demo .gd__ball--d { animation: none !important; }
.gd.is-demo .gd__ball { transform: translate(var(--bx), var(--by)) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gd {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.gd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.gd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff4d1f;
}
.gd__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.5);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
overflow: hidden;
touch-action: manipulation;
}
.gd__field:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.gd__floor {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: rgba(23, 20, 26, 0.35);
}
.gd__ball {
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
background: #17141a;
}
.gd__ball--a {
width: 28px;
height: 28px;
--bx: 34px;
--by: 148px;
--h: 140px;
}
.gd__ball--b {
width: 20px;
height: 20px;
background: #ff4d1f;
--bx: 132px;
--by: 156px;
--h: 104px;
}
.gd__ball--c {
width: 24px;
height: 24px;
background: #2f6df6;
--bx: 230px;
--by: 152px;
--h: 122px;
}
.gd__ball--d {
width: 16px;
height: 16px;
--bx: 322px;
--by: 160px;
--h: 88px;
}
.gd__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.gd__dial {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: #17141a;
}
.gd__dial input {
width: 88px;
accent-color: #ff4d1f;
}
.gd__val {
min-width: 28px;
text-align: center;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
}
.gd__drop {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
color: #ffd23f;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.gd__drop .gd__ico {
color: #ffd23f;
}
.gd.is-demo .gd__ball--a {
animation: gdBounceA 2s linear infinite;
}
.gd.is-demo .gd__ball--b {
animation: gdBounceA 2s linear infinite;
animation-delay: -660ms;
}
.gd.is-demo .gd__ball--c {
animation: gdBounceA 2s linear infinite;
animation-delay: -330ms;
}
.gd.is-demo .gd__ball--d {
animation: gdBounceA 2s linear infinite;
animation-delay: -880ms;
}
@keyframes gdBounceA {
0% {
transform: translate(var(--bx), var(--by));
}
3% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.21));
}
6% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.57));
}
9% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 1));
}
12% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.57));
}
15% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.21));
}
18% {
transform: translate(var(--bx), var(--by));
}
24% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.4));
}
30% {
transform: translate(var(--bx), var(--by));
}
34% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.23));
}
38% {
transform: translate(var(--bx), var(--by));
}
41% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.11));
}
44% {
transform: translate(var(--bx), var(--by));
}
46% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.04));
}
48% {
transform: translate(var(--bx), var(--by));
}
100% {
transform: translate(var(--bx), var(--by));
}
}
@media (max-width: 340px) {
.gd {
gap: 8px;
}
.gd__field {
height: 104px;
}
.gd__ball--a {
width: 20px;
height: 20px;
--by: 84px;
--h: 72px;
}
.gd__ball--b {
width: 16px;
height: 16px;
--by: 88px;
--h: 56px;
}
.gd__ball--c {
width: 18px;
height: 18px;
--by: 86px;
--h: 64px;
}
.gd__ball--d {
width: 12px;
height: 12px;
--by: 92px;
--h: 48px;
}
.gd__dial input {
width: 56px;
}
}
@media (prefers-reduced-motion: reduce) {
.gd.is-demo .gd__ball--a,
.gd.is-demo .gd__ball--b,
.gd.is-demo .gd__ball--c,
.gd.is-demo .gd__ball--d {
animation: none !important;
}
.gd.is-demo .gd__ball {
transform: translate(var(--bx), var(--by)) !important;
}
}var root = document.getElementById('gd');
var field = document.getElementById('field');
var els = {
a: root.querySelector('.gd__ball--a'),
b: root.querySelector('.gd__ball--b'),
c: root.querySelector('.gd__ball--c'),
d: root.querySelector('.gd__ball--d')
};
var restIn = document.getElementById('rest');
var restVal = document.getElementById('restVal');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 공 4개: 크기(px) 와 기준 x 위치(424px 판 기준). 실제 판 폭에 비례해 얹는다.
// 무게는 크기에 비례한다고 보고 반발은 전 공 공통 값 하나로 쓴다
var SPEC = [ { el: 'a', size: 28, x: 34 }, { el: 'b', size: 20, x: 132 }, { el: 'c', size: 24, x: 230 }, { el: 'd', size: 16, x: 322 } ];
var G = 1800, balls = [], last = 0, running = false;
function scaledX(x) { return x * field.clientWidth / 424; }
function reset(top) {
balls = SPEC.map(function (s) {
var y = top ? 4 : field.clientHeight - s.size;
return { el: els[s.el], size: s.size, x: scaledX(s.x), y: y, vx: 0, vy: 0, done: !top };
});
paint();
}
function paint() {
for (var i = 0; i < balls.length; i++) {
balls[i].el.style.transform = 'translate(' + balls[i].x + 'px, ' + balls[i].y + 'px)';
}
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var e = Number(restIn.value), floor = field.clientHeight, all = true;
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
if (b.done) { continue; }
b.vy += G * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
// 바닥 충돌: 반발계수만큼 세게 되튕기고 남은 속도가 느리면 바닥에 붙여 끝낸다
if (b.y + b.size >= floor) {
b.y = floor - b.size;
if (Math.abs(b.vy) > 40) { b.vy = -b.vy * e; }
else { b.vy = 0; b.done = true; }
}
// 공끼리: 겹친 만큼 서로 민다 (간이 충돌 — 속도 교환까지는 하지 않는다)
for (var j = i + 1; j < balls.length; j++) {
var o = balls[j];
var dx = o.x - b.x, dy = o.y - b.y, gap = (b.size + o.size) / 2;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0 && dist < gap) {
var push = (gap - dist) / 2, nx = dx / dist, ny = dy / dist;
b.x -= nx * push; b.y -= ny * push;
o.x += nx * push; o.y += ny * push;
}
}
if (!b.done) { all = false; }
}
paint();
if (all) { running = false; return; }
requestAnimationFrame(step);
}
function drop() {
root.classList.remove('is-demo');
if (reduce.matches) { reset(false); return; }
reset(true);
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
}
field.addEventListener('pointerdown', drop);
document.getElementById('drop').addEventListener('click', drop);
field.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); drop(); }
});
restIn.addEventListener('input', function () { restVal.textContent = Number(restIn.value).toFixed(2); });
window.addEventListener('resize', function () { reset(false); });
reset(false);// GravityDrop — vanilla 판(run/292/demo/02-physics-gravity-drop/index.html) 의 React 포트.
// 가속 G = 1800px/s² 를 속도에 더하고 위치를 옮기는 준암시적 오일러 적분.
// 바닥에 닿으면 반발계수(e) 만큼 되튕기고, 남은 속도가 40 아래로 떨어지면 바닥에 붙여 끝낸다.
// 공끼리는 겹친 만큼 서로 미는 간이 충돌만 한다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GravityDrop.module.scss";
export type GravityDropProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type InputEvt = { target: { value: string } };
type Ball = { el: HTMLSpanElement; size: number; x: number; y: number; vx: number; vy: number; done: boolean };
// 공 4개: 크기(px) 와 기준 x 위치(424px 판 기준). 실제 판 폭에 비례해 얹는다.
// 무게는 크기에 비례한다고 보고 반발은 전 공 공통 값 하나로 쓴다
const SPEC: { size: number; x: number }[] = [
{ size: 28, x: 34 },
{ size: 20, x: 132 },
{ size: 24, x: 230 },
{ size: 16, x: 322 },
];
const G = 1800;
const FIELD_LABEL = "중력 실험판";
export default function GravityDrop({
duration = 2000,
easing = "linear",
color = "#ffd23f",
}: GravityDropProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const ballARef = useRef<HTMLSpanElement | null>(null);
const ballBRef = useRef<HTMLSpanElement | null>(null);
const ballCRef = useRef<HTMLSpanElement | null>(null);
const ballDRef = useRef<HTMLSpanElement | null>(null);
const ballsRef = useRef<Ball[]>([]) as Box<Ball[]>;
const restRef = useRef<number>(0.62) as Box<number>;
const lastRef = useRef<number>(0) as Box<number>;
const rafRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const [rest, setRest] = useState(0.62);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function ballEls(): (HTMLSpanElement | null)[] {
return [ballARef.current, ballBRef.current, ballCRef.current, ballDRef.current];
}
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function scaledX(x: number): number {
const field = fieldRef.current;
return field ? (x * field.clientWidth) / 424 : x;
}
function reset(top: boolean): void {
const field = fieldRef.current;
if (!field) return;
const els = ballEls();
const next: Ball[] = [];
for (let i = 0; i < SPEC.length; i++) {
const el = els[i];
if (!el) continue;
const s = SPEC[i];
const y = top ? 4 : field.clientHeight - s.size;
next.push({ el: el, size: s.size, x: scaledX(s.x), y: y, vx: 0, vy: 0, done: !top });
}
ballsRef.current = next;
paint();
}
function paint(): void {
const balls = ballsRef.current;
for (let i = 0; i < balls.length; i++) {
balls[i].el.style.transform = "translate(" + balls[i].x + "px, " + balls[i].y + "px)";
}
}
function step(now: number): void {
const field = fieldRef.current;
if (!field) { runningRef.current = false; return; }
const dt = Math.min((now - lastRef.current) / 1000, 0.033);
lastRef.current = now;
const balls = ballsRef.current;
const e = restRef.current;
const floor = field.clientHeight;
let all = true;
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.done) continue;
b.vy += G * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
// 바닥 충돌: 반발계수만큼 세게 되튕기고 남은 속도가 느리면 바닥에 붙여 끝낸다
if (b.y + b.size >= floor) {
b.y = floor - b.size;
if (Math.abs(b.vy) > 40) { b.vy = -b.vy * e; }
else { b.vy = 0; b.done = true; }
}
// 공끼리: 겹친 만큼 서로 민다 (간이 충돌 — 속도 교환까지는 하지 않는다)
for (let j = i + 1; j < balls.length; j++) {
const o = balls[j];
const dx = o.x - b.x;
const dy = o.y - b.y;
const gap = (b.size + o.size) / 2;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0 && dist < gap) {
const push = (gap - dist) / 2;
const nx = dx / dist;
const ny = dy / dist;
b.x -= nx * push; b.y -= ny * push;
o.x += nx * push; o.y += ny * push;
}
}
if (!b.done) all = false;
}
paint();
if (all) { runningRef.current = false; return; }
rafRef.current = requestAnimationFrame(step);
}
function drop(): void {
setDemo(false);
if (reduced()) { reset(false); return; }
reset(true);
if (!runningRef.current) {
runningRef.current = true;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
}
useEffect(() => {
reset(false);
function onResize(): void { reset(false); }
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
function onKeyDown(event: KeyEvt): void {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); drop(); }
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-drop" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></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>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-drop" /></svg>
<span>판을 클릭하면 네 공이 다시 떨어지고 반발 슬라이더 값만큼 튀어 오릅니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={drop}
onKeyDown={onKeyDown}
>
<span className={`${styles.ball} ${styles.ballA}`} ref={ballARef} aria-hidden="true"></span>
<span className={`${styles.ball} ${styles.ballB}`} ref={ballBRef} aria-hidden="true"></span>
<span className={`${styles.ball} ${styles.ballC}`} ref={ballCRef} aria-hidden="true"></span>
<span className={`${styles.ball} ${styles.ballD}`} ref={ballDRef} aria-hidden="true"></span>
<span className={styles.floor} aria-hidden="true"></span>
</div>
<div className={styles.bar}>
<label className={styles.dial}>반발
<input
type="range" min={0.3} max={0.9} step={0.02} value={rest} aria-label="반발계수"
onChange={(event: InputEvt) => { const v = Number(event.target.value); restRef.current = v; setRest(v); }}
/>
<b className={styles.val}>{rest.toFixed(2)}</b>
</label>
<button className={styles.drop} type="button" onClick={drop}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-drop" /></svg><span>다시 떨어뜨리기</span>
</button>
</div>
</div>
</main>
);
}02 · 重力ドロップ — 計測 累積面積 5.933% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
落ちてくるファイルのアイコンや、ゲームのような報酬エフェクトに使えます。
// 床との衝突: 反発係数の分だけ強く跳ね返し、残り速度が遅ければ床に置いて終える
if (b.y + b.size >= floor) {
b.y = floor - b.size;
if (Math.abs(b.vy) > 40) { b.vy = -b.vy * e; }
else { b.vy = 0; b.done = true; }
}
// ボール同士: 重なった分だけ互いに押す (簡易接触 — 速度の交換はしない)
for (var j = i + 1; j < balls.length; j++) {
var o = balls[j];
var dx = o.x - b.x, dy = o.y - b.y, gap = (b.size + o.size) / 2;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0 && dist < gap) {
var push = (gap - dist) / 2, nx = dx / dist, ny = dy / dist;
b.x -= nx * push; b.y -= ny * push;
o.x += nx * push; o.y += ny * push;
}
}
03振り子スイング
どんな動きか
おもりを引いて離すと、角加速度が -(g/L)·sinθ と減衰で決まり、角度が高さに変わって振動がひとりでに続きます。回転軸は固定点なので、rotate の transform-origin を腕の上端に置きます。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-grab" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 108v44a80 80 0 0 1-160 0v-12a20 20 0 0 1 20-20h20V92a20 20 0 0 1 40 0a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M188 80a27.8 27.8 0 0 0-13.36 3.4a28 28 0 0 0-46.64-11A28 28 0 0 0 80 92v20H68a28 28 0 0 0-28 28v12a88 88 0 0 0 176 0v-44a28 28 0 0 0-28-28m12 72a72 72 0 0 1-144 0v-12a12 12 0 0 1 12-12h12v24a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0v-12a12 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="pd is-demo" id="pd">
<p class="pd__hint"><svg class="pd__ico" aria-hidden="true"><use href="#ico-grab"/></svg><span>錘を引いて離すと、角度が高さに変わって自分で揺れ続けます</span></p>
<div class="pd__field" id="field" role="application" aria-label="振り子の実験台" tabindex="0">
<span class="pd__pivot" aria-hidden="true"></span>
<div class="pd__arm" id="arm">
<span class="pd__rod" aria-hidden="true"></span>
<button class="pd__bob" id="bob" type="button" aria-label="振り子の錘">
<svg class="pd__bobIco" aria-hidden="true"><use href="#ico-grab"/></svg>
</button>
</div>
<p class="pd__angle" aria-live="polite"><span class="pd__angleVal" id="angleText">0度</span><span class="pd__angleLoop" aria-hidden="true"><i>26度</i><i>0度</i><i>26度</i><i>0度</i></span></p>
</div>
<div class="pd__bar">
<button class="pd__pull" id="pull" type="button">
<svg class="pd__ico" aria-hidden="true"><use href="#ico-grab"/></svg><span>30度で離す</span>
</button>
<button class="pd__reset" id="reset" type="button">
<svg class="pd__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>止める</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 진자 흔들기 — 각가속도 -(g/L)sinθ - c·ω 의 준암시적 오일러 적분. 각도가 곧 위치에너지다.
// 회전축은 고정점(위쪽 경첩)이므로 rotate 의 transform-origin 을 맨 위 변에 둔다 (blog-demo 규칙 8 "붙는 지점" 예외).
// 격자 미리보기(.is-demo)는 감쇠 없는 이상 진자(에너지 보존)의 사인 흔들림을 키프레임으로 재생한다 —
// 주기의 시작과 끝이 같은 각도라 경계가 닫혀 있다. 사람이 추를 잡으면 JS 가 .is-demo 를 떼고 진짜 적분으로 간다.
$duration: $dur-loop;
$easing: ease-in-out; // 사인파의 4분의 한 구간마다 가감속이 있어 구간 이징으로 표본화한다
$color: $stage-orange;
@include stage($stage-orange);
.pd { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.pd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.pd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.pd__field {
position: relative; height: 176px; border-radius: $r-card;
// 판을 이만큼 어둡게 해야 위의 크림 각도 글자가 WCAG AA(4.5:1)를 넘는다 — .18 에서는 4.23:1 이었다
background: rgba($subject-ink, .42);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .22);
cursor: grab; overflow: hidden; touch-action: none;
}
.pd__field:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.pd__pivot {
position: absolute; left: 50%; top: 0; width: 20px; height: 20px;
transform: translateX(-50%); border-radius: 50%;
background: $subject-cream; box-shadow: $shadow-press;
}
// 팔 전체가 고정점을 축으로 돈다 — transform-origin 을 맨 위로 (붙는 지점)
.pd__arm {
position: absolute; left: 50%; top: 10px; width: 0; height: 118px;
transform-origin: 50% 0;
}
.pd__rod {
position: absolute; left: -1px; top: 0; width: 2px; height: 100%;
background: rgba($subject-cream, .8);
}
.pd__bob {
position: absolute; left: -16px; bottom: -12px; width: 32px; height: 32px;
display: grid; place-items: center; border: 0; border-radius: 50%;
background: $subject-cream; color: $stage-orange; cursor: grab; padding: 0;
box-shadow: $shadow-press; touch-action: none;
}
.pd__bob:active { cursor: grabbing; }
.pd__bobIco { width: 16px; height: 16px; pointer-events: none; }
.pd__angle {
position: absolute; right: $sp-2; bottom: 0; margin: 0;
font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); color: $subject-cream;
}
.pd__bar { display: flex; align-items: center; justify-content: flex-end; gap: $sp-2; flex-wrap: wrap; }
.pd__pull, .pd__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $subject-cream; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 에너지 보존(감쇠 0)의 등진폭 사인 흔들림 — 2초에 왕복 한 번.
// 각도 텍스트는 사인과 같은 주기로 세 값을 steps 로 돌며 컷 교체된다.
.pd.is-demo .pd__arm {
animation-name: pdSwing; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes pdSwing {
0% { transform: rotate(26deg); }
25% { transform: rotate(0deg); }
50% { transform: rotate(-26deg); }
75% { transform: rotate(0deg); }
100% { transform: rotate(26deg); }
}
// 각도 값 교체 — 미리보기 전용 겹칠 텍스트(291 막대 스펙트럼의 대역 이름 패턴).
// 본문 값은 color: transparent 로 공간만 유지한다 (274 실측: display:none 은 폭까지 없앤다)
.pd__angleLoop { position: absolute; inset: 0; display: grid; place-items: center end; visibility: hidden; }
.pd__angleLoop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
.pd.is-demo .pd__angleVal { color: transparent; }
.pd.is-demo .pd__angleLoop { visibility: visible; }
.pd.is-demo .pd__angleLoop i {
animation-name: pdAngle; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pd.is-demo .pd__angleLoop i:nth-child(2) { animation-delay: 500ms; }
.pd.is-demo .pd__angleLoop i:nth-child(3) { animation-delay: 1000ms; }
.pd.is-demo .pd__angleLoop i:nth-child(4) { animation-delay: 1500ms; }
@keyframes pdAngle {
0%, 24% { visibility: visible; }
25%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.pd { gap: $sp-2; }
.pd__field { height: 104px; }
.pd__arm { height: 64px; top: 8px; }
.pd__bob { width: 26px; height: 26px; left: -13px; bottom: -8px; }
.pd__bobIco { width: 13px; height: 13px; }
}
// reduce 에서는 수직으로 멈춘 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.pd.is-demo .pd__arm { animation: none !important; transform: rotate(0deg) !important; }
.pd.is-demo .pd__angleVal { color: $subject-cream !important; }
.pd.is-demo .pd__angleLoop { visibility: hidden !important; }
.pd.is-demo .pd__angleLoop i { animation: none !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);
}
.pd {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.pd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.pd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.pd__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.42);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.22);
cursor: grab;
overflow: hidden;
touch-action: none;
}
.pd__field:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.pd__pivot {
position: absolute;
left: 50%;
top: 0;
width: 20px;
height: 20px;
transform: translateX(-50%);
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.pd__arm {
position: absolute;
left: 50%;
top: 10px;
width: 0;
height: 118px;
transform-origin: 50% 0;
}
.pd__rod {
position: absolute;
left: -1px;
top: 0;
width: 2px;
height: 100%;
background: rgba(255, 247, 230, 0.8);
}
.pd__bob {
position: absolute;
left: -16px;
bottom: -12px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: #fff7e6;
color: #ff4d1f;
cursor: grab;
padding: 0;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
touch-action: none;
}
.pd__bob:active {
cursor: grabbing;
}
.pd__bobIco {
width: 16px;
height: 16px;
pointer-events: none;
}
.pd__angle {
position: absolute;
right: 8px;
bottom: 0;
margin: 0;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
color: #fff7e6;
}
.pd__bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.pd__pull, .pd__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #fff7e6;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.pd.is-demo .pd__arm {
animation-name: pdSwing;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes pdSwing {
0% {
transform: rotate(26deg);
}
25% {
transform: rotate(0deg);
}
50% {
transform: rotate(-26deg);
}
75% {
transform: rotate(0deg);
}
100% {
transform: rotate(26deg);
}
}
.pd__angleLoop {
position: absolute;
inset: 0;
display: grid;
place-items: center end;
visibility: hidden;
}
.pd__angleLoop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.pd.is-demo .pd__angleVal {
color: transparent;
}
.pd.is-demo .pd__angleLoop {
visibility: visible;
}
.pd.is-demo .pd__angleLoop i {
animation-name: pdAngle;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pd.is-demo .pd__angleLoop i:nth-child(2) {
animation-delay: 500ms;
}
.pd.is-demo .pd__angleLoop i:nth-child(3) {
animation-delay: 1000ms;
}
.pd.is-demo .pd__angleLoop i:nth-child(4) {
animation-delay: 1500ms;
}
@keyframes pdAngle {
0%, 24% {
visibility: visible;
}
25%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.pd {
gap: 8px;
}
.pd__field {
height: 104px;
}
.pd__arm {
height: 64px;
top: 8px;
}
.pd__bob {
width: 26px;
height: 26px;
left: -13px;
bottom: -8px;
}
.pd__bobIco {
width: 13px;
height: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.pd.is-demo .pd__arm {
animation: none !important;
transform: rotate(0deg) !important;
}
.pd.is-demo .pd__angleVal {
color: #fff7e6 !important;
}
.pd.is-demo .pd__angleLoop {
visibility: hidden !important;
}
.pd.is-demo .pd__angleLoop i {
animation: none !important;
}
}var root = document.getElementById('pd');
var field = document.getElementById('field');
var arm = document.getElementById('arm');
var bob = document.getElementById('bob');
var angleText = document.getElementById('angleText');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// G 는 px/s² 로 02 중력 드롭과 같은 눈금이고 RAD 는 度 변환 상수다.
// -(g/L)sinθ 는 라디안/s² 이라 度 단위인 angle·avel 에 그대로 더하면 57배 작아진다 —
// 그 상태로는 30도에서 놓아도 6초에 0.9도밖에 안 움직여 추가 멈춰 있는 것처럼 보인다
var G = 1800, LEN = 118, DAMP = 0.4, RAD = 180 / Math.PI;
var angle = 0, avel = 0, dragging = false, last = 0, running = false;
function paint() {
arm.style.transform = 'rotate(' + angle.toFixed(2) + 'deg)';
var deg = Math.round(Math.abs(angle));
if (angleText.textContent !== deg + '度') { angleText.textContent = deg + '度'; }
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// 각가속도 = -(g/L)·sinθ - 減衰. 각도가 높이(에너지)로 바뀌는 게 진자의 물리다
avel += (-(G / LEN) * Math.sin(angle / RAD) * RAD - DAMP * avel) * dt;
angle += avel * dt;
paint();
if (Math.abs(angle) < 0.2 && Math.abs(avel) < 0.6) {
angle = 0; avel = 0; paint(); running = false; return;
}
requestAnimationFrame(step);
}
function start() {
root.classList.remove('is-demo');
if (reduce.matches) { angle = 0; avel = 0; paint(); return; }
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
}
function angleFromEvent(e) {
var r = field.getBoundingClientRect();
var px = e.clientX - (r.left + r.width / 2);
var py = e.clientY - r.top;
var deg = Math.atan2(px, py) * 180 / Math.PI;
return Math.max(-75, Math.min(75, deg));
}
bob.addEventListener('pointerdown', function (e) {
e.preventDefault();
dragging = true; root.classList.remove('is-demo');
angle = angleFromEvent(e); avel = 0; paint();
bob.setPointerCapture(e.pointerId);
});
bob.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
angle = angleFromEvent(e); avel = 0; paint();
});
bob.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false;
if (reduce.matches) { angle = 0; avel = 0; paint(); return; }
// 놓는 순간의 각도가 위치에너지 — 여기서부터 진동이 시작된다
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
});
field.addEventListener('keydown', function (e) {
var JUMP = 15;
if (e.key === 'ArrowLeft') { angle = Math.max(-75, angle - JUMP); }
else if (e.key === 'ArrowRight') { angle = Math.min(75, angle + JUMP); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); start(); return; }
else { return; }
e.preventDefault();
root.classList.remove('is-demo'); avel = 0; paint();
});
document.getElementById('pull').addEventListener('click', function () { angle = 30; avel = 0; start(); });
document.getElementById('reset').addEventListener('click', function () { angle = 0; avel = 0; running = false; paint(); });
paint();// PendulumSwing — vanilla 판(run/292/demo/03-physics-pendulum-swing/index.html) 의 React 포트.
// 각가속도 = -(G/LEN)·sin(각도) - DAMP·각속도 의 준암시적 오일러 적분. 각도가 곧 위치에너지다.
// 추를 잡고 있는 동안에는 포인터 각도를 그대로 쓰고, 놓는 순간의 각도에서 진동이 시작된다.
// 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./PendulumSwing.module.scss";
export type PendulumSwingProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
// G 는 px/s² 로 02 중력 드롭과 같은 눈금이고 RAD 는 도 변환 상수다.
// -(g/L)sinθ 는 라디안/s² 이라 도 단위인 angle·avel 에 그대로 더하면 57배 작아진다 —
// 그 상태로는 30도에서 놓아도 6초에 0.9도밖에 안 움직여 추가 멈춰 있는 것처럼 보인다
const G = 1800;
const LEN = 118;
const DAMP = 0.4;
const RAD = 180 / Math.PI;
const JUMP = 15; // 방향키 한 번에 더하는 각도
const LIMIT = 75; // 잡아 당길 수 있는 최대 각도
const FIELD_LABEL = "진자 실험판";
export default function PendulumSwing({
duration = 2000,
easing = "ease-in-out",
color = "#ff4d1f",
}: PendulumSwingProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const armRef = useRef<HTMLDivElement | null>(null);
const angleRef = useRef<number>(0) as Box<number>;
const avelRef = useRef<number>(0) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const lastRef = useRef<number>(0) as Box<number>;
const rafRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const shownRef = useRef<number>(0) as Box<number>; // 화면에 찍힌 각도 — 같은 값이면 다시 그리지 않는다
const [shown, setShown] = useState(0);
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 paint(): void {
const arm = armRef.current;
if (arm) arm.style.transform = "rotate(" + angleRef.current.toFixed(2) + "deg)";
const deg = Math.round(Math.abs(angleRef.current));
if (shownRef.current !== deg) { shownRef.current = deg; setShown(deg); }
}
function step(now: number): void {
const dt = Math.min((now - lastRef.current) / 1000, 0.033);
lastRef.current = now;
// 각가속도 = -(g/L)·sinθ - 감쇠. 각도가 높이(에너지)로 바뀌는 게 진자의 물리다
avelRef.current += (-(G / LEN) * Math.sin(angleRef.current / RAD) * RAD - DAMP * avelRef.current) * dt;
angleRef.current += avelRef.current * dt;
paint();
if (Math.abs(angleRef.current) < 0.2 && Math.abs(avelRef.current) < 0.6) {
angleRef.current = 0; avelRef.current = 0;
paint();
runningRef.current = false;
return;
}
rafRef.current = requestAnimationFrame(step);
}
function start(): void {
setDemo(false);
if (reduced()) { angleRef.current = 0; avelRef.current = 0; paint(); return; }
if (!runningRef.current) {
runningRef.current = true;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
}
function angleFromEvent(event: ReactPointerEvent<HTMLButtonElement>): number {
const field = fieldRef.current;
if (!field) return angleRef.current;
const r = field.getBoundingClientRect();
const px = event.clientX - (r.left + r.width / 2);
const py = event.clientY - r.top;
const deg = (Math.atan2(px, py) * 180) / Math.PI;
return Math.max(-LIMIT, Math.min(LIMIT, deg));
}
useEffect(() => {
paint();
return () => cancelAnimationFrame(rafRef.current);
}, []);
function onBobPointerDown(event: ReactPointerEvent<HTMLButtonElement>): void {
event.preventDefault();
draggingRef.current = true;
setDemo(false);
angleRef.current = angleFromEvent(event);
avelRef.current = 0;
paint();
event.currentTarget.setPointerCapture(event.pointerId);
}
function onBobPointerMove(event: ReactPointerEvent<HTMLButtonElement>): void {
if (!draggingRef.current) return;
angleRef.current = angleFromEvent(event);
avelRef.current = 0;
paint();
}
function onBobPointerUp(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
if (reduced()) { angleRef.current = 0; avelRef.current = 0; paint(); return; }
// 놓는 순간의 각도가 위치에너지 — 여기서부터 진동이 시작된다
if (!runningRef.current) {
runningRef.current = true;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
}
function onKeyDown(event: KeyEvt): void {
if (event.key === "ArrowLeft") angleRef.current = Math.max(-LIMIT, angleRef.current - JUMP);
else if (event.key === "ArrowRight") angleRef.current = Math.min(LIMIT, angleRef.current + JUMP);
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); start(); return; }
else return;
event.preventDefault();
setDemo(false);
avelRef.current = 0;
paint();
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-grab" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 108v44a80 80 0 0 1-160 0v-12a20 20 0 0 1 20-20h20V92a20 20 0 0 1 40 0a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M188 80a27.8 27.8 0 0 0-13.36 3.4a28 28 0 0 0-46.64-11A28 28 0 0 0 80 92v20H68a28 28 0 0 0-28 28v12a88 88 0 0 0 176 0v-44a28 28 0 0 0-28-28m12 72a72 72 0 0 1-144 0v-12a12 12 0 0 1 12-12h12v24a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0v-12a12 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>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-grab" /></svg>
<span>추를 잡아 당겼다 놓으면 각도가 높이로 바뀌어 스스로 흔들립니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onKeyDown={onKeyDown}
>
<span className={styles.pivot} aria-hidden="true"></span>
<div className={styles.arm} ref={armRef}>
<span className={styles.rod} aria-hidden="true"></span>
<button
className={styles.bob}
type="button"
aria-label="진자 추"
onPointerDown={onBobPointerDown}
onPointerMove={onBobPointerMove}
onPointerUp={onBobPointerUp}
>
<svg className={styles.bobIco} aria-hidden="true"><use href="#ico-grab" /></svg>
</button>
</div>
<p className={styles.angle} aria-live="polite">
<span className={styles.angleVal}>{shown}도</span>
<span className={styles.angleLoop} aria-hidden="true"><i>26도</i><i>0도</i><i>26도</i><i>0도</i></span>
</p>
</div>
<div className={styles.bar}>
<button
className={styles.pull}
type="button"
onClick={() => { angleRef.current = 30; avelRef.current = 0; start(); }}
>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-grab" /></svg><span>30도에서 놓기</span>
</button>
<button
className={styles.reset}
type="button"
onClick={() => { angleRef.current = 0; avelRef.current = 0; runningRef.current = false; paint(); }}
>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-reset" /></svg><span>멈추기</span>
</button>
</div>
</div>
</main>
);
}03 · 振り子スイング — 計測 累積面積 5.61% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
プレビューは累積面積 5.6%・移動フレーム 23/23 です。
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// 角加速度 = -(g/L)·sinθ - 減衰。角度が高さ(エネルギー)に変わるのが振り子の物理
// angle と avel の単位は度、-(g/L)·sinθ はラジアン/s² — RAD を掛けて度へそろえる
avel += (-(G / LEN) * Math.sin(angle / RAD) * RAD - DAMP * avel) * dt;
angle += avel * dt;
paint();
if (Math.abs(angle) < 0.2 && Math.abs(avel) < 0.6) {
angle = 0; avel = 0; paint(); running = false; return;
}
requestAnimationFrame(step);
}
// 腕の全体が固定点を軸に回る — transform-origin を上辺(吊るした点)へ
.pd__arm {
position: absolute; left: 50%; top: 10px; width: 0; height: 118px;
transform-origin: 50% 0;
}
.pd__rod {
position: absolute; left: -1px; top: 0; width: 2px; height: 100%;
background: rgba($subject-cream, .8);
}
04マグネットパーティクル
何が見えるか
8×4の格子に置いた鉄粉32個が、カーソルの磁石から距離の二乗に反比例する力を受けて、押しのけられたり引き寄せられたりします。手を止めると定位置のばねがすべてを元の並びへ戻し、プレビューは累積面積 18.8%・移動フレーム 23/23 を記録しました。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-magnet" viewBox="0 0 256 256" fill="currentColor"><g><path d="m168.36 200.36l-30 29.35a8 8 0 0 1-11.26-.05L98.46 201a8 8 0 0 1 .08-11.4l30-29Zm-142-82.76a8 8 0 0 0 0 11.28L55 157.54a8 8 0 0 0 11.38-.06l29.18-29.92l-39.79-39.79Z" opacity=".2"/><path d="M207 50.25A87.46 87.46 0 0 0 144.6 24h-.33A87.48 87.48 0 0 0 82 49.81l-31.89 32.3L20.61 112a16 16 0 0 0 .06 22.56l28.66 28.66a15.92 15.92 0 0 0 11.32 4.69h.09a16 16 0 0 0 11.36-4.82l60.9-62.4a16.08 16.08 0 0 1 22.41-.21a15.6 15.6 0 0 1 4.73 11.19a16.9 16.9 0 0 1-4.85 12L93 183.88a16 16 0 0 0-.17 22.79l28.66 28.66a16.06 16.06 0 0 0 22.52.12l61.8-60.45c34.45-34.5 34.98-90.44 1.19-124.75M60.7 156.47l-27-27L55.87 108l27 27Zm98.46-71.25a24 24 0 0 0-34.29.44l-30 30.72l-27-27l30.11-30.42A71.5 71.5 0 0 1 144.36 40h.27a71.49 71.49 0 0 1 50.71 21.53c.51.52 1 1.05 1.5 1.59l-37.68 22.1Zm53.76 53.76L151 200l-27-27l61.81-60.46l.24-.24c.4-.4.79-.8 1.17-1.21a24 24 0 0 0-17.3-40.48l25.6-15A71.66 71.66 0 0 1 213 138.87Z"/></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="mg is-demo" id="mg">
<p class="mg__hint"><svg class="mg__ico" aria-hidden="true"><use href="#ico-magnet"/></svg><span>板の上でマウスを動かすと、鉄粉がカーソルの磁石に押されたり引かれたりします</span></p>
<div class="mg__field" id="field" role="application" aria-label="磁石の実験台" tabindex="0">
<span class="mg__magnet" id="magnet" aria-hidden="true"><svg class="mg__magnetIco"><use href="#ico-magnet"/></svg></span>
<span class="mg__dots" id="dots" aria-hidden="true"></span>
</div>
<div class="mg__bar">
<button class="mg__mode" id="mode" type="button" aria-pressed="false">
<svg class="mg__ico" aria-hidden="true"><use href="#ico-magnet"/></svg><span id="modeLabel">引き寄せる</span>
</button>
<button class="mg__reset" id="reset" type="button">
<svg class="mg__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>元に戻す</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 자석 파티클 — 커서가 자석. 힘 = 부호 / (r² + 연화) 의 쿨롱 간이 + 제자리 스프링.
// 철가루는 격자 자리(var(--hx), var(--hy)) 로 돌아오고 translate 하나로만 움직인다.
// 격자 미리보기(.is-demo)는 당김 단계와 밀어냄 단계를 같은 루프에 담은 표본을 키프레임으로 재생한다 —
// 각 점의 이동량은 (중심 - 제자리) 의 비율이라 점마다 방향이 다르다. 사람이 판에 들어오면 JS 가 실시간 힘을 계산한다.
$duration: $dur-loop;
$easing: ease-in-out;
$color: $subject-ink;
@include stage($stage-paper);
.mg { --cx: 212px; --cy: 88px; display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.mg__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.mg__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.mg__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($subject-blue, .06);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .14);
overflow: hidden; touch-action: none;
}
.mg__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.mg__dot {
position: absolute; left: 0; top: 0; width: 10px; height: 10px; border-radius: 50%;
background: $color;
transform: translate(var(--hx), var(--hy));
}
.mg__magnet {
position: absolute; left: 0; top: 0; width: 24px; height: 24px;
display: grid; place-items: center; border-radius: 50%;
background: $subject-blue; color: $stage-paper;
box-shadow: $shadow-press;
transform: translate(212px, 88px);
}
.mg__magnetIco { width: 14px; height: 14px; }
.mg__bar { display: flex; align-items: center; justify-content: flex-end; gap: $sp-2; flex-wrap: wrap; }
.mg__mode, .mg__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.mg__mode { background: $subject-blue; color: $stage-paper; }
.mg__mode .mg__ico { color: $stage-paper; }
.mg__reset { background: $color; color: $stage-paper; }
.mg__reset .mg__ico { color: $stage-paper; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 0~44% 당김(중심 방향 38%), 58~92% 밀어냄(중심 반대 26%), 나머지는 제자리.
// 이동량은 점마다 (중심 - 제자리) 비율이라 격자가 통째로 숨쉬듯 모였다 흩어진다.
// 점 32개를 -62.5ms 간격 위상으로 흩어 파도가 도는 느낌을 낸다 (음수 지연이라 루프가 닫혀 있다).
.mg.is-demo .mg__dot {
animation-name: mgSwoop; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -62.5ms);
}
@keyframes mgSwoop {
0% { transform: translate(var(--hx), var(--hy)) scale(1); }
10% { transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * .2), calc(var(--hy) + (var(--cy) - var(--hy)) * .2)) scale(1.14); }
22% { transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * .38), calc(var(--hy) + (var(--cy) - var(--hy)) * .38)) scale(1.32); }
36% { transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * .12), calc(var(--hy) + (var(--cy) - var(--hy)) * .12)) scale(1.06); }
46% { transform: translate(var(--hx), var(--hy)) scale(1); }
58% { transform: translate(var(--hx), var(--hy)) scale(1); }
70% { transform: translate(calc(var(--hx) + (var(--hx) - var(--cx)) * .26), calc(var(--hy) + (var(--hy) - var(--cy)) * .26)) scale(.94); }
84% { transform: translate(calc(var(--hx) + (var(--hx) - var(--cx)) * .1), calc(var(--hy) + (var(--hy) - var(--cy)) * .1)) scale(1); }
94% { transform: translate(var(--hx), var(--hy)) scale(1); }
100% { transform: translate(var(--hx), var(--hy)) scale(1); }
}
// 자석 아이콘은 제자리에서 살짝 숨쉰다 — 커지는 건 자기 중심에서 (blog-demo 규칙 8)
.mg.is-demo .mg__magnet {
animation-name: mgBreath; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
transform: translate(var(--cx), var(--cy));
}
@keyframes mgBreath {
0%, 46%, 58%, 100% { transform: translate(var(--cx), var(--cy)) scale(1); }
22% { transform: translate(var(--cx), var(--cy)) scale(1.16); }
70% { transform: translate(var(--cx), var(--cy)) scale(.9); }
}
@media (max-width: 340px) {
.mg { gap: $sp-2; --cx: 147px; --cy: 52px; }
.mg__field { height: 104px; }
.mg__dot { width: 8px; height: 8px; }
.mg__magnet { width: 20px; height: 20px; }
.mg__magnetIco { width: 12px; height: 12px; }
}
// reduce 에서는 격자 자리에 멈춘 정지 상태 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.mg.is-demo .mg__dot,
.mg.is-demo .mg__magnet { animation: none !important; }
.mg.is-demo .mg__dot { transform: translate(var(--hx), var(--hy)) !important; }
.mg.is-demo .mg__magnet { transform: translate(var(--cx), var(--cy)) !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);
}
.mg {
--cx: 212px;
--cy: 88px;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.mg__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.mg__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.mg__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(47, 109, 246, 0.06);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.14);
overflow: hidden;
touch-action: none;
}
.mg__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.mg__dot {
position: absolute;
left: 0;
top: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: #17141a;
transform: translate(var(--hx), var(--hy));
}
.mg__magnet {
position: absolute;
left: 0;
top: 0;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 50%;
background: #2f6df6;
color: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(212px, 88px);
}
.mg__magnetIco {
width: 14px;
height: 14px;
}
.mg__bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.mg__mode, .mg__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.mg__mode {
background: #2f6df6;
color: #ffffff;
}
.mg__mode .mg__ico {
color: #ffffff;
}
.mg__reset {
background: #17141a;
color: #ffffff;
}
.mg__reset .mg__ico {
color: #ffffff;
}
.mg.is-demo .mg__dot {
animation-name: mgSwoop;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -62.5ms);
}
@keyframes mgSwoop {
0% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
10% {
transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * 0.2), calc(var(--hy) + (var(--cy) - var(--hy)) * 0.2)) scale(1.14);
}
22% {
transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * 0.38), calc(var(--hy) + (var(--cy) - var(--hy)) * 0.38)) scale(1.32);
}
36% {
transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * 0.12), calc(var(--hy) + (var(--cy) - var(--hy)) * 0.12)) scale(1.06);
}
46% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
58% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
70% {
transform: translate(calc(var(--hx) + (var(--hx) - var(--cx)) * 0.26), calc(var(--hy) + (var(--hy) - var(--cy)) * 0.26)) scale(0.94);
}
84% {
transform: translate(calc(var(--hx) + (var(--hx) - var(--cx)) * 0.1), calc(var(--hy) + (var(--hy) - var(--cy)) * 0.1)) scale(1);
}
94% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
100% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
}
.mg.is-demo .mg__magnet {
animation-name: mgBreath;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
transform: translate(var(--cx), var(--cy));
}
@keyframes mgBreath {
0%, 46%, 58%, 100% {
transform: translate(var(--cx), var(--cy)) scale(1);
}
22% {
transform: translate(var(--cx), var(--cy)) scale(1.16);
}
70% {
transform: translate(var(--cx), var(--cy)) scale(0.9);
}
}
@media (max-width: 340px) {
.mg {
gap: 8px;
--cx: 147px;
--cy: 52px;
}
.mg__field {
height: 104px;
}
.mg__dot {
width: 8px;
height: 8px;
}
.mg__magnet {
width: 20px;
height: 20px;
}
.mg__magnetIco {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg.is-demo .mg__dot,
.mg.is-demo .mg__magnet {
animation: none !important;
}
.mg.is-demo .mg__dot {
transform: translate(var(--hx), var(--hy)) !important;
}
.mg.is-demo .mg__magnet {
transform: translate(var(--cx), var(--cy)) !important;
}
}var root = document.getElementById('mg');
var field = document.getElementById('field');
var magnet = document.getElementById('magnet');
var dotsBox = document.getElementById('dots');
var modeBtn = document.getElementById('mode');
var modeLabel = document.getElementById('modeLabel');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 철가루 32개 — 8×4 격자에 지터를 준 자리가 제자리(home)다. 커서는 자석.
var COLS = 8, ROWS = 4, dots = [];
function homeXY(c, r) {
return {
x: (c + 0.5) * field.clientWidth / COLS - 5 + (r % 2) * 8 - 4,
y: (r + 0.5) * field.clientHeight / ROWS - 5 + ((c % 3) - 1) * 6
};
}
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 = 'mg__dot';
el.style.setProperty('--hx', home.x.toFixed(1) + 'px');
el.style.setProperty('--hy', home.y.toFixed(1) + 'px');
el.style.setProperty('--i', String(r * COLS + c));
dotsBox.appendChild(el);
dots.push({ el: el, c: c, r: r, hx: home.x, hy: home.y, x: home.x, y: home.y, vx: 0, vy: 0 });
}
}
var mag = { x: 212, y: 88 }, attract = false, last = 0, running = false, idleAt = 0;
function paintMagnet() {
magnet.style.transform = 'translate(' + mag.x + 'px, ' + mag.y + 'px)';
}
function paintDots() {
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
d.el.style.transform = 'translate(' + d.x + 'px, ' + d.y + 'px)';
}
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) { paintMagnet(); paintDots(); 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 < 1600, allCalm = true;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var dx = d.x - mag.x, dy = d.y - mag.y;
var r2 = dx * dx + dy * dy;
// 쿨롱 간이: 힘 = 부호 / (r² + 연화) — 거리 제곱에 반비례하고 0 나누기를 막는다
var f = (attract ? -5200 : 4200) / (r2 + 900);
var len = Math.sqrt(r2) || 1;
d.vx += (dx / len) * f * dt * 60;
d.vy += (dy / len) * f * dt * 60;
// 제자리 스프링 + 減衰 — 철가루가 흩어지지 않고 돌아온다
d.vx += (d.hx - d.x) * 46 * dt;
d.vy += (d.hy - d.y) * 46 * dt;
d.vx *= 0.86; d.vy *= 0.86;
d.x += d.vx * dt; d.y += d.vy * dt;
if (Math.abs(d.vx) + Math.abs(d.vy) > 3 || Math.abs(d.hx - d.x) + Math.abs(d.hy - d.y) > 0.4) { allCalm = false; }
d.el.style.transform = 'translate(' + d.x + 'px, ' + d.y + 'px)';
}
if (!lively && allCalm) { paintDots(); running = false; return; }
requestAnimationFrame(step);
}
function moveMagnet(x, y, fromPointer) {
mag.x = Math.max(-8, Math.min(field.clientWidth - 24, x));
mag.y = Math.max(-8, Math.min(field.clientHeight - 24, y));
paintMagnet();
wake();
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
moveMagnet(e.clientX - r.left - 12, e.clientY - r.top - 12, true);
});
field.addEventListener('keydown', function (e) {
var JUMP = 28;
if (e.key === 'ArrowLeft') { moveMagnet(mag.x - JUMP, mag.y); }
else if (e.key === 'ArrowRight') { moveMagnet(mag.x + JUMP, mag.y); }
else if (e.key === 'ArrowUp') { moveMagnet(mag.x, mag.y - JUMP); }
else if (e.key === 'ArrowDown') { moveMagnet(mag.x, mag.y + JUMP); }
else { return; }
e.preventDefault();
});
modeBtn.addEventListener('click', function () {
attract = !attract;
modeBtn.setAttribute('aria-pressed', attract ? 'true' : 'false');
modeLabel.textContent = attract ? '押しのける' : '引き寄せる';
wake();
});
document.getElementById('reset').addEventListener('click', 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].x = home.x; dots[i].y = home.y; dots[i].vx = 0; dots[i].vy = 0;
dots[i].el.style.setProperty('--hx', home.x.toFixed(1) + 'px');
dots[i].el.style.setProperty('--hy', home.y.toFixed(1) + 'px');
}
mag.x = field.clientWidth / 2 - 12; mag.y = field.clientHeight / 2 - 12;
paintMagnet(); paintDots();
});
window.addEventListener('resize', function () {
document.getElementById('reset').click();
});
paintMagnet(); paintDots();// MagnetParticles — vanilla 판(run/292/demo/04-physics-magnet-particles/index.html) 의 React 포트.
// 커서가 자석이다. 힘 = 부호 / (r² + 연화) 의 쿨롱 간이로 밀거나 당기고,
// 제자리 스프링(46)과 감쇠(0.86)가 철가루를 격자 자리로 되돌린다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./MagnetParticles.module.scss";
export type MagnetParticlesProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DotVars = CSSProperties & { "--i"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Dot = { el: HTMLElement; c: number; r: number; hx: number; hy: number; x: number; y: number; vx: number; vy: number };
// 철가루 32개 — 8×4 격자에 지터를 준 자리가 제자리(home)다
const COLS = 8;
const ROWS = 4;
const JUMP = 28; // 방향키 한 번에 옮기는 자석 거리
const FIELD_LABEL = "자석 실험판";
const INDEXES: number[] = [];
for (let n = 0; n < COLS * ROWS; n++) INDEXES.push(n);
export default function MagnetParticles({
duration = 2000,
easing = "ease-in-out",
color = "#17141a",
}: MagnetParticlesProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const magnetRef = useRef<HTMLSpanElement | null>(null);
const dotsBoxRef = useRef<HTMLSpanElement | null>(null);
const dotsRef = useRef<Dot[]>([]) as Box<Dot[]>;
const magRef = useRef<{ x: number; y: number }>({ x: 212, y: 88 }) as Box<{ x: number; y: number }>;
const attractRef = useRef<boolean>(false) as Box<boolean>;
const lastRef = useRef<number>(0) as Box<number>;
const idleAtRef = useRef<number>(0) as Box<number>;
const rafRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const [attract, setAttract] = useState(false);
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): { x: number; y: number } {
const field = fieldRef.current;
const w = field ? field.clientWidth : 424;
const h = field ? field.clientHeight : 176;
return {
x: ((c + 0.5) * w) / COLS - 5 + (r % 2) * 8 - 4,
y: ((r + 0.5) * h) / ROWS - 5 + ((c % 3) - 1) * 6,
};
}
function paintMagnet(): void {
const magnet = magnetRef.current;
if (magnet) magnet.style.transform = "translate(" + magRef.current.x + "px, " + magRef.current.y + "px)";
}
function paintDots(): void {
const dots = dotsRef.current;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
d.el.style.transform = "translate(" + d.x + "px, " + d.y + "px)";
}
}
function wake(): void {
setDemo(false);
if (reduced()) { paintMagnet(); paintDots(); return; }
idleAtRef.current = performance.now();
if (!runningRef.current) {
runningRef.current = true;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
}
function step(now: number): void {
const dt = Math.min((now - lastRef.current) / 1000, 0.033);
lastRef.current = now;
const dots = dotsRef.current;
const mag = magRef.current;
const lively = now - idleAtRef.current < 1600;
let allCalm = true;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const dx = d.x - mag.x;
const dy = d.y - mag.y;
const r2 = dx * dx + dy * dy;
// 쿨롱 간이: 힘 = 부호 / (r² + 연화) — 거리 제곱에 반비례하고 0 나누기를 막는다
const f = (attractRef.current ? -5200 : 4200) / (r2 + 900);
const len = Math.sqrt(r2) || 1;
d.vx += (dx / len) * f * dt * 60;
d.vy += (dy / len) * f * dt * 60;
// 제자리 스프링 + 감쇠 — 철가루가 흩어지지 않고 돌아온다
d.vx += (d.hx - d.x) * 46 * dt;
d.vy += (d.hy - d.y) * 46 * dt;
d.vx *= 0.86; d.vy *= 0.86;
d.x += d.vx * dt; d.y += d.vy * dt;
if (Math.abs(d.vx) + Math.abs(d.vy) > 3 || Math.abs(d.hx - d.x) + Math.abs(d.hy - d.y) > 0.4) allCalm = false;
d.el.style.transform = "translate(" + d.x + "px, " + d.y + "px)";
}
if (!lively && allCalm) { paintDots(); runningRef.current = false; return; }
rafRef.current = requestAnimationFrame(step);
}
function moveMagnet(x: number, y: number): void {
const field = fieldRef.current;
if (!field) return;
const mag = magRef.current;
mag.x = Math.max(-8, Math.min(field.clientWidth - 24, x));
mag.y = Math.max(-8, Math.min(field.clientHeight - 24, y));
paintMagnet();
wake();
}
function home(): void {
const field = fieldRef.current;
const dots = dotsRef.current;
for (let i = 0; i < dots.length; i++) {
const spot = homeXY(dots[i].c, dots[i].r);
dots[i].hx = spot.x; dots[i].hy = spot.y;
dots[i].x = spot.x; dots[i].y = spot.y;
dots[i].vx = 0; dots[i].vy = 0;
dots[i].el.style.setProperty("--hx", spot.x.toFixed(1) + "px");
dots[i].el.style.setProperty("--hy", spot.y.toFixed(1) + "px");
}
if (field) {
magRef.current.x = field.clientWidth / 2 - 12;
magRef.current.y = field.clientHeight / 2 - 12;
}
paintMagnet();
paintDots();
}
useEffect(() => {
const box = dotsBoxRef.current;
if (box) {
const nodes = box.querySelectorAll("i");
const built: Dot[] = [];
for (let n = 0; n < nodes.length; n++) {
const el = nodes[n];
const c = n % COLS;
const r = Math.floor(n / COLS);
const spot = homeXY(c, r);
el.style.setProperty("--hx", spot.x.toFixed(1) + "px");
el.style.setProperty("--hy", spot.y.toFixed(1) + "px");
built.push({ el: el, c: c, r: r, hx: spot.x, hy: spot.y, x: spot.x, y: spot.y, vx: 0, vy: 0 });
}
dotsRef.current = built;
}
paintMagnet();
paintDots();
function onResize(): void { home(); }
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
moveMagnet(event.clientX - r.left - 12, event.clientY - r.top - 12);
}
function onKeyDown(event: KeyEvt): void {
const mag = magRef.current;
if (event.key === "ArrowLeft") moveMagnet(mag.x - JUMP, mag.y);
else if (event.key === "ArrowRight") moveMagnet(mag.x + JUMP, mag.y);
else if (event.key === "ArrowUp") moveMagnet(mag.x, mag.y - JUMP);
else if (event.key === "ArrowDown") moveMagnet(mag.x, mag.y + JUMP);
else return;
event.preventDefault();
}
function toggleMode(): void {
const next = !attractRef.current;
attractRef.current = next;
setAttract(next);
wake();
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const dotVars = (n: number): DotVars => ({ "--i": String(n) });
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-magnet" viewBox="0 0 256 256" fill="currentColor"><g><path d="m168.36 200.36l-30 29.35a8 8 0 0 1-11.26-.05L98.46 201a8 8 0 0 1 .08-11.4l30-29Zm-142-82.76a8 8 0 0 0 0 11.28L55 157.54a8 8 0 0 0 11.38-.06l29.18-29.92l-39.79-39.79Z" opacity=".2"/><path d="M207 50.25A87.46 87.46 0 0 0 144.6 24h-.33A87.48 87.48 0 0 0 82 49.81l-31.89 32.3L20.61 112a16 16 0 0 0 .06 22.56l28.66 28.66a15.92 15.92 0 0 0 11.32 4.69h.09a16 16 0 0 0 11.36-4.82l60.9-62.4a16.08 16.08 0 0 1 22.41-.21a15.6 15.6 0 0 1 4.73 11.19a16.9 16.9 0 0 1-4.85 12L93 183.88a16 16 0 0 0-.17 22.79l28.66 28.66a16.06 16.06 0 0 0 22.52.12l61.8-60.45c34.45-34.5 34.98-90.44 1.19-124.75M60.7 156.47l-27-27L55.87 108l27 27Zm98.46-71.25a24 24 0 0 0-34.29.44l-30 30.72l-27-27l30.11-30.42A71.5 71.5 0 0 1 144.36 40h.27a71.49 71.49 0 0 1 50.71 21.53c.51.52 1 1.05 1.5 1.59l-37.68 22.1Zm53.76 53.76L151 200l-27-27l61.81-60.46l.24-.24c.4-.4.79-.8 1.17-1.21a24 24 0 0 0-17.3-40.48l25.6-15A71.66 71.66 0 0 1 213 138.87Z"/></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>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-magnet" /></svg>
<span>판 위에서 마우스를 움직이면 철가루가 커서 자석을 따라 밀리고 당겨집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onKeyDown={onKeyDown}
>
<span className={styles.magnet} ref={magnetRef} aria-hidden="true">
<svg className={styles.magnetIco}><use href="#ico-magnet" /></svg>
</span>
<span ref={dotsBoxRef} aria-hidden="true">
{INDEXES.map((n) => (
<i key={n} className={styles.dot} style={dotVars(n)} />
))}
</span>
</div>
<div className={styles.bar}>
<button className={styles.mode} type="button" aria-pressed={attract} onClick={toggleMode}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-magnet" /></svg>
<span>{attract ? "밀어내기" : "당기기"}</span>
</button>
<button className={styles.reset} type="button" onClick={home}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-reset" /></svg><span>처음대로</span>
</button>
</div>
</div>
</main>
);
}04 · マグネットパーティクル — 計測 累積面積 18.77% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ヒーローセクションの背景や、カーソルまわりに集まるナビのハイライトに使えます。
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var lively = now - idleAt < 1600, allCalm = true;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var dx = d.x - mag.x, dy = d.y - mag.y;
var r2 = dx * dx + dy * dy;
// 簡易クーロン: 力 = 符号 / (r² + 軟化) — 距離の二乗に反比例させ、0 除算も防ぐ
var f = (attract ? -5200 : 4200) / (r2 + 900);
var len = Math.sqrt(r2) || 1;
d.vx += (dx / len) * f * dt * 60;
d.vy += (dy / len) * f * dt * 60;
// 定位置のばね + 減衰 — 鉄粉が散らばらずに戻ってくる
d.vx += (d.hx - d.x) * 46 * dt;
d.vy += (d.hy - d.y) * 46 * dt;
d.vx *= 0.86; d.vy *= 0.86;
d.x += d.vx * dt; d.y += d.vy * dt;
if (Math.abs(d.vx) + Math.abs(d.vy) > 3 || Math.abs(d.hx - d.x) + Math.abs(d.hy - d.y) > 0.4) { allCalm = false; }
d.el.style.transform = 'translate(' + d.x + 'px, ' + d.y + 'px)';
}
if (!lively && allCalm) { paintDots(); running = false; return; }
requestAnimationFrame(step);
}
05パドルバウンド
画面で起きること
パドルがマウスを追い、ボールは壁と3×6のレンガに当たって跳ね返りながらスコアを積み上げます。当たった位置が水平成分を決めるので、パドルの端で受けるほど鋭い角度で飛びます。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-ball" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88"/></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="pb is-demo" id="pb">
<p class="pb__hint"><svg class="pb__ico" aria-hidden="true"><use href="#ico-ball"/></svg><span>パドルがマウスを追い、ボールがレンガに当たるたびスコアが上がります</span></p>
<div class="pb__field" id="field" role="application" aria-label="レンガのゲーム盤" tabindex="0">
<p class="pb__score" aria-live="polite"><span class="pb__scoreVal" id="scoreText">スコア 0</span><span class="pb__scoreLoop" aria-hidden="true"><i>スコア 0</i><i>スコア 1</i><i>スコア 2</i></span></p>
<div class="pb__bricks" id="bricks" aria-hidden="true"></div>
<span class="pb__ball" id="ball" aria-hidden="true"></span>
<span class="pb__paddle" id="paddle" aria-hidden="true"></span>
</div>
<div class="pb__bar">
<button class="pb__launch" id="launch" type="button">
<svg class="pb__ico" aria-hidden="true"><use href="#ico-ball"/></svg><span>ボールを放つ</span>
</button>
<button class="pb__reset" id="reset" type="button">
<svg class="pb__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>最初へ</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 패들 튕기기 — 공은 속도 벡터를 법선으로 되돌리는 반사, 패들은 맞은 칸에 따라 수평 성분이 꺾인다.
// 패들은 pointermove(마우스) 와 keydown(방향키) 두 경로가 같은 px 값을 공유한다.
// 격자 미리보기(.is-demo)는 실제 게임의 왕복을 표본 추출한 공 경로 + 패들 추적 + 벽돌 섬광을 키프레임으로 재생한다.
// 루프의 시작과 끝을 패들 접촉점 한 곳에 두어 경계가 곧 패들 튕김이라 순간이동이 보이지 않는다.
$duration: $dur-loop;
$easing: linear; // 공은 등속 — 왕복 속도는 키프레임 간 거리 비율로 표본화했다
$color: $stage-yellow;
@include stage($stage-ink);
.pb { --kx: 1; --ky: 1; display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.pb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.pb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.pb__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($subject-cream, .06);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
overflow: hidden; touch-action: none; cursor: none;
}
.pb__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pb__bricks { position: absolute; inset: 0; }
.pb__brick {
position: absolute; left: 0; top: 0; width: 60px; height: 14px;
border-radius: $r-xs;
}
.pb__brick--r0 { background: $stage-orange; }
.pb__brick--r1 { background: $color; }
.pb__brick--r2 { background: $subject-cream; }
.pb__brick.is-hit { visibility: hidden; }
.pb__ball {
position: absolute; left: 0; top: 0; width: 12px; height: 12px;
border-radius: 50%; background: $subject-cream;
}
.pb__paddle {
position: absolute; left: 0; top: 0; width: 68px; height: 10px;
border-radius: $r-pill; background: $color;
}
.pb__score {
position: absolute; left: $sp-2; top: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); color: $subject-cream;
z-index: 1;
}
.pb__scoreLoop { position: absolute; inset: 0; display: grid; visibility: hidden; }
.pb__scoreLoop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
.pb__bar { display: flex; align-items: center; justify-content: flex-end; gap: $sp-2; flex-wrap: wrap; }
.pb__launch, .pb__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $color; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.pb__launch .pb__ico, .pb__reset .pb__ico { color: $subject-ink; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 공 경로: 패들 접촉(0%) → 벽돌 3회 섬광 → 벽 2회 → 패들 접촉 2회 → 하강(94%) → 같은 접촉점(100%).
// 100% 와 0% 가 같은 점이라 경계가 닫혀 있고, 그 모서리는 패들 튕김으로 읽힌다.
.pb.is-demo .pb__ball {
animation-name: pbBall; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes pbBall {
0% { transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 158px)); }
9% { transform: translate(calc(var(--kx) * 318px), calc(var(--ky) * 40px)); }
17% { transform: translate(calc(var(--kx) * 398px), calc(var(--ky) * 116px)); }
25% { transform: translate(calc(var(--kx) * 306px), calc(var(--ky) * 158px)); }
33% { transform: translate(calc(var(--kx) * 252px), calc(var(--ky) * 48px)); }
41% { transform: translate(calc(var(--kx) * 128px), calc(var(--ky) * 52px)); }
49% { transform: translate(calc(var(--kx) * 24px), calc(var(--ky) * 116px)); }
57% { transform: translate(calc(var(--kx) * 110px), calc(var(--ky) * 158px)); }
65% { transform: translate(calc(var(--kx) * 196px), calc(var(--ky) * 100px)); }
73% { transform: translate(calc(var(--kx) * 280px), calc(var(--ky) * 20px)); }
81% { transform: translate(calc(var(--kx) * 398px), calc(var(--ky) * 84px)); }
89% { transform: translate(calc(var(--kx) * 330px), calc(var(--ky) * 130px)); }
94% { transform: translate(calc(var(--kx) * 160px), calc(var(--ky) * 134px)); }
100% { transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 158px)); }
}
// 패들 추적 — 공이 패들 선에 닿는 순간(0/25/57/96%) 의 x 로 맞춰 간다
.pb.is-demo .pb__paddle {
animation-name: pbPaddle; animation-duration: $duration; animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes pbPaddle {
0% { transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px)); }
9% { transform: translate(calc(var(--kx) * 284px), calc(var(--ky) * 158px)); }
25% { transform: translate(calc(var(--kx) * 272px), calc(var(--ky) * 158px)); }
41% { transform: translate(calc(var(--kx) * 94px), calc(var(--ky) * 158px)); }
57% { transform: translate(calc(var(--kx) * 76px), calc(var(--ky) * 158px)); }
73% { transform: translate(calc(var(--kx) * 246px), calc(var(--ky) * 158px)); }
89% { transform: translate(calc(var(--kx) * 296px), calc(var(--ky) * 158px)); }
96% { transform: translate(calc(var(--kx) * 136px), calc(var(--ky) * 158px)); }
100% { transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px)); }
}
// 벽돌 섬광 — 공이 닿는 9%(r1c4)·33%(r2c3)·41%(r2c1)·73%(r0c4) 에 반투명 스윕.
// 사라지지 않고 반짝이기만 해서 루프 경계에 되살아나는 순간이동이 없다.
.pb.is-demo .pb__brick--r1c4 { animation: pbFlash $duration linear infinite; animation-delay: 0ms; }
.pb.is-demo .pb__brick--r2c3 { animation: pbFlash $duration linear infinite; animation-delay: 460ms; }
.pb.is-demo .pb__brick--r2c1 { animation: pbFlash $duration linear infinite; animation-delay: 620ms; }
.pb.is-demo .pb__brick--r0c4 { animation: pbFlash $duration linear infinite; animation-delay: 1260ms; }
@keyframes pbFlash {
0%, 7%, 13%, 100% { opacity: 1; }
10% { opacity: .3; }
}
// 점수 교체 — 0→1(9%)→2(33%)→0(96%). steps 컷이라 반투명 겹침이 없다
.pb.is-demo .pb__scoreVal { color: transparent; }
.pb.is-demo .pb__scoreLoop { visibility: visible; }
.pb.is-demo .pb__scoreLoop i {
animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pb.is-demo .pb__scoreLoop i:nth-child(1) { animation-name: pbScore0; }
.pb.is-demo .pb__scoreLoop i:nth-child(2) { animation-name: pbScore1; }
.pb.is-demo .pb__scoreLoop i:nth-child(3) { animation-name: pbScore2; }
@keyframes pbScore0 { 0%, 8% { visibility: visible; } 9%, 95% { visibility: hidden; } 96%, 100% { visibility: visible; } }
@keyframes pbScore1 { 0%, 8% { visibility: hidden; } 9%, 32% { visibility: visible; } 33%, 100% { visibility: hidden; } }
@keyframes pbScore2 { 0%, 32% { visibility: hidden; } 33%, 95% { visibility: visible; } 96%, 100% { visibility: hidden; } }
@media (max-width: 340px) {
.pb { --kx: .68; --ky: .59; gap: $sp-2; }
.pb__field { height: 104px; }
.pb__brick { width: 40px; height: 10px; }
.pb__paddle { width: 52px; height: 8px; }
.pb__ball { width: 10px; height: 10px; }
}
// reduce 에서는 패들 위에 공이 얹힌 정지 상태 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.pb.is-demo .pb__ball,
.pb.is-demo .pb__paddle,
.pb.is-demo .pb__brick,
.pb.is-demo .pb__scoreLoop i { animation: none !important; }
.pb.is-demo .pb__ball { transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 144px)) !important; }
.pb.is-demo .pb__paddle { transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px)) !important; }
.pb.is-demo .pb__scoreVal { color: $subject-cream !important; }
.pb.is-demo .pb__scoreLoop { visibility: hidden !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pb {
--kx: 1;
--ky: 1;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.pb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.pb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.pb__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
overflow: hidden;
touch-action: none;
cursor: none;
}
.pb__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pb__bricks {
position: absolute;
inset: 0;
}
.pb__brick {
position: absolute;
left: 0;
top: 0;
width: 60px;
height: 14px;
border-radius: 4px;
}
.pb__brick--r0 {
background: #ff4d1f;
}
.pb__brick--r1 {
background: #ffd23f;
}
.pb__brick--r2 {
background: #fff7e6;
}
.pb__brick.is-hit {
visibility: hidden;
}
.pb__ball {
position: absolute;
left: 0;
top: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
}
.pb__paddle {
position: absolute;
left: 0;
top: 0;
width: 68px;
height: 10px;
border-radius: 999px;
background: #ffd23f;
}
.pb__score {
position: absolute;
left: 8px;
top: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
color: #fff7e6;
z-index: 1;
}
.pb__scoreLoop {
position: absolute;
inset: 0;
display: grid;
visibility: hidden;
}
.pb__scoreLoop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.pb__bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.pb__launch, .pb__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.pb__launch .pb__ico, .pb__reset .pb__ico {
color: #17141a;
}
.pb.is-demo .pb__ball {
animation-name: pbBall;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes pbBall {
0% {
transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 158px));
}
9% {
transform: translate(calc(var(--kx) * 318px), calc(var(--ky) * 40px));
}
17% {
transform: translate(calc(var(--kx) * 398px), calc(var(--ky) * 116px));
}
25% {
transform: translate(calc(var(--kx) * 306px), calc(var(--ky) * 158px));
}
33% {
transform: translate(calc(var(--kx) * 252px), calc(var(--ky) * 48px));
}
41% {
transform: translate(calc(var(--kx) * 128px), calc(var(--ky) * 52px));
}
49% {
transform: translate(calc(var(--kx) * 24px), calc(var(--ky) * 116px));
}
57% {
transform: translate(calc(var(--kx) * 110px), calc(var(--ky) * 158px));
}
65% {
transform: translate(calc(var(--kx) * 196px), calc(var(--ky) * 100px));
}
73% {
transform: translate(calc(var(--kx) * 280px), calc(var(--ky) * 20px));
}
81% {
transform: translate(calc(var(--kx) * 398px), calc(var(--ky) * 84px));
}
89% {
transform: translate(calc(var(--kx) * 330px), calc(var(--ky) * 130px));
}
94% {
transform: translate(calc(var(--kx) * 160px), calc(var(--ky) * 134px));
}
100% {
transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 158px));
}
}
.pb.is-demo .pb__paddle {
animation-name: pbPaddle;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes pbPaddle {
0% {
transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px));
}
9% {
transform: translate(calc(var(--kx) * 284px), calc(var(--ky) * 158px));
}
25% {
transform: translate(calc(var(--kx) * 272px), calc(var(--ky) * 158px));
}
41% {
transform: translate(calc(var(--kx) * 94px), calc(var(--ky) * 158px));
}
57% {
transform: translate(calc(var(--kx) * 76px), calc(var(--ky) * 158px));
}
73% {
transform: translate(calc(var(--kx) * 246px), calc(var(--ky) * 158px));
}
89% {
transform: translate(calc(var(--kx) * 296px), calc(var(--ky) * 158px));
}
96% {
transform: translate(calc(var(--kx) * 136px), calc(var(--ky) * 158px));
}
100% {
transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px));
}
}
.pb.is-demo .pb__brick--r1c4 {
animation: pbFlash 2s linear infinite;
animation-delay: 0ms;
}
.pb.is-demo .pb__brick--r2c3 {
animation: pbFlash 2s linear infinite;
animation-delay: 460ms;
}
.pb.is-demo .pb__brick--r2c1 {
animation: pbFlash 2s linear infinite;
animation-delay: 620ms;
}
.pb.is-demo .pb__brick--r0c4 {
animation: pbFlash 2s linear infinite;
animation-delay: 1260ms;
}
@keyframes pbFlash {
0%, 7%, 13%, 100% {
opacity: 1;
}
10% {
opacity: 0.3;
}
}
.pb.is-demo .pb__scoreVal {
color: transparent;
}
.pb.is-demo .pb__scoreLoop {
visibility: visible;
}
.pb.is-demo .pb__scoreLoop i {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pb.is-demo .pb__scoreLoop i:nth-child(1) {
animation-name: pbScore0;
}
.pb.is-demo .pb__scoreLoop i:nth-child(2) {
animation-name: pbScore1;
}
.pb.is-demo .pb__scoreLoop i:nth-child(3) {
animation-name: pbScore2;
}
@keyframes pbScore0 {
0%, 8% {
visibility: visible;
}
9%, 95% {
visibility: hidden;
}
96%, 100% {
visibility: visible;
}
}
@keyframes pbScore1 {
0%, 8% {
visibility: hidden;
}
9%, 32% {
visibility: visible;
}
33%, 100% {
visibility: hidden;
}
}
@keyframes pbScore2 {
0%, 32% {
visibility: hidden;
}
33%, 95% {
visibility: visible;
}
96%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.pb {
--kx: .68;
--ky: .59;
gap: 8px;
}
.pb__field {
height: 104px;
}
.pb__brick {
width: 40px;
height: 10px;
}
.pb__paddle {
width: 52px;
height: 8px;
}
.pb__ball {
width: 10px;
height: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.pb.is-demo .pb__ball,
.pb.is-demo .pb__paddle,
.pb.is-demo .pb__brick,
.pb.is-demo .pb__scoreLoop i {
animation: none !important;
}
.pb.is-demo .pb__ball {
transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 144px)) !important;
}
.pb.is-demo .pb__paddle {
transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px)) !important;
}
.pb.is-demo .pb__scoreVal {
color: #fff7e6 !important;
}
.pb.is-demo .pb__scoreLoop {
visibility: hidden !important;
}
}var root = document.getElementById('pb');
var field = document.getElementById('field');
var bricksBox = document.getElementById('bricks');
var ball = document.getElementById('ball');
var paddle = document.getElementById('paddle');
var scoreText = document.getElementById('scoreText');
var SCORE_LABEL = 'スコア';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 벽돌 3×6 — 左 여백 22, 위 여백 24, 칸 60×14, 간격 4. 행 색은 CSS 클래스가 정한다.
// 격자의 원점은 여기 한 곳뿐이다 — 그리는 자리(laid)와 충돌 판정(step)이 같은 BX·BY 를 본다.
// 두 곳에 따로 적으면 위 여백만 옮기는 순간 공이 보이지 않는 띠에서 튕긴다
var COLS = 6, ROWS = 3, BX = 22, BY = 24, BW = 60, BH = 14, GAP = 4;
var bricks = [];
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var el = document.createElement('i');
el.className = 'pb__brick pb__brick--r' + r + ' pb__brick--r' + r + 'c' + c;
el.style.transform = 'translate(0, 0)'; // 실제 위치는 laid() 가 판 폭에 맞춰 넣는다
bricksBox.appendChild(el);
bricks.push({ el: el, r: r, c: c, alive: true });
}
}
var PW = 68, PH = 10, SPEED = 240;
function padY() { return field.clientHeight - 18; }
var px = 176, bx = 210, by = 150, vx = SPEED * 0.66, vy = -SPEED * 0.75;
var stuck = true, score = 0, last = 0, running = false;
function scaled(n) { return n * field.clientWidth / 424; }
function laid() {
for (var i = 0; i < bricks.length; i++) {
var b = bricks[i];
b.el.style.transform = 'translate(' + scaled(BX + b.c * (BW + GAP)) + 'px, ' + (BY + b.r * (BH + GAP)) + 'px)';
}
}
function paint() {
paddle.style.transform = 'translate(' + px + 'px, ' + padY() + 'px)';
ball.style.transform = 'translate(' + bx + 'px, ' + by + 'px)';
}
function paintScore() {
// 라벨을 낱말로 떼어 둬야 문구 사전(demo/문구.json)이 en·ja 로 바꿔 준다 — 붙여 쓴 'スコア ' 는 사전 키가 못 된다
scoreText.textContent = SCORE_LABEL + ' ' + score;
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var w = field.clientWidth, h = field.clientHeight;
if (stuck) { bx = px + PW / 2 - 6; by = padY() - 14; paint(); requestAnimationFrame(step); return; }
bx += vx * dt; by += vy * dt;
// 벽 반사 — 좌우와 천장
if (bx < 0) { bx = 0; vx = Math.abs(vx); }
if (bx > w - 12) { bx = w - 12; vx = -Math.abs(vx); }
if (by < 0) { by = 0; vy = Math.abs(vy); }
// 패들 반사 — 맞은 칸에 따라 수평 성분이 꺾인다 (법선 반사의 게임판 버전)
var pyNow = padY();
if (vy > 0 && by + 12 >= pyNow && by + 12 <= pyNow + PH && bx + 12 > px && bx < px + PW) {
var off = (bx + 6 - (px + PW / 2)) / (PW / 2);
vx = SPEED * off * 0.9;
vy = -Math.abs(vy);
by = pyNow - 12;
}
// 벽돌 충돌 — 겹친 칸을 끄고 뚫은 변으로 반사한다
for (var i = 0; i < bricks.length; i++) {
var b = bricks[i];
if (!b.alive) { continue; }
var bxx = scaled(BX + b.c * (BW + GAP)), byy = BY + b.r * (BH + GAP);
if (bx + 12 > bxx && bx < bxx + scaled(BW) && by + 12 > byy && by < byy + BH) {
b.alive = false;
b.el.classList.add('is-hit');
score += 1; paintScore();
var overX = bx + 6 - (bxx + scaled(BW) / 2), overY = by + 6 - (byy + BH / 2);
if (Math.abs(overX) > Math.abs(overY)) { vx = overX > 0 ? Math.abs(vx) : -Math.abs(vx); }
else { vy = overY > 0 ? Math.abs(vy) : -Math.abs(vy); }
break;
}
}
// 공을 놓쳤으면 패들 위에 다시 얹는다
if (by > h + 8) { stuck = true; bx = px + PW / 2 - 6; by = padY() - 14; }
paint();
requestAnimationFrame(step);
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) {
px = 176; stuck = true; bx = px + PW / 2 - 6; by = padY() - 14;
paint(); paintScore(); return false;
}
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
return true;
}
function movePaddle(x) {
px = Math.max(0, Math.min(field.clientWidth - PW, x));
if (!running) { wake(); }
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
movePaddle(e.clientX - r.left - PW / 2);
});
field.addEventListener('keydown', function (e) {
var JUMP = 30;
if (e.key === 'ArrowLeft') { movePaddle(px - JUMP); }
else if (e.key === 'ArrowRight') { movePaddle(px + JUMP); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); launchBall(); }
else { return; }
e.preventDefault();
});
function launchBall() {
wake();
stuck = false;
vx = SPEED * 0.66; vy = -SPEED * 0.75;
}
document.getElementById('launch').addEventListener('click', launchBall);
document.getElementById('reset').addEventListener('click', function () {
for (var i = 0; i < bricks.length; i++) {
bricks[i].alive = true; bricks[i].el.classList.remove('is-hit');
}
score = 0; paintScore(); stuck = true; px = 176;
wake();
});
laid();
window.addEventListener('resize', laid);
paint(); paintScore();// PaddleBounce — vanilla 판(run/292/demo/05-physics-paddle-bounce/index.html) 의 React 포트.
// 공은 등속으로 날고 벽·패들·벽돌에 부딪히면 뚫은 변의 법선으로 속도 부호를 되돌린다.
// 패들은 맞은 칸(off) 에 따라 수평 성분이 SPEED×off×0.9 로 꺾인다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./PaddleBounce.module.scss";
export type PaddleBounceProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Brick = { el: HTMLElement; r: number; c: number; alive: boolean };
// 벽돌 3×6 — 왼쪽 여백 22, 위 여백 24, 칸 60×14, 간격 4. 행 색은 CSS 클래스가 정한다.
// 격자의 원점은 여기 한 곳뿐이다 — 그리는 자리(laid)와 충돌 판정(step)이 같은 BX·BY 를 본다.
// 두 곳에 따로 적으면 위 여백만 옮기는 순간 공이 보이지 않는 띠에서 튕긴다
const COLS = 6;
const ROWS = 3;
const BX = 22;
const BY = 24;
const BW = 60;
const BH = 14;
const GAP = 4;
const PW = 68;
const PH = 10;
const SPEED = 240;
const JUMP = 30; // 방향키 한 번에 옮기는 패들 거리
const FIELD_LABEL = "벽돌 게임판";
const SCORE_LABEL = "점수"; // 문구 사전이 en·ja 로 바꿔 줄 수 있게 상수 하나로 둔다
const CELLS: { r: number; c: number }[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) CELLS.push({ r: r, c: c });
}
export default function PaddleBounce({
duration = 2000,
easing = "linear",
color = "#ffd23f",
}: PaddleBounceProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const bricksBoxRef = useRef<HTMLDivElement | null>(null);
const ballRef = useRef<HTMLSpanElement | null>(null);
const paddleRef = useRef<HTMLSpanElement | null>(null);
const bricksRef = useRef<Brick[]>([]) as Box<Brick[]>;
const pxRef = useRef<number>(176) as Box<number>;
const bxRef = useRef<number>(210) as Box<number>;
const byRef = useRef<number>(150) as Box<number>;
const vxRef = useRef<number>(SPEED * 0.66) as Box<number>;
const vyRef = useRef<number>(-SPEED * 0.75) as Box<number>;
const stuckRef = useRef<boolean>(true) as Box<boolean>;
const scoreRef = useRef<number>(0) as Box<number>;
const lastRef = useRef<number>(0) as Box<number>;
const rafRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const [score, setScore] = useState(0);
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 padY(): number {
const field = fieldRef.current;
return (field ? field.clientHeight : 176) - 18;
}
function scaled(n: number): number {
const field = fieldRef.current;
return field ? (n * field.clientWidth) / 424 : n;
}
function laid(): void {
const bricks = bricksRef.current;
for (let i = 0; i < bricks.length; i++) {
const b = bricks[i];
b.el.style.transform = "translate(" + scaled(BX + b.c * (BW + GAP)) + "px, " + (BY + b.r * (BH + GAP)) + "px)";
}
}
function paint(): void {
const paddle = paddleRef.current;
const ball = ballRef.current;
if (paddle) paddle.style.transform = "translate(" + pxRef.current + "px, " + padY() + "px)";
if (ball) ball.style.transform = "translate(" + bxRef.current + "px, " + byRef.current + "px)";
}
function step(now: number): void {
const field = fieldRef.current;
if (!field) { runningRef.current = false; return; }
const dt = Math.min((now - lastRef.current) / 1000, 0.033);
lastRef.current = now;
const w = field.clientWidth;
const h = field.clientHeight;
if (stuckRef.current) {
bxRef.current = pxRef.current + PW / 2 - 6;
byRef.current = padY() - 14;
paint();
rafRef.current = requestAnimationFrame(step);
return;
}
bxRef.current += vxRef.current * dt;
byRef.current += vyRef.current * dt;
// 벽 반사 — 좌우와 천장
if (bxRef.current < 0) { bxRef.current = 0; vxRef.current = Math.abs(vxRef.current); }
if (bxRef.current > w - 12) { bxRef.current = w - 12; vxRef.current = -Math.abs(vxRef.current); }
if (byRef.current < 0) { byRef.current = 0; vyRef.current = Math.abs(vyRef.current); }
// 패들 반사 — 맞은 칸에 따라 수평 성분이 꺾인다 (법선 반사의 게임판 버전)
const pyNow = padY();
if (
vyRef.current > 0 && byRef.current + 12 >= pyNow && byRef.current + 12 <= pyNow + PH &&
bxRef.current + 12 > pxRef.current && bxRef.current < pxRef.current + PW
) {
const off = (bxRef.current + 6 - (pxRef.current + PW / 2)) / (PW / 2);
vxRef.current = SPEED * off * 0.9;
vyRef.current = -Math.abs(vyRef.current);
byRef.current = pyNow - 12;
}
// 벽돌 충돌 — 겹친 칸을 끄고 뚫은 변으로 반사한다
const bricks = bricksRef.current;
for (let i = 0; i < bricks.length; i++) {
const b = bricks[i];
if (!b.alive) continue;
const bxx = scaled(BX + b.c * (BW + GAP));
const byy = BY + b.r * (BH + GAP);
if (bxRef.current + 12 > bxx && bxRef.current < bxx + scaled(BW) && byRef.current + 12 > byy && byRef.current < byy + BH) {
b.alive = false;
b.el.classList.add(styles.isHit);
scoreRef.current += 1;
setScore(scoreRef.current);
const overX = bxRef.current + 6 - (bxx + scaled(BW) / 2);
const overY = byRef.current + 6 - (byy + BH / 2);
if (Math.abs(overX) > Math.abs(overY)) { vxRef.current = overX > 0 ? Math.abs(vxRef.current) : -Math.abs(vxRef.current); }
else { vyRef.current = overY > 0 ? Math.abs(vyRef.current) : -Math.abs(vyRef.current); }
break;
}
}
// 공을 놓쳤으면 패들 위에 다시 얹는다
if (byRef.current > h + 8) {
stuckRef.current = true;
bxRef.current = pxRef.current + PW / 2 - 6;
byRef.current = padY() - 14;
}
paint();
rafRef.current = requestAnimationFrame(step);
}
function wake(): void {
setDemo(false);
if (reduced()) {
pxRef.current = 176;
stuckRef.current = true;
bxRef.current = pxRef.current + PW / 2 - 6;
byRef.current = padY() - 14;
paint();
return;
}
if (!runningRef.current) {
runningRef.current = true;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
}
function movePaddle(x: number): void {
const field = fieldRef.current;
if (!field) return;
pxRef.current = Math.max(0, Math.min(field.clientWidth - PW, x));
if (!runningRef.current) wake();
}
function launchBall(): void {
wake();
stuckRef.current = false;
vxRef.current = SPEED * 0.66;
vyRef.current = -SPEED * 0.75;
}
function restart(): void {
const bricks = bricksRef.current;
for (let i = 0; i < bricks.length; i++) {
bricks[i].alive = true;
bricks[i].el.classList.remove(styles.isHit);
}
scoreRef.current = 0;
setScore(0);
stuckRef.current = true;
pxRef.current = 176;
wake();
}
useEffect(() => {
const box = bricksBoxRef.current;
if (box) {
const nodes = box.querySelectorAll("i");
const built: Brick[] = [];
for (let n = 0; n < nodes.length; n++) {
built.push({ el: nodes[n], r: CELLS[n].r, c: CELLS[n].c, alive: true });
}
bricksRef.current = built;
}
laid();
paint();
function onResize(): void { laid(); }
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
movePaddle(event.clientX - r.left - PW / 2);
}
function onKeyDown(event: KeyEvt): void {
if (event.key === "ArrowLeft") movePaddle(pxRef.current - JUMP);
else if (event.key === "ArrowRight") movePaddle(pxRef.current + JUMP);
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); launchBall(); }
else return;
event.preventDefault();
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
function brickCls(r: number, c: number): string {
const spot = styles["brickR" + r + "C" + c] || "";
return [styles.brick, styles["brickR" + r] || "", spot].filter(Boolean).join(" ");
}
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-ball" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88"/></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>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-ball" /></svg>
<span>패들이 마우스를 따라오고 공은 벽돌에 부딪힌 만큼 점수가 오릅니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onKeyDown={onKeyDown}
>
<p className={styles.score} aria-live="polite">
<span className={styles.scoreVal}>{SCORE_LABEL} {score}</span>
<span className={styles.scoreLoop} aria-hidden="true"><i>점수 0</i><i>점수 1</i><i>점수 2</i></span>
</p>
<div className={styles.bricks} ref={bricksBoxRef} aria-hidden="true">
{CELLS.map((cell) => (
<i key={`${cell.r}-${cell.c}`} className={brickCls(cell.r, cell.c)} />
))}
</div>
<span className={styles.ball} ref={ballRef} aria-hidden="true"></span>
<span className={styles.paddle} ref={paddleRef} aria-hidden="true"></span>
</div>
<div className={styles.bar}>
<button className={styles.launch} type="button" onClick={launchBall}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-ball" /></svg><span>공 놓기</span>
</button>
<button className={styles.reset} type="button" onClick={restart}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-reset" /></svg><span>처음으로</span>
</button>
</div>
</div>
</main>
);
}05 · パドルバウンド — 計測 累積面積 6.159% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
プレビューは累積面積 6.2%・移動フレーム 23/23 です。
// パドル反射 — 当たった位置で水平成分が折れる (法線反射のゲーム盤版)
var pyNow = padY();
if (vy > 0 && by + 12 >= pyNow && by + 12 <= pyNow + PH && bx + 12 > px && bx < px + PW) {
var off = (bx + 6 - (px + PW / 2)) / (PW / 2);
vx = SPEED * off * 0.9;
vy = -Math.abs(vy);
by = pyNow - 12;
}
// レンガ衝突 — 重なったマスを消し、抜けた辺の側へ反射する
for (var i = 0; i < bricks.length; i++) {
var b = bricks[i];
if (!b.alive) { continue; }
var bxx = scaled(BX + b.c * (BW + GAP)), byy = BY + b.r * (BH + GAP);
if (bx + 12 > bxx && bx < bxx + scaled(BW) && by + 12 > byy && by < byy + BH) {
b.alive = false;
b.el.classList.add('is-hit');
score += 1; paintScore();
var overX = bx + 6 - (bxx + scaled(BW) / 2), overY = by + 6 - (byy + BH / 2);
if (Math.abs(overX) > Math.abs(overY)) { vx = overX > 0 ? Math.abs(vx) : -Math.abs(vx); }
else { vy = overY > 0 ? Math.abs(vy) : -Math.abs(vy); }
break;
}
}
.pb__brick--r0 { background: $stage-orange; }
.pb__brick--r1 { background: $color; }
.pb__brick--r2 { background: $subject-cream; }
.pb__brick.is-hit { visibility: hidden; }
06布の揺れ
どんな動きか
12×5の点の格子は上端の1行が固定点で、カーソルから半径 36px 以内に入った点だけが押されます。押された点は隣の点の変位を少し追うので波が横へ広がり、定位置のばねと減衰が元の並びに戻します。

<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 に同梱 · パスワードを見る
どこを変えるか
プレビューは累積面積 12.9%・移動フレーム 23/23 でした。
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; }
}
07フローフィールド
何が見えるか
カーソルが動いた向きがそのまま風になり、36本のダッシュがそろって同じ方向へ流れます。速さは 190 で頭打ちになり、向きは Math.atan2 の値を rotate に渡しているので、粒は進む方向へ傾きます。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-wind" viewBox="0 0 256 256" fill="currentColor"><g><path d="M120 96a24 24 0 1 1 24-24a24 24 0 0 1-24 24m88-16a24 24 0 1 0 24 24a24 24 0 0 0-24-24m-56 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></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="ff is-demo" id="ff">
<p class="ff__hint"><svg class="ff__ico" aria-hidden="true"><use href="#ico-wind"/></svg><span>マウスを動かした方向が風になり、ダッシュの群れがその方へ流れます</span></p>
<div class="ff__field" id="field" role="application" aria-label="フローフィールドの実験台" tabindex="0">
<span class="ff__parts" id="parts" aria-hidden="true"></span>
</div>
<div class="ff__bar">
<p class="ff__dir"><span class="ff__dirLabel">今の風</span><b class="ff__dirBox"><span class="ff__dirVal" id="dirText">凪</span><span class="ff__dirLoop" aria-hidden="true"><i>右</i><i>左</i></span></b></p>
<button class="ff__reset" id="reset" type="button">
<svg class="ff__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>散らす</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 플로우 필드 — 커서의 이동 속도가 곧 바람 벡터. 모든 대시가 그 벡터로 조타(steer)되고
// 가장자리는 반대편으로 이어진다. 대시는 진행 방향으로 rotate 까지 맞춰 돈다.
// 격자 미리보기(.is-demo)는 결정적 난수로 정한 중심(var(--hx), var(--hy)) 을 도는
// 닫힌 타원 궤도를 키프레임으로 재생한다 — 궤도가 닫혀 있어 경계 순간이동이 없다.
$duration: $dur-loop;
$easing: linear; // 궤도 속도는 키프레임 표본 비율에 들어 있다
$color: $stage-yellow;
@include stage($stage-yellow);
.ff { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.ff__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.ff__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-orange; }
.ff__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($stage-paper, .42);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .14);
overflow: hidden; touch-action: none;
}
.ff__field:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ff__part {
position: absolute; left: 0; top: 0; width: 14px; height: 4px;
border-radius: $r-pill; background: $subject-ink;
}
.ff__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.ff__dir { margin: 0; display: flex; align-items: center; gap: $sp-1; }
.ff__dirLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: $subject-ink; }
.ff__dirBox {
position: relative; display: grid; place-items: center;
min-width: 52px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba($subject-ink, .12); color: $subject-ink;
font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.ff__dirLoop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.ff__dirLoop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
.ff__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $subject-ink; color: $color; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.ff__reset .ff__ico { color: $color; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 대시마다 타원 한 바퀴(2s) — 진행 방향 rotate 가 붙어 흐르는 결처럼 보인다.
// 위상은 -i * 56ms 로 흩어 떼가 한꺼번에 도는 게 아니라 출렁이며 흐른다 (음수 지연이라 닫혀 있다).
.ff.is-demo .ff__part {
animation-name: ffOrbit; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -56ms);
}
@keyframes ffOrbit {
0% { transform: translate(var(--hx), var(--hy)) rotate(0deg); }
25% { transform: translate(calc(var(--hx) + var(--rx)), calc(var(--hy) - var(--ry))) rotate(90deg); }
50% { transform: translate(var(--hx), calc(var(--hy) - var(--ry) * 2)) rotate(180deg); }
75% { transform: translate(calc(var(--hx) - var(--rx)), calc(var(--hy) - var(--ry))) rotate(270deg); }
100% { transform: translate(var(--hx), var(--hy)) rotate(360deg); }
}
// 바람 방향 표시 — 오른쪽/왼쪽이 1초씩 steps 컷으로 바뀐다
.ff.is-demo .ff__dirVal { color: transparent; }
.ff.is-demo .ff__dirLoop { visibility: visible; }
.ff.is-demo .ff__dirLoop i {
animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ff.is-demo .ff__dirLoop i:nth-child(1) { animation-name: ffDirR; }
.ff.is-demo .ff__dirLoop i:nth-child(2) { animation-name: ffDirL; }
@keyframes ffDirR { 0%, 49% { visibility: visible; } 50%, 100% { visibility: hidden; } }
@keyframes ffDirL { 0%, 49% { visibility: hidden; } 50%, 100% { visibility: visible; } }
@media (max-width: 340px) {
.ff { gap: $sp-2; }
.ff__field { height: 104px; }
.ff__part { width: 11px; height: 3px; }
.ff__dirBox { min-width: 44px; }
}
// reduce 에서는 흩어진 대시가 제자리에 멈춘 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.ff.is-demo .ff__part,
.ff.is-demo .ff__dirLoop i { animation: none !important; }
.ff.is-demo .ff__part { transform: translate(var(--hx), var(--hy)) !important; }
.ff.is-demo .ff__dirVal { color: $subject-ink !important; }
.ff.is-demo .ff__dirLoop { visibility: hidden !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ff {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.ff__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.ff__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff4d1f;
}
.ff__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.42);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.14);
overflow: hidden;
touch-action: none;
}
.ff__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ff__part {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 4px;
border-radius: 999px;
background: #17141a;
}
.ff__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.ff__dir {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
}
.ff__dirLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: #17141a;
}
.ff__dirBox {
position: relative;
display: grid;
place-items: center;
min-width: 52px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.ff__dirLoop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.ff__dirLoop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.ff__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
color: #ffd23f;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.ff__reset .ff__ico {
color: #ffd23f;
}
.ff.is-demo .ff__part {
animation-name: ffOrbit;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -56ms);
}
@keyframes ffOrbit {
0% {
transform: translate(var(--hx), var(--hy)) rotate(0deg);
}
25% {
transform: translate(calc(var(--hx) + var(--rx)), calc(var(--hy) - var(--ry))) rotate(90deg);
}
50% {
transform: translate(var(--hx), calc(var(--hy) - var(--ry) * 2)) rotate(180deg);
}
75% {
transform: translate(calc(var(--hx) - var(--rx)), calc(var(--hy) - var(--ry))) rotate(270deg);
}
100% {
transform: translate(var(--hx), var(--hy)) rotate(360deg);
}
}
.ff.is-demo .ff__dirVal {
color: transparent;
}
.ff.is-demo .ff__dirLoop {
visibility: visible;
}
.ff.is-demo .ff__dirLoop i {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ff.is-demo .ff__dirLoop i:nth-child(1) {
animation-name: ffDirR;
}
.ff.is-demo .ff__dirLoop i:nth-child(2) {
animation-name: ffDirL;
}
@keyframes ffDirR {
0%, 49% {
visibility: visible;
}
50%, 100% {
visibility: hidden;
}
}
@keyframes ffDirL {
0%, 49% {
visibility: hidden;
}
50%, 100% {
visibility: visible;
}
}
@media (max-width: 340px) {
.ff {
gap: 8px;
}
.ff__field {
height: 104px;
}
.ff__part {
width: 11px;
height: 3px;
}
.ff__dirBox {
min-width: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.ff.is-demo .ff__part,
.ff.is-demo .ff__dirLoop i {
animation: none !important;
}
.ff.is-demo .ff__part {
transform: translate(var(--hx), var(--hy)) !important;
}
.ff.is-demo .ff__dirVal {
color: #17141a !important;
}
.ff.is-demo .ff__dirLoop {
visibility: hidden !important;
}
}var root = document.getElementById('ff');
var field = document.getElementById('field');
var partsBox = document.getElementById('parts');
var dirText = document.getElementById('dirText');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 대시 36개 — 결정적 난수(사인 곱셈법)로 뿌린 자리가 시작점이다. 시드가 고정되어 매번 같은 배치로 재현된다
var N = 36, parts = [];
function rnd(seed) { var x = Math.sin(seed * 127.1) * 43758.5453; return x - Math.floor(x); }
function scatter() {
var w = field.clientWidth, h = field.clientHeight;
if (!parts.length) {
for (var i = 0; i < N; i++) {
var el = document.createElement('i');
el.className = 'ff__part';
el.style.setProperty('--i', String(i));
partsBox.appendChild(el);
parts.push({ el: el, x: 0, y: 0, vx: 12, vy: 0, ang: 0, seed: i + 1 });
}
}
for (var j = 0; j < parts.length; j++) {
var p = parts[j];
p.x = (0.06 + rnd(p.seed) * 0.88) * w;
p.y = (0.1 + rnd(p.seed + 60) * 0.8) * h;
p.vx = 12; p.vy = 0; p.ang = 0;
// 미리보기 궤도의 중심과 반지름 — 결정적 난수라 같은 배치가 매번 나온다
p.el.style.setProperty('--hx', p.x.toFixed(1) + 'px');
p.el.style.setProperty('--hy', p.y.toFixed(1) + 'px');
p.el.style.setProperty('--rx', (10 + rnd(p.seed + 7) * 26).toFixed(1) + 'px');
p.el.style.setProperty('--ry', (6 + rnd(p.seed + 13) * 16).toFixed(1) + 'px');
}
}
var wind = { x: 12, y: 0 }, windUntil = 0, last = 0, running = false;
var DIRS = { e: '右', w: '左', s: '下', n: '上', calm: '凪' };
function paint() {
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
p.el.style.transform = 'translate(' + p.x.toFixed(1) + 'px, ' + p.y.toFixed(1) + 'px) rotate(' + p.ang.toFixed(1) + 'deg)';
}
}
function dirName() {
var ax = Math.abs(wind.x), ay = Math.abs(wind.y);
if (ax < 8 && ay < 8) { return DIRS.calm; }
if (ax > ay * 1.2) { return wind.x > 0 ? DIRS.e : DIRS.w; }
if (ay > ax * 1.2) { return wind.y > 0 ? DIRS.s : DIRS.n; }
return wind.x > 0 ? DIRS.e : DIRS.w;
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) { paint(); return false; }
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 w = field.clientWidth, h = field.clientHeight;
var gust = now - windUntil < 900; // 최근 입력 바람이 살아 있는 동안 조타력이 세다
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
// 흐름장: 모든 대시가 같은 방향으로 조타된다 — 힘은 커서 속도에서 온다
p.vx += (wind.x - p.vx) * (gust ? 2.4 : 0.4) * dt;
p.vy += (wind.y - p.vy) * (gust ? 2.4 : 0.4) * dt;
var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (sp > 190) { p.vx *= 190 / sp; p.vy *= 190 / sp; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI;
// 가장자리에서 반대편으로 돌아온다 — 흐름장의 공간이 잇달아 이어진 셈이다
if (p.x > w + 8) { p.x = -8; } else if (p.x < -8) { p.x = w + 8; }
if (p.y > h + 8) { p.y = -8; } else if (p.y < -8) { p.y = h + 8; }
}
var name = dirName();
if (dirText.textContent !== name) { dirText.textContent = name; }
paint();
requestAnimationFrame(step);
}
var prev = null;
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
if (prev) {
var dx = x - prev.x, dy = y - prev.y;
if (Math.abs(dx) + Math.abs(dy) > 2) {
wind.x = dx * 9; wind.y = dy * 9;
windUntil = performance.now();
wake();
}
}
prev = { x: x, y: y };
});
field.addEventListener('keydown', function (e) {
var BLOW = 120;
if (e.key === 'ArrowLeft') { wind.x = -BLOW; wind.y = 0; }
else if (e.key === 'ArrowRight') { wind.x = BLOW; wind.y = 0; }
else if (e.key === 'ArrowUp') { wind.x = 0; wind.y = -BLOW; }
else if (e.key === 'ArrowDown') { wind.x = 0; wind.y = BLOW; }
else { return; }
e.preventDefault();
windUntil = performance.now();
wake();
});
document.getElementById('reset').addEventListener('click', function () {
wind.x = 12; wind.y = 0;
scatter(); paint();
});
window.addEventListener('resize', function () { scatter(); paint(); });
scatter(); paint();// FlowField — vanilla 판(run/292/demo/07-physics-flow-field/index.html) 의 React 포트.
// 커서의 이동 속도가 곧 바람 벡터다. 대시 36개가 모두 그 벡터로 조타(steer)되고 최고 속도는 190,
// 가장자리에서는 반대편으로 이어진다. 진행 방향으로 rotate 까지 맞춘다. 상수·식은 vanilla 와 같은 값이다.
// 자리는 결정적 난수(사인 곱셈법)로 뿌리므로 매번 같은 배치가 나온다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./FlowField.module.scss";
export type FlowFieldProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PartVars = CSSProperties & { "--i"?: string; "--hx"?: string; "--hy"?: string; "--rx"?: string; "--ry"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Part = { x: number; y: number; vx: number; vy: number; ang: number; seed: number };
type Seed = { hx: number; hy: number; rx: number; ry: number };
const N = 36;
const BLOW = 120; // 키보드 한 번의 바람 세기
const HINT = "마우스를 움직인 방향이 바람이 되어 대시 떼가 그 쪽으로 흘러갑니다";
const FIELD_LABEL = "흐름장 실험판";
const DIR_LABEL = "지금 바람";
const RESET_LABEL = "흩어놓기";
const DIRS = { e: "오른쪽", w: "왼쪽", s: "아래", n: "위", calm: "쉬는 중" };
function rnd(seed: number): number {
const x = Math.sin(seed * 127.1) * 43758.5453;
return x - Math.floor(x);
}
function WindIcon({ className }: { className: string }) {
return (
<svg viewBox="0 0 256 256" className={className} aria-hidden="true" fill="currentColor"><g><path d="M120 96a24 24 0 1 1 24-24a24 24 0 0 1-24 24m88-16a24 24 0 1 0 24 24a24 24 0 0 0-24-24m-56 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></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 FlowField({
duration = 2000,
easing = "linear",
color = "#ffd23f",
}: FlowFieldProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const elsBox = useRef<Array<HTMLElement | null>>([]) as Box<Array<HTMLElement | null>>;
const partsBox = useRef<Part[]>([]) as Box<Part[]>;
const windBox = useRef({ x: 12, y: 0 }) as Box<{ x: number; y: number }>;
const windUntilBox = useRef(0) as Box<number>;
const prevBox = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
const lastBox = useRef(0) as Box<number>;
const runningBox = useRef(false) as Box<boolean>;
const frameBox = useRef(0) as Box<number>;
const [seeds, setSeeds] = useState<Seed[]>([]);
const [dir, setDir] = useState(DIRS.calm);
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;
}
// 대시를 판 안에 결정적 난수로 뿌린다 — 미리보기 궤도의 중심(hx·hy)과 반지름(rx·ry)도 여기서 나온다
function scatter(): void {
const field = fieldRef.current;
if (!field) { return; }
const w = field.clientWidth;
const h = field.clientHeight;
const parts = partsBox.current;
if (!parts.length) {
for (let i = 0; i < N; i++) {
parts.push({ x: 0, y: 0, vx: 12, vy: 0, ang: 0, seed: i + 1 });
}
}
const next: Seed[] = [];
for (let j = 0; j < parts.length; j++) {
const p = parts[j];
p.x = (0.06 + rnd(p.seed) * 0.88) * w;
p.y = (0.1 + rnd(p.seed + 60) * 0.8) * h;
p.vx = 12;
p.vy = 0;
p.ang = 0;
next.push({
hx: p.x,
hy: p.y,
rx: 10 + rnd(p.seed + 7) * 26,
ry: 6 + rnd(p.seed + 13) * 16,
});
}
setSeeds(next);
}
function paint(): void {
const parts = partsBox.current;
const els = elsBox.current;
for (let i = 0; i < parts.length; i++) {
const el = els[i];
if (!el) { continue; }
const p = parts[i];
el.style.transform =
"translate(" + p.x.toFixed(1) + "px, " + p.y.toFixed(1) + "px) rotate(" + p.ang.toFixed(1) + "deg)";
}
}
function dirName(): string {
const wind = windBox.current;
const ax = Math.abs(wind.x);
const ay = Math.abs(wind.y);
if (ax < 8 && ay < 8) { return DIRS.calm; }
if (ax > ay * 1.2) { return wind.x > 0 ? DIRS.e : DIRS.w; }
if (ay > ax * 1.2) { return wind.y > 0 ? DIRS.s : DIRS.n; }
return wind.x > 0 ? DIRS.e : DIRS.w;
}
function wake(): boolean {
setDemo(false);
if (reduced()) { paint(); return false; }
if (!runningBox.current) {
runningBox.current = true;
lastBox.current = performance.now();
frameBox.current = requestAnimationFrame(step);
}
return true;
}
function step(now: number): void {
const field = fieldRef.current;
if (!field) { runningBox.current = false; return; }
const dt = Math.min((now - lastBox.current) / 1000, 0.033);
lastBox.current = now;
const w = field.clientWidth;
const h = field.clientHeight;
const gust = now - windUntilBox.current < 900; // 최근 입력 바람이 살아 있는 동안 조타력이 세다
const parts = partsBox.current;
const wind = windBox.current;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
// 흐름장: 모든 대시가 같은 방향으로 조타된다 — 힘은 커서 속도에서 온다
p.vx += (wind.x - p.vx) * (gust ? 2.4 : 0.4) * dt;
p.vy += (wind.y - p.vy) * (gust ? 2.4 : 0.4) * dt;
const sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (sp > 190) { p.vx *= 190 / sp; p.vy *= 190 / sp; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.ang = (Math.atan2(p.vy, p.vx) * 180) / Math.PI;
// 가장자리에서 반대편으로 돌아온다 — 흐름장의 공간이 잇달아 이어진 셈이다
if (p.x > w + 8) { p.x = -8; } else if (p.x < -8) { p.x = w + 8; }
if (p.y > h + 8) { p.y = -8; } else if (p.y < -8) { p.y = h + 8; }
}
const name = dirName();
setDir(function (prev: string) { return prev === name ? prev : name; });
paint();
frameBox.current = requestAnimationFrame(step);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) { return; }
const r = field.getBoundingClientRect();
const x = event.clientX - r.left;
const y = event.clientY - r.top;
const prev = prevBox.current;
if (prev) {
const dx = x - prev.x;
const dy = y - prev.y;
if (Math.abs(dx) + Math.abs(dy) > 2) {
windBox.current.x = dx * 9;
windBox.current.y = dy * 9;
windUntilBox.current = performance.now();
wake();
}
}
prevBox.current = { x: x, y: y };
}
function onKeyDown(event: KeyEvt): void {
const wind = windBox.current;
if (event.key === "ArrowLeft") { wind.x = -BLOW; wind.y = 0; }
else if (event.key === "ArrowRight") { wind.x = BLOW; wind.y = 0; }
else if (event.key === "ArrowUp") { wind.x = 0; wind.y = -BLOW; }
else if (event.key === "ArrowDown") { wind.x = 0; wind.y = BLOW; }
else { return; }
event.preventDefault();
windUntilBox.current = performance.now();
wake();
}
function onReset(): void {
windBox.current.x = 12;
windBox.current.y = 0;
scatter();
}
useEffect(() => {
scatter();
const onResize = (): void => { scatter(); paint(); };
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(frameBox.current);
};
}, []);
// 대시가 새로 뿌려진 뒤에 현재 자리를 한 번 칠한다
useEffect(() => { paint(); }, [seeds]);
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<p className={styles.hint}><WindIcon className={styles.ico} /><span>{HINT}</span></p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onKeyDown={onKeyDown}
>
<span aria-hidden="true">
{seeds.map((seed, i) => (
<i
key={i}
className={styles.part}
ref={(el: HTMLElement | null) => { elsBox.current[i] = el; }}
style={{
"--i": String(i),
"--hx": seed.hx.toFixed(1) + "px",
"--hy": seed.hy.toFixed(1) + "px",
"--rx": seed.rx.toFixed(1) + "px",
"--ry": seed.ry.toFixed(1) + "px",
} as PartVars}
/>
))}
</span>
</div>
<div className={styles.bar}>
<p className={styles.dir}>
<span className={styles.dirLabel}>{DIR_LABEL}</span>
<b className={styles.dirBox}>
<span className={styles.dirVal}>{dir}</span>
<span className={styles.dirLoop} aria-hidden="true"><i>{DIRS.e}</i><i>{DIRS.w}</i></span>
</b>
</p>
<button className={styles.reset} type="button" onClick={onReset}>
<ResetIcon className={styles.ico} /><span>{RESET_LABEL}</span>
</button>
</div>
</div>
</main>
);
}07 · フローフィールド — 計測 累積面積 19.074% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
プレビューは累積面積 19.1%・移動フレーム 23/23 です。
var gust = now - windUntil < 900; // 直前の入力の風が生きている間は操舵が強い
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
// 流れ場: すべてのダッシュが同じ方向へ操舵される — 力はカーソルの速度から来る
p.vx += (wind.x - p.vx) * (gust ? 2.4 : 0.4) * dt;
p.vy += (wind.y - p.vy) * (gust ? 2.4 : 0.4) * dt;
var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (sp > 190) { p.vx *= 190 / sp; p.vy *= 190 / sp; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI;
// 端から反対側へ戻ってくる — 流れ場の空間がひと続きにつながっている
if (p.x > w + 8) { p.x = -8; } else if (p.x < -8) { p.x = w + 8; }
if (p.y > h + 8) { p.y = -8; } else if (p.y < -8) { p.y = h + 8; }
}
08慣性ボックス
画面で起きること
箱をつかんで振ると、箱の加速度が逆向きの力としてボールに掛かり、壁では 0.7 の反発で跳ね返ります。手を離すと箱だけがばねで中央へ戻り、ボールはその間も転がり続けます。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-shake" viewBox="0 0 256 256" fill="currentColor"><g><path d="m232 128l-32 32H56l-32-32l32-32h144Z" opacity=".2"/><path d="m237.66 133.66l-32 32a8 8 0 0 1-11.32-11.32L212.69 136H43.31l18.35 18.34a8 8 0 0 1-11.32 11.32l-32-32a8 8 0 0 1 0-11.32l32-32a8 8 0 0 1 11.32 11.32L43.31 120h169.38l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32"/></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="ib is-demo" id="ib">
<p class="ib__hint"><svg class="ib__ico" aria-hidden="true"><use href="#ico-shake"/></svg><span>箱をつかんで左右に振ると、箱の加速度が逆向きの力になってボールが転がります</span></p>
<div class="ib__field" id="field" role="application" aria-label="慣性の実験台" tabindex="0">
<div class="ib__box" id="box">
<span class="ib__ball ib__ball--a" aria-hidden="true"></span>
<span class="ib__ball ib__ball--b" aria-hidden="true"></span>
<span class="ib__ball ib__ball--c" aria-hidden="true"></span>
</div>
</div>
<div class="ib__bar">
<button class="ib__reset" id="reset" type="button">
<svg class="ib__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>まっすぐ戻す</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 관성 상자 — 상자를 흔들면(가속 boxAX) 공에게는 -boxAX 방향 힘이 걸리고 벽에서 반발 0.7 로 튕긴다.
// 손을 떼면 상자 자체가 스프링으로 중앙에 정렬된다. 상자와 공 모두 translate 하나로만 움직인다.
// 격자 미리보기(.is-demo)는 감쇠 흔들림(상자)과 위상이 늦게 밀리는 공의 출렁임을 표본 추출한 키프레임.
// 시작과 끝을 모두 정지(0px)에 두어 루프 경계가 없다. 좌표는 --sx/--sy 계수로 좁은 판에 맞춘다.
$duration: $dur-loop;
$easing: ease-in-out;
$color: $stage-orange;
@include stage($stage-ink);
.ib { --sx: 1; --sy: 1; display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.ib__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.ib__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.ib__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($subject-cream, .05);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .12);
display: grid; place-items: center; overflow: hidden; touch-action: none;
}
.ib__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 상자 — 잡고 흔드는 대상. 커서에 잡힌다는 단서로 grab 커서
.ib__box {
position: relative; width: 340px; height: 128px;
border: 2px solid rgba($subject-cream, .8); border-radius: $r-card;
background: rgba($subject-cream, .04); cursor: grab;
}
.ib__box:active { cursor: grabbing; }
.ib__ball { position: absolute; left: 0; top: 0; border-radius: 50%; }
.ib__ball--a { width: 24px; height: 24px; background: $subject-cream; }
.ib__ball--b { width: 18px; height: 18px; background: $color; }
.ib__ball--c { width: 14px; height: 14px; background: $stage-yellow; }
.ib__bar { display: flex; align-items: center; justify-content: flex-end; gap: $sp-2; flex-wrap: wrap; }
.ib__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $subject-cream; color: $stage-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.ib__reset .ib__ico { color: $stage-ink; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 상자: 좌(-34px) → 우(+26px) → 좌(-14px) → 우(+6px) → 정지(86%), 감쇠 흔들림 한 사이클.
// 공: 상자와 반대 위상으로 밀리고(관성) 벽 반발로 되튕긴 뒤 바닥에 굴러 멎는 표본.
.ib.is-demo .ib__box {
animation-name: ibShake; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ibShake {
0% { transform: translate(calc(var(--sx) * 0px), 0); }
12% { transform: translate(calc(var(--sx) * -34px), 0); }
30% { transform: translate(calc(var(--sx) * 26px), 0); }
46% { transform: translate(calc(var(--sx) * -14px), 0); }
60% { transform: translate(calc(var(--sx) * 6px), 0); }
74% { transform: translate(calc(var(--sx) * -2px), 0); }
86%, 100% { transform: translate(calc(var(--sx) * 0px), 0); }
}
.ib.is-demo .ib__ball--a {
animation-name: ibRollA; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ibRollA {
0% { transform: translate(calc(var(--sx) * 40px), calc(var(--sy) * 104px)); }
16% { transform: translate(calc(var(--sx) * 122px), calc(var(--sy) * 104px)); }
32% { transform: translate(calc(var(--sx) * 196px), calc(var(--sy) * 104px)); }
40% { transform: translate(calc(var(--sx) * 314px), calc(var(--sy) * 104px)); }
46% { transform: translate(calc(var(--sx) * 240px), calc(var(--sy) * 104px)); }
60% { transform: translate(calc(var(--sx) * 176px), calc(var(--sy) * 104px)); }
68% { transform: translate(calc(var(--sx) * 64px), calc(var(--sy) * 104px)); }
78% { transform: translate(calc(var(--sx) * 96px), calc(var(--sy) * 104px)); }
86%, 100% { transform: translate(calc(var(--sx) * 88px), calc(var(--sy) * 104px)); }
}
.ib.is-demo .ib__ball--b {
animation-name: ibRollB; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ibRollB {
0% { transform: translate(calc(var(--sx) * 120px), calc(var(--sy) * 110px)); }
14% { transform: translate(calc(var(--sx) * 48px), calc(var(--sy) * 110px)); }
22% { transform: translate(calc(var(--sx) * 6px), calc(var(--sy) * 110px)); }
34% { transform: translate(calc(var(--sx) * 120px), calc(var(--sy) * 110px)); }
48% { transform: translate(calc(var(--sx) * 210px), calc(var(--sy) * 110px)); }
56% { transform: translate(calc(var(--sx) * 318px), calc(var(--sy) * 110px)); }
64% { transform: translate(calc(var(--sx) * 230px), calc(var(--sy) * 110px)); }
78% { transform: translate(calc(var(--sx) * 150px), calc(var(--sy) * 110px)); }
86%, 100% { transform: translate(calc(var(--sx) * 140px), calc(var(--sy) * 110px)); }
}
.ib.is-demo .ib__ball--c {
animation-name: ibRollC; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ibRollC {
0% { transform: translate(calc(var(--sx) * 190px), calc(var(--sy) * 114px)); }
18% { transform: translate(calc(var(--sx) * 260px), calc(var(--sy) * 114px)); }
30% { transform: translate(calc(var(--sx) * 322px), calc(var(--sy) * 114px)); }
44% { transform: translate(calc(var(--sx) * 210px), calc(var(--sy) * 114px)); }
58% { transform: translate(calc(var(--sx) * 120px), calc(var(--sy) * 114px)); }
70% { transform: translate(calc(var(--sx) * 30px), calc(var(--sy) * 114px)); }
80% { transform: translate(calc(var(--sx) * 90px), calc(var(--sy) * 114px)); }
86%, 100% { transform: translate(calc(var(--sx) * 78px), calc(var(--sy) * 114px)); }
}
@media (max-width: 340px) {
.ib { gap: $sp-2; --sx: .68; --sy: .62; }
.ib__field { height: 104px; }
.ib__box { width: 236px; height: 84px; }
.ib__ball--a { width: 18px; height: 18px; }
.ib__ball--b { width: 14px; height: 14px; }
.ib__ball--c { width: 11px; height: 11px; }
}
// reduce 에서는 공이 바닥에 멈춘 정지 상자 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.ib.is-demo .ib__box,
.ib.is-demo .ib__ball--a,
.ib.is-demo .ib__ball--b,
.ib.is-demo .ib__ball--c { animation: none !important; }
.ib.is-demo .ib__box { transform: translate(0, 0) !important; }
.ib.is-demo .ib__ball--a { transform: translate(calc(var(--sx) * 40px), calc(var(--sy) * 104px)) !important; }
.ib.is-demo .ib__ball--b { transform: translate(calc(var(--sx) * 120px), calc(var(--sy) * 110px)) !important; }
.ib.is-demo .ib__ball--c { transform: translate(calc(var(--sx) * 190px), calc(var(--sy) * 114px)) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ib {
--sx: 1;
--sy: 1;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.ib__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.ib__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff4d1f;
}
.ib__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.05);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.12);
display: grid;
place-items: center;
overflow: hidden;
touch-action: none;
}
.ib__field:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.ib__box {
position: relative;
width: 340px;
height: 128px;
border: 2px solid rgba(255, 247, 230, 0.8);
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
cursor: grab;
}
.ib__box:active {
cursor: grabbing;
}
.ib__ball {
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
}
.ib__ball--a {
width: 24px;
height: 24px;
background: #fff7e6;
}
.ib__ball--b {
width: 18px;
height: 18px;
background: #ff4d1f;
}
.ib__ball--c {
width: 14px;
height: 14px;
background: #ffd23f;
}
.ib__bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.ib__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #fff7e6;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.ib__reset .ib__ico {
color: #17141a;
}
.ib.is-demo .ib__box {
animation-name: ibShake;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes ibShake {
0% {
transform: translate(calc(var(--sx) * 0px), 0);
}
12% {
transform: translate(calc(var(--sx) * -34px), 0);
}
30% {
transform: translate(calc(var(--sx) * 26px), 0);
}
46% {
transform: translate(calc(var(--sx) * -14px), 0);
}
60% {
transform: translate(calc(var(--sx) * 6px), 0);
}
74% {
transform: translate(calc(var(--sx) * -2px), 0);
}
86%, 100% {
transform: translate(calc(var(--sx) * 0px), 0);
}
}
.ib.is-demo .ib__ball--a {
animation-name: ibRollA;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes ibRollA {
0% {
transform: translate(calc(var(--sx) * 40px), calc(var(--sy) * 104px));
}
16% {
transform: translate(calc(var(--sx) * 122px), calc(var(--sy) * 104px));
}
32% {
transform: translate(calc(var(--sx) * 196px), calc(var(--sy) * 104px));
}
40% {
transform: translate(calc(var(--sx) * 314px), calc(var(--sy) * 104px));
}
46% {
transform: translate(calc(var(--sx) * 240px), calc(var(--sy) * 104px));
}
60% {
transform: translate(calc(var(--sx) * 176px), calc(var(--sy) * 104px));
}
68% {
transform: translate(calc(var(--sx) * 64px), calc(var(--sy) * 104px));
}
78% {
transform: translate(calc(var(--sx) * 96px), calc(var(--sy) * 104px));
}
86%, 100% {
transform: translate(calc(var(--sx) * 88px), calc(var(--sy) * 104px));
}
}
.ib.is-demo .ib__ball--b {
animation-name: ibRollB;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes ibRollB {
0% {
transform: translate(calc(var(--sx) * 120px), calc(var(--sy) * 110px));
}
14% {
transform: translate(calc(var(--sx) * 48px), calc(var(--sy) * 110px));
}
22% {
transform: translate(calc(var(--sx) * 6px), calc(var(--sy) * 110px));
}
34% {
transform: translate(calc(var(--sx) * 120px), calc(var(--sy) * 110px));
}
48% {
transform: translate(calc(var(--sx) * 210px), calc(var(--sy) * 110px));
}
56% {
transform: translate(calc(var(--sx) * 318px), calc(var(--sy) * 110px));
}
64% {
transform: translate(calc(var(--sx) * 230px), calc(var(--sy) * 110px));
}
78% {
transform: translate(calc(var(--sx) * 150px), calc(var(--sy) * 110px));
}
86%, 100% {
transform: translate(calc(var(--sx) * 140px), calc(var(--sy) * 110px));
}
}
.ib.is-demo .ib__ball--c {
animation-name: ibRollC;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes ibRollC {
0% {
transform: translate(calc(var(--sx) * 190px), calc(var(--sy) * 114px));
}
18% {
transform: translate(calc(var(--sx) * 260px), calc(var(--sy) * 114px));
}
30% {
transform: translate(calc(var(--sx) * 322px), calc(var(--sy) * 114px));
}
44% {
transform: translate(calc(var(--sx) * 210px), calc(var(--sy) * 114px));
}
58% {
transform: translate(calc(var(--sx) * 120px), calc(var(--sy) * 114px));
}
70% {
transform: translate(calc(var(--sx) * 30px), calc(var(--sy) * 114px));
}
80% {
transform: translate(calc(var(--sx) * 90px), calc(var(--sy) * 114px));
}
86%, 100% {
transform: translate(calc(var(--sx) * 78px), calc(var(--sy) * 114px));
}
}
@media (max-width: 340px) {
.ib {
gap: 8px;
--sx: .68;
--sy: .62;
}
.ib__field {
height: 104px;
}
.ib__box {
width: 236px;
height: 84px;
}
.ib__ball--a {
width: 18px;
height: 18px;
}
.ib__ball--b {
width: 14px;
height: 14px;
}
.ib__ball--c {
width: 11px;
height: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ib.is-demo .ib__box,
.ib.is-demo .ib__ball--a,
.ib.is-demo .ib__ball--b,
.ib.is-demo .ib__ball--c {
animation: none !important;
}
.ib.is-demo .ib__box {
transform: translate(0, 0) !important;
}
.ib.is-demo .ib__ball--a {
transform: translate(calc(var(--sx) * 40px), calc(var(--sy) * 104px)) !important;
}
.ib.is-demo .ib__ball--b {
transform: translate(calc(var(--sx) * 120px), calc(var(--sy) * 110px)) !important;
}
.ib.is-demo .ib__ball--c {
transform: translate(calc(var(--sx) * 190px), calc(var(--sy) * 114px)) !important;
}
}var root = document.getElementById('ib');
var field = document.getElementById('field');
var box = document.getElementById('box');
var els = { a: box.querySelector('.ib__ball--a'), b: box.querySelector('.ib__ball--b'), c: box.querySelector('.ib__ball--c') };
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 공 3개 — 크기와 상자 안 처음 x(상자 내부 좌표). 상자의 가속이 -a 로 공에게 들어온다
var SPEC = [ { el: 'a', size: 24, x: 40 }, { el: 'b', size: 18, x: 120 }, { el: 'c', size: 14, x: 190 } ];
var boxX = 0, boxVX = 0, boxAX = 0, lastBX = 0;
var dragging = false, balls = [], last = 0, running = false;
function innerW() { return box.clientWidth; }
function floorY(size) { return box.clientHeight - size; }
function reset() {
balls = SPEC.map(function (s) { return { el: els[s.el], size: s.size, x: s.x, vx: 0 }; });
boxX = 0; boxVX = 0; boxAX = 0;
paint();
}
function paint() {
box.style.transform = 'translate(' + boxX.toFixed(1) + 'px, 0)';
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
b.el.style.transform = 'translate(' + b.x.toFixed(1) + 'px, ' + floorY(b.size) + 'px)';
}
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var inner = innerW(), allCalm = true;
if (!dragging) {
// 손을 뗀 뒤 상자는 스프링으로 중앙으로 돌아온다
boxVX += (-90 * boxX - 9 * boxVX) * dt;
boxX += boxVX * dt;
}
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
// 관성: 상자 가속의 반대 방향 힘이 공에게 걸리고 벽에서는 반발로 튕긴다
b.vx += -boxAX * 1.1 * dt;
b.x += b.vx * dt;
if (b.x < 0) { b.x = 0; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
if (b.x > inner - b.size) { b.x = inner - b.size; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
if (Math.abs(b.vx) > 1 || Math.abs(boxVX) > 2 || Math.abs(boxX) > 0.5) { allCalm = false; }
}
// 손을 떼고 있을 때 가속은 한 프레임짜리 충격이다 — 안 지우면 화살표 키로 준 힘이 영원히 남아
// 공이 한쪽 벽에 붙고 allCalm 이 끝내 참이 안 돼 루프도 안 멎는다. 끄는 동안은 moveBox 가 매번 다시 넣는다
if (!dragging) { boxAX = 0; }
paint();
if (allCalm && !dragging) { reset(); running = false; return; }
requestAnimationFrame(step);
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) { reset(); return false; }
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
return true;
}
function moveBox(clientX) {
var r = field.getBoundingClientRect();
var nx = Math.max(-44, Math.min(44, clientX - r.left - r.width / 2));
boxAX = (nx - boxX) * 46; // 잡아당기는 힘 = 변위 비례 (흔듦의 가속원)
boxX = nx;
wake();
}
box.addEventListener('pointerdown', function (e) {
e.preventDefault();
dragging = true;
box.setPointerCapture(e.pointerId);
moveBox(e.clientX);
});
box.addEventListener('pointermove', function (e) { if (dragging) { moveBox(e.clientX); } });
box.addEventListener('pointerup', function () { dragging = false; boxAX = 0; });
field.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { boxAX = 2600; boxX = Math.max(-44, boxX - 12); }
else if (e.key === 'ArrowRight') { boxAX = -2600; boxX = Math.min(44, boxX + 12); }
else { return; }
e.preventDefault();
wake();
});
document.getElementById('reset').addEventListener('click', function () { reset(); wake(); });
window.addEventListener('resize', function () { reset(); });
reset();// InertiaBox — vanilla 판(run/292/demo/08-physics-inertia-box/index.html) 의 React 포트.
// 상자를 흔들면(가속 boxAX) 공에게는 -boxAX * 1.1 의 힘이 걸리고 벽에서 반발 0.7 로 튕긴다.
// 손을 떼면 상자 자체가 스프링(90, 감쇠 9)으로 중앙에 정렬된다. 상수·식은 vanilla 와 같은 값이다.
// 격자 미리보기(isDemo)는 module.scss 의 감쇠 흔들림 키프레임이 돌린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./InertiaBox.module.scss";
export type InertiaBoxProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Ball = { size: number; x: number; vx: number };
// 공 3개 — 크기와 상자 안 처음 x(상자 내부 좌표). 상자의 가속이 -a 로 공에게 들어온다
const SPEC: Array<{ cls: string; size: number; x: number }> = [
{ cls: "ballA", size: 24, x: 40 },
{ cls: "ballB", size: 18, x: 120 },
{ cls: "ballC", size: 14, x: 190 },
];
const HINT = "상자를 잡고 좌우로 흔들면 상자의 가속이 반대 힘이 되어 공이 굴러갑니다";
const FIELD_LABEL = "관성 실험판";
const RESET_LABEL = "바로 세우기";
function ShakeIcon({ className }: { className: string }) {
return (
<svg viewBox="0 0 256 256" className={className} aria-hidden="true" fill="currentColor"><g><path d="m232 128l-32 32H56l-32-32l32-32h144Z" opacity=".2"/><path d="m237.66 133.66l-32 32a8 8 0 0 1-11.32-11.32L212.69 136H43.31l18.35 18.34a8 8 0 0 1-11.32 11.32l-32-32a8 8 0 0 1 0-11.32l32-32a8 8 0 0 1 11.32 11.32L43.31 120h169.38l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32"/></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 InertiaBox({
duration = 2000,
easing = "ease-in-out",
color = "#ff4d1f",
}: InertiaBoxProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
const elsBox = useRef<Array<HTMLElement | null>>([]) as Box<Array<HTMLElement | null>>;
const ballsBox = useRef<Ball[]>([]) as Box<Ball[]>;
const boxXBox = useRef(0) as Box<number>;
const boxVXBox = useRef(0) as Box<number>;
const boxAXBox = useRef(0) as Box<number>;
const draggingBox = useRef(false) as Box<boolean>;
const lastBox = useRef(0) as Box<number>;
const runningBox = useRef(false) as Box<boolean>;
const frameBox = useRef(0) as Box<number>;
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 innerW(): number {
const box = boxRef.current;
return box ? box.clientWidth : 340;
}
function floorY(size: number): number {
const box = boxRef.current;
return (box ? box.clientHeight : 128) - size;
}
function resetAll(): void {
ballsBox.current = SPEC.map(function (s) { return { size: s.size, x: s.x, vx: 0 }; });
boxXBox.current = 0;
boxVXBox.current = 0;
boxAXBox.current = 0;
paint();
}
function paint(): void {
const box = boxRef.current;
if (box) {
box.style.transform = "translate(" + boxXBox.current.toFixed(1) + "px, 0)";
}
const balls = ballsBox.current;
const els = elsBox.current;
for (let i = 0; i < balls.length; i++) {
const el = els[i];
if (!el) { continue; }
const b = balls[i];
el.style.transform = "translate(" + b.x.toFixed(1) + "px, " + floorY(b.size) + "px)";
}
}
function step(now: number): void {
const dt = Math.min((now - lastBox.current) / 1000, 0.033);
lastBox.current = now;
const inner = innerW();
let allCalm = true;
if (!draggingBox.current) {
// 손을 뗀 뒤 상자는 스프링으로 중앙으로 돌아온다
boxVXBox.current += (-90 * boxXBox.current - 9 * boxVXBox.current) * dt;
boxXBox.current += boxVXBox.current * dt;
}
const balls = ballsBox.current;
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
// 관성: 상자 가속의 반대 방향 힘이 공에게 걸리고 벽에서는 반발로 튕긴다
b.vx += -boxAXBox.current * 1.1 * dt;
b.x += b.vx * dt;
if (b.x < 0) { b.x = 0; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
if (b.x > inner - b.size) {
b.x = inner - b.size;
if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; }
}
if (Math.abs(b.vx) > 1 || Math.abs(boxVXBox.current) > 2 || Math.abs(boxXBox.current) > 0.5) { allCalm = false; }
}
// 손을 떼고 있을 때 가속은 한 프레임짜리 충격이다 — 안 지우면 화살표 키로 준 힘이 영원히 남아
// 공이 한쪽 벽에 붙고 allCalm 이 끝내 참이 안 돼 루프도 안 멎는다. 끄는 동안은 moveBox 가 매번 다시 넣는다
if (!draggingBox.current) { boxAXBox.current = 0; }
paint();
if (allCalm && !draggingBox.current) { resetAll(); runningBox.current = false; return; }
frameBox.current = requestAnimationFrame(step);
}
function wake(): boolean {
setDemo(false);
if (reduced()) { resetAll(); return false; }
if (!runningBox.current) {
runningBox.current = true;
lastBox.current = performance.now();
frameBox.current = requestAnimationFrame(step);
}
return true;
}
function moveBox(clientX: number): void {
const field = fieldRef.current;
if (!field) { return; }
const r = field.getBoundingClientRect();
const nx = Math.max(-44, Math.min(44, clientX - r.left - r.width / 2));
boxAXBox.current = (nx - boxXBox.current) * 46; // 잡아당기는 힘 = 변위 비례 (흔듦의 가속원)
boxXBox.current = nx;
wake();
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
event.preventDefault();
draggingBox.current = true;
const box = boxRef.current;
if (box) { box.setPointerCapture(event.pointerId); }
moveBox(event.clientX);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (draggingBox.current) { moveBox(event.clientX); }
}
function onPointerUp(): void {
draggingBox.current = false;
boxAXBox.current = 0;
}
function onKeyDown(event: KeyEvt): void {
if (event.key === "ArrowLeft") {
boxAXBox.current = 2600;
boxXBox.current = Math.max(-44, boxXBox.current - 12);
} else if (event.key === "ArrowRight") {
boxAXBox.current = -2600;
boxXBox.current = Math.min(44, boxXBox.current + 12);
} else { return; }
event.preventDefault();
wake();
}
function onReset(): void {
resetAll();
wake();
}
useEffect(() => {
resetAll();
const onResize = (): void => { resetAll(); };
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(frameBox.current);
};
}, []);
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<p className={styles.hint}><ShakeIcon className={styles.ico} /><span>{HINT}</span></p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onKeyDown={onKeyDown}
>
<div
className={styles.box}
ref={boxRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
{SPEC.map((spec, i) => (
<span
key={spec.cls}
className={`${styles.ball} ${styles[spec.cls]}`}
aria-hidden="true"
ref={(el: HTMLElement | null) => { elsBox.current[i] = el; }}
/>
))}
</div>
</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>
);
}08 · 慣性ボックス — 計測 累積面積 11.817% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
プレビューは累積面積 11.8%・移動フレーム 21/23 でした。
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var inner = innerW(), allCalm = true;
if (!dragging) {
// 手を離した後、箱はばねで中央へ戻る
boxVX += (-90 * boxX - 9 * boxVX) * dt;
boxX += boxVX * dt;
}
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
// 慣性: 箱の加速度と逆向きの力がボールに掛かり、壁では反発で跳ね返る
b.vx += -boxAX * 1.1 * dt;
b.x += b.vx * dt;
if (b.x < 0) { b.x = 0; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
if (b.x > inner - b.size) { b.x = inner - b.size; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
if (Math.abs(b.vx) > 1 || Math.abs(boxVX) > 2 || Math.abs(boxX) > 0.5) { allCalm = false; }
}
09慣性スクラブ
どんな動きか
リストを強くはじいて離すと、直前 120ms ぶんのサンプルから求めた速度で滑り、摩擦で減速してから一番近いカードへ吸着します。9つの中で動く面積が最も広く、累積面積 33.3%・1フレーム最大 10.5%・移動フレーム 20/23 を記録しました。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-swipe" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 140v36c0 24-8 40-8 40H64l-29.32-50a20 20 0 0 1 34.64-20L88 176V68a20 20 0 0 1 40 0v56a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M216 140v36c0 25.59-8.49 42.85-8.85 43.58A8 8 0 0 1 200 224a7.9 7.9 0 0 1-3.57-.85a8 8 0 0 1-3.58-10.73c.06-.12 7.16-14.81 7.16-36.42v-36a12 12 0 0 0-24 0v4a8 8 0 0 1-16 0v-20a12 12 0 0 0-24 0v12a8 8 0 0 1-16 0V68a12 12 0 0 0-24 0v108a8 8 0 0 1-14.79 4.23l-18.68-30l-.14-.23a12 12 0 1 0-20.8 12l29.29 50a8 8 0 1 1-13.81 8l-29.32-50a28 28 0 0 1 48.41-28.17L80 148V68a28 28 0 0 1 56 0v30.7a28 28 0 0 1 38.65 16.69A28 28 0 0 1 216 140m33-92h-52.69l18.34-18.34a8 8 0 0 0-11.31-11.32l-32 32a8 8 0 0 0 0 11.32l32 32a8 8 0 0 0 11.31-11.32L195.31 64H248a8 8 0 0 0 0-16Z"/></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="sc is-demo" id="sc">
<p class="sc__hint"><svg class="sc__ico" aria-hidden="true"><use href="#ico-swipe"/></svg><span>強くはじいて離すと、離した瞬間の速度で滑ってカードに吸着します</span></p>
<div class="sc__viewport" id="viewport" role="application" aria-label="カードスクラブ" tabindex="0">
<div class="sc__track" id="track">
<span class="sc__card"><b>1</b><i>1枚目</i></span>
<span class="sc__card"><b>2</b><i>2枚目</i></span>
<span class="sc__card"><b>3</b><i>3枚目</i></span>
<span class="sc__card"><b>4</b><i>4枚目</i></span>
<span class="sc__card"><b>5</b><i>5枚目</i></span>
<span class="sc__card"><b>6</b><i>6枚目</i></span>
</div>
</div>
<div class="sc__bar">
<p class="sc__meter"><span class="sc__meterLabel">はじいた速度</span><b class="sc__meterBar"><i class="sc__meterFill" id="meter"></i></b></p>
<button class="sc__reset" id="reset" type="button">
<svg class="sc__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>最初へ</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 관성 스크럽 — pointerup 이 잡은 놓는 속도로 미끄러지고, 마찰은 60fps 기준 0.91^dt 지수 감쇠,
// 속도가 160px/s 아래로 떨어지면 가장 가까운 칸으로 스프링(150, 감쇠 13)이 붙인다.
// 트랙은 translate 하나로만 움직인다 — 진짜 스크롤(scrollLeft) 은 rAF 로 잴 수 없다 (050-06 원칙과 같은 계열).
// 격자 미리보기(.is-demo)는 훑고 놓아 감속하다 멎고 되돌아오는 한 사이클을 키프레임으로 재생한다.
$duration: $dur-loop;
$easing: ease-out;
$color: $stage-yellow;
@include stage($stage-ink);
.sc { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.sc__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.sc__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.sc__viewport {
position: relative; height: 152px; border-radius: $r-card; overflow: hidden;
background: rgba($subject-cream, .05);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .12);
cursor: grab; touch-action: pan-y;
}
.sc__viewport:active { cursor: grabbing; }
.sc__viewport:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sc__track {
position: absolute; left: $sp-2; top: $sp-2;
display: flex; gap: 12px; will-change: transform;
}
.sc__card {
flex: 0 0 120px; width: 120px; height: 120px;
display: grid; place-content: center; justify-items: center; gap: $sp-1;
border-radius: $r-card; background: $subject-cream; color: $stage-ink;
}
.sc__card b { font-weight: 800; font-size: 24px; line-height: 1; }
.sc__card i { font-style: normal; font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); }
.sc__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.sc__meter { margin: 0; display: flex; align-items: center; gap: $sp-1; }
.sc__meterLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-cream, .82); }
.sc__meterBar {
display: block; width: 96px; height: 6px; border-radius: $r-pill; overflow: hidden;
background: rgba($subject-cream, .16);
}
// 속도 계기 — 채움 막대라 시작 변(left) 이 원점이다 (blog-demo 규칙 8 "붙는 지점" 예외)
.sc__meterFill {
display: block; width: 100%; height: 100%; border-radius: inherit;
background: $color; transform-origin: left center;
transform: scaleX(0);
}
.sc__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-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.sc__reset .sc__ico { color: $stage-ink; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 트랙: 0 → -264px(두 칸) 까지 ease-out 으로 감속 미끄러짐(0~48%) → 멈춤(48~64%) →
// 스프링 되돌림(64~92%) → 정지(100%). 시작과 끝이 0px 라 경계 순간이동이 없다.
.sc.is-demo .sc__track {
animation-name: scGlide; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes scGlide {
0% { transform: translate(0px, 0); }
48% { transform: translate(-264px, 0); }
64% { transform: translate(-264px, 0); }
92% { transform: translate(0px, 0); }
100% { transform: translate(0px, 0); }
}
// 속도 계기 — 놓는 순간 1 에서 0 으로 줄고, 되돌림 때 잠깐 반대 방향으로 찍힌 다음 0 이 된다
.sc.is-demo .sc__meterFill {
animation-name: scMeter; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes scMeter {
0% { transform: scaleX(1); }
48% { transform: scaleX(0); }
64% { transform: scaleX(0); }
78% { transform: scaleX(.4); }
92% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@media (max-width: 340px) {
.sc { gap: $sp-2; }
.sc__viewport { height: 112px; }
.sc__track { left: $sp-1; top: $sp-1; gap: 8px; }
.sc__card { flex: 0 0 92px; width: 92px; height: 92px; }
.sc__card b { font-size: 20px; }
.sc__meterBar { width: 64px; }
.sc__hint { font-size: 10px; line-height: 1.25; }
}
// reduce 에서는 첫 칸에 멈춘 정지 상태 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__track,
.sc.is-demo .sc__meterFill { animation: none !important; }
.sc.is-demo .sc__track { transform: translate(0, 0) !important; }
.sc.is-demo .sc__meterFill { transform: scaleX(0) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.sc__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.sc__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.sc__viewport {
position: relative;
height: 152px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 247, 230, 0.05);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.12);
cursor: grab;
touch-action: pan-y;
}
.sc__viewport:active {
cursor: grabbing;
}
.sc__viewport:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.sc__track {
position: absolute;
left: 8px;
top: 8px;
display: flex;
gap: 12px;
will-change: transform;
}
.sc__card {
flex: 0 0 120px;
width: 120px;
height: 120px;
display: grid;
place-content: center;
justify-items: center;
gap: 4px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
}
.sc__card b {
font-weight: 800;
font-size: 24px;
line-height: 1;
}
.sc__card i {
font-style: normal;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
}
.sc__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.sc__meter {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
}
.sc__meterLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.sc__meterBar {
display: block;
width: 96px;
height: 6px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 247, 230, 0.16);
}
.sc__meterFill {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: #ffd23f;
transform-origin: left center;
transform: scaleX(0);
}
.sc__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.sc__reset .sc__ico {
color: #17141a;
}
.sc.is-demo .sc__track {
animation-name: scGlide;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes scGlide {
0% {
transform: translate(0px, 0);
}
48% {
transform: translate(-264px, 0);
}
64% {
transform: translate(-264px, 0);
}
92% {
transform: translate(0px, 0);
}
100% {
transform: translate(0px, 0);
}
}
.sc.is-demo .sc__meterFill {
animation-name: scMeter;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes scMeter {
0% {
transform: scaleX(1);
}
48% {
transform: scaleX(0);
}
64% {
transform: scaleX(0);
}
78% {
transform: scaleX(0.4);
}
92% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@media (max-width: 340px) {
.sc {
gap: 8px;
}
.sc__viewport {
height: 112px;
}
.sc__track {
left: 4px;
top: 4px;
gap: 8px;
}
.sc__card {
flex: 0 0 92px;
width: 92px;
height: 92px;
}
.sc__card b {
font-size: 20px;
}
.sc__meterBar {
width: 64px;
}
.sc__hint {
font-size: 10px;
line-height: 1.25;
}
}
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__track,
.sc.is-demo .sc__meterFill {
animation: none !important;
}
.sc.is-demo .sc__track {
transform: translate(0, 0) !important;
}
.sc.is-demo .sc__meterFill {
transform: scaleX(0) !important;
}
}var root = document.getElementById('sc');
var viewport = document.getElementById('viewport');
var track = document.getElementById('track');
var meter = document.getElementById('meter');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var CW = 120, GAP2 = 12, STEP = CW + GAP2; // 카드 120px + 간격 12px = 한 칸 132px
var x = 0, vx = 0, dragging = false, samples = [], last = 0, running = false, settling = false;
function clampX(v) {
var max = 0, min = viewport.clientWidth - track.scrollWidth;
return Math.max(min, Math.min(max, v));
}
function snapTarget() { return Math.round(clampX(x) / STEP) * STEP; }
function paint() {
track.style.transform = 'translate(' + x.toFixed(1) + 'px, 0)';
var pct = Math.min(1, Math.abs(vx) / 900);
meter.style.transform = 'scaleX(' + pct.toFixed(3) + ')';
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// 손이 붙어 있는 동안은 물리를 돌리지 않는다 — 돌리면 마찰 감속과 스냅 스프링이 끄는 손과 싸운다
if (dragging) { requestAnimationFrame(step); return; }
if (settling) {
// 카드에 붙는 마지막 스프링 — 목표는 가장 가까운 칸
var target = snapTarget();
vx += (150 * (target - x) - 13 * vx) * dt;
x += vx * dt;
if (Math.abs(target - x) < 0.4 && Math.abs(vx) < 6) {
x = target; vx = 0; settling = false; paint(); running = false; return;
}
} else {
// 마찰 감속 — 프레임 비율과 무관하게 60fps 기준 0.91^dt 로 지수 감쇠시킨다
vx *= Math.pow(0.91, dt * 60);
x += vx * dt;
var c = clampX(x);
if (c !== x) { x = c; vx = 0; }
if (Math.abs(vx) < 160) { settling = true; vx = 0; }
}
paint();
requestAnimationFrame(step);
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) { x = snapTarget(); vx = 0; paint(); return false; }
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
return true;
}
viewport.addEventListener('pointerdown', function (e) {
e.preventDefault();
dragging = true; settling = false;
x = clampX(x); vx = 0;
samples = [{ x: e.clientX, t: performance.now() }];
viewport.setPointerCapture(e.pointerId);
wake();
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var now = performance.now();
var prev = samples[samples.length - 1];
x = clampX(x + (e.clientX - prev.x));
samples.push({ x: e.clientX, t: now });
if (samples.length > 8) { samples.shift(); }
paint();
});
viewport.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false;
if (reduce.matches) { x = snapTarget(); vx = 0; paint(); return; }
// 놓는 순간의 속도 = 최근 표본들의 기울기 (pointerup 이 순간을 잡는다)
var now = performance.now(), i = samples.length - 1;
while (i > 0 && now - samples[i - 1].t < 120) { i -= 1; }
var span = samples[samples.length - 1].t - samples[i].t;
vx = span > 8 ? (samples[samples.length - 1].x - samples[i].x) / span * 1000 : 0;
vx = Math.max(-1400, Math.min(1400, vx));
settling = false;
if (!running) { wake(); }
});
viewport.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { x = clampX(x + STEP); vx = 0; settling = true; wake(); }
else if (e.key === 'ArrowRight') { x = clampX(x - STEP); vx = 0; settling = true; wake(); }
else { return; }
e.preventDefault();
});
document.getElementById('reset').addEventListener('click', function () { x = 0; vx = 0; settling = false; paint(); wake(); });
window.addEventListener('resize', function () { x = clampX(x); paint(); });
paint();// InertiaScrub — vanilla 판(run/292/demo/09-physics-inertia-scrub/index.html) 의 React 포트.
// pointerup 이 잡은 놓는 속도로 미끄러지고, 마찰은 60fps 기준 0.91^dt 지수 감쇠,
// 속도가 160px/s 아래로 떨어지면 가장 가까운 칸으로 스프링(150, 감쇠 13)이 붙인다.
// 트랙은 translate 하나로만 움직인다 — 진짜 스크롤(scrollLeft) 은 rAF 로 잴 수 없다.
// 상수·식은 vanilla 와 같은 값이다. 격자 미리보기(isDemo)는 module.scss 의 키프레임이 돌린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./InertiaScrub.module.scss";
export type InertiaScrubProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Sample = { x: number; t: number };
const CW = 120;
const GAP2 = 12;
const STEP = CW + GAP2; // 카드 120px + 간격 12px = 한 칸 132px
const HINT = "세게 훑고 놓으면 손 뗀 속도로 미끄러지다 카드에 붙습니다";
const VIEW_LABEL = "카드 스크럽";
const METER_LABEL = "던진 속도";
const RESET_LABEL = "처음으로";
const CARDS = [
{ n: "1", name: "첫 카드" },
{ n: "2", name: "둘 카드" },
{ n: "3", name: "셋 카드" },
{ n: "4", name: "넷 카드" },
{ n: "5", name: "다섯 카드" },
{ n: "6", name: "여섯 카드" },
];
function SwipeIcon({ className }: { className: string }) {
return (
<svg viewBox="0 0 256 256" className={className} aria-hidden="true" fill="currentColor"><g><path d="M208 140v36c0 24-8 40-8 40H64l-29.32-50a20 20 0 0 1 34.64-20L88 176V68a20 20 0 0 1 40 0v56a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M216 140v36c0 25.59-8.49 42.85-8.85 43.58A8 8 0 0 1 200 224a7.9 7.9 0 0 1-3.57-.85a8 8 0 0 1-3.58-10.73c.06-.12 7.16-14.81 7.16-36.42v-36a12 12 0 0 0-24 0v4a8 8 0 0 1-16 0v-20a12 12 0 0 0-24 0v12a8 8 0 0 1-16 0V68a12 12 0 0 0-24 0v108a8 8 0 0 1-14.79 4.23l-18.68-30l-.14-.23a12 12 0 1 0-20.8 12l29.29 50a8 8 0 1 1-13.81 8l-29.32-50a28 28 0 0 1 48.41-28.17L80 148V68a28 28 0 0 1 56 0v30.7a28 28 0 0 1 38.65 16.69A28 28 0 0 1 216 140m33-92h-52.69l18.34-18.34a8 8 0 0 0-11.31-11.32l-32 32a8 8 0 0 0 0 11.32l32 32a8 8 0 0 0 11.31-11.32L195.31 64H248a8 8 0 0 0 0-16Z"/></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 InertiaScrub({
duration = 2000,
easing = "ease-out",
color = "#ffd23f",
}: InertiaScrubProps) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const meterRef = useRef<HTMLElement | null>(null);
const xBox = useRef(0) as Box<number>;
const vxBox = useRef(0) as Box<number>;
const draggingBox = useRef(false) as Box<boolean>;
const settlingBox = useRef(false) as Box<boolean>;
const samplesBox = useRef<Sample[]>([]) as Box<Sample[]>;
const lastBox = useRef(0) as Box<number>;
const runningBox = useRef(false) as Box<boolean>;
const frameBox = useRef(0) as Box<number>;
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 clampX(v: number): number {
const viewport = viewportRef.current;
const track = trackRef.current;
if (!viewport || !track) { return v; }
const max = 0;
const min = viewport.clientWidth - track.scrollWidth;
return Math.max(min, Math.min(max, v));
}
function snapTarget(): number {
return Math.round(clampX(xBox.current) / STEP) * STEP;
}
function paint(): void {
const track = trackRef.current;
if (track) {
track.style.transform = "translate(" + xBox.current.toFixed(1) + "px, 0)";
}
const meter = meterRef.current;
if (meter) {
const pct = Math.min(1, Math.abs(vxBox.current) / 900);
meter.style.transform = "scaleX(" + pct.toFixed(3) + ")";
}
}
function step(now: number): void {
const dt = Math.min((now - lastBox.current) / 1000, 0.033);
lastBox.current = now;
// 손이 붙어 있는 동안은 물리를 돌리지 않는다 — 돌리면 마찰 감속과 스냅 스프링이 끄는 손과 싸운다
if (draggingBox.current) { frameBox.current = requestAnimationFrame(step); return; }
if (settlingBox.current) {
// 카드에 붙는 마지막 스프링 — 목표는 가장 가까운 칸
const target = snapTarget();
vxBox.current += (150 * (target - xBox.current) - 13 * vxBox.current) * dt;
xBox.current += vxBox.current * dt;
if (Math.abs(target - xBox.current) < 0.4 && Math.abs(vxBox.current) < 6) {
xBox.current = target;
vxBox.current = 0;
settlingBox.current = false;
paint();
runningBox.current = false;
return;
}
} else {
// 마찰 감속 — 프레임 비율과 무관하게 60fps 기준 0.91^dt 로 지수 감쇠시킨다
vxBox.current *= Math.pow(0.91, dt * 60);
xBox.current += vxBox.current * dt;
const c = clampX(xBox.current);
if (c !== xBox.current) { xBox.current = c; vxBox.current = 0; }
if (Math.abs(vxBox.current) < 160) { settlingBox.current = true; vxBox.current = 0; }
}
paint();
frameBox.current = requestAnimationFrame(step);
}
function wake(): boolean {
setDemo(false);
if (reduced()) { xBox.current = snapTarget(); vxBox.current = 0; paint(); return false; }
if (!runningBox.current) {
runningBox.current = true;
lastBox.current = performance.now();
frameBox.current = requestAnimationFrame(step);
}
return true;
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
event.preventDefault();
draggingBox.current = true;
settlingBox.current = false;
xBox.current = clampX(xBox.current);
vxBox.current = 0;
samplesBox.current = [{ x: event.clientX, t: performance.now() }];
const viewport = viewportRef.current;
if (viewport) { viewport.setPointerCapture(event.pointerId); }
wake();
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (!draggingBox.current) { return; }
const now = performance.now();
const samples = samplesBox.current;
const prev = samples[samples.length - 1];
xBox.current = clampX(xBox.current + (event.clientX - prev.x));
samples.push({ x: event.clientX, t: now });
if (samples.length > 8) { samples.shift(); }
paint();
}
function onPointerUp(): void {
if (!draggingBox.current) { return; }
draggingBox.current = false;
if (reduced()) { xBox.current = snapTarget(); vxBox.current = 0; paint(); return; }
// 놓는 순간의 속도 = 최근 표본들의 기울기 (pointerup 이 순간을 잡는다)
const now = performance.now();
const samples = samplesBox.current;
let i = samples.length - 1;
while (i > 0 && now - samples[i - 1].t < 120) { i -= 1; }
const span = samples[samples.length - 1].t - samples[i].t;
vxBox.current = span > 8 ? ((samples[samples.length - 1].x - samples[i].x) / span) * 1000 : 0;
vxBox.current = Math.max(-1400, Math.min(1400, vxBox.current));
settlingBox.current = false;
if (!runningBox.current) { wake(); }
}
function onKeyDown(event: KeyEvt): void {
if (event.key === "ArrowLeft") {
xBox.current = clampX(xBox.current + STEP);
vxBox.current = 0;
settlingBox.current = true;
wake();
} else if (event.key === "ArrowRight") {
xBox.current = clampX(xBox.current - STEP);
vxBox.current = 0;
settlingBox.current = true;
wake();
} else { return; }
event.preventDefault();
}
function onReset(): void {
xBox.current = 0;
vxBox.current = 0;
settlingBox.current = false;
paint();
wake();
}
useEffect(() => {
paint();
const onResize = (): void => { xBox.current = clampX(xBox.current); paint(); };
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(frameBox.current);
};
}, []);
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<p className={styles.hint}><SwipeIcon className={styles.ico} /><span>{HINT}</span></p>
<div
className={styles.viewport}
ref={viewportRef}
role="application"
aria-label={VIEW_LABEL}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<div className={styles.track} ref={trackRef}>
{CARDS.map((card) => (
<span className={styles.card} key={card.n}><b>{card.n}</b><i>{card.name}</i></span>
))}
</div>
</div>
<div className={styles.bar}>
<p className={styles.meter}>
<span className={styles.meterLabel}>{METER_LABEL}</span>
<b className={styles.meterBar}>
<i className={styles.meterFill} ref={meterRef} />
</b>
</p>
<button className={styles.reset} type="button" onClick={onReset}>
<ResetIcon className={styles.ico} /><span>{RESET_LABEL}</span>
</button>
</div>
</div>
</main>
);
}09 · 慣性スクラブ — 計測 累積面積 33.269% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
ギャラリーのカルーセルや、ストーリーフィードの横スワイプに使えます。
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// 指が触れている間は物理を回さない — 回すと摩擦減速とスナップのばねが引く手と争う
if (dragging) { requestAnimationFrame(step); return; }
if (settling) {
// カードへ吸着する最後のばね — 目標は一番近いマス
var target = snapTarget();
vx += (150 * (target - x) - 13 * vx) * dt;
x += vx * dt;
if (Math.abs(target - x) < 0.4 && Math.abs(vx) < 6) {
x = target; vx = 0; settling = false; paint(); running = false; return;
}
} else {
// 摩擦減速 — フレーム比率と無関係に 60fps 基準の 0.91^dt で指数減衰させる
vx *= Math.pow(0.91, dt * 60);
x += vx * dt;
var c = clampX(x);
if (c !== x) { x = c; vx = 0; }
if (Math.abs(vx) < 160) { settling = true; vx = 0; }
}
paint();
requestAnimationFrame(step);
}
どこで壊れるか — 落とし穴
1つ目は 05 のレンガが透明に見えてしまった不具合です。JavaScript が付けていたクラスは pb__brick--r0c0 の形でしたが、色を決めるルールは .pb__brick--r0 だったので、セレクタがどれとも一致せず背景色が適用されませんでした。行のクラスを別に付け足して直しました — 列まで含んだ名前は1マスを名指しするためのもので、色は行で決まるという、別々の役目だったわけです。
2つ目は 09 で、指が触れている間も rAF ループが摩擦減速とスナップのばねを回していたことです。ドラッグしている指と物理演算が同じ x を奪い合い、指を右へ動かしているのにカードが左へ戻ろうとしました。step() の先頭に if (dragging) { requestAnimationFrame(step); return; } を置き、指が離れるまで物理演算を止めることで直りました。
3つ目は 03 の単位です — angle と avel は度で持っているのに、-(g/L)·sinθ が返すのはラジアン/s² の角加速度でした。そのまま足していたので復元項だけが 180/π(およそ57倍)小さくなり、30度から離しても6秒で0.9度しか動かず、振り子はぶら下がったままでした。それでも、どのゲートも赤くなりませんでした — 格子のプレビューは CSS の @keyframes pdSwing(±26度)で動いていて、計測が見ているのはそのプレビューなので、壊れている間も 03 の節に書いたのと同じ累積面積 5.6%・移動フレーム 23/23 を記録していました。直したのは var G = 1800, LEN = 118, DAMP = 0.4, RAD = 180 / Math.PI; の定数行と、ラジアンの角加速度に RAD を掛けて度へそろえる1行です(G は 22 px/s² から 02 と同じ目盛りの 1800 px/s² へ — 単位だけ直しても 22 px/s²・腕 118px では周期が14.6秒で、やはり振れません)。測り直すと6秒で中心を8回通過し、最大26.9度、6秒後でも7.28度で、周期はおよそ1.6秒です。9つともプレビューは rAF ループとは別のキーフレームなので、レンダーのゲートを通ったことは、人が触ったときにどう動くかを保証しません。
4つ目は時間そのものです。9つとも dt を Math.min((now - last) / 1000, 0.033) で抑えています。タブをバックグラウンドに回してから戻ると now - last が数秒になり、上限がなければ1フレームで速度に数秒ぶんの力が入って画面の外へ飛びます。挙動の違いを確かめたいときは 01 と 02 のスライダーを動かしてみてください — 下の表のとおり、値を1つ変えるだけで動きの性格が変わります。
| 名前 | 変えた値 | 感じ |
|---|---|---|
| ばねを硬く | 01 のばねスライダー 3 → 6 (k = 値 × 60) | 到達が早まり、行き過ぎが大きい |
| 減衰を強く | 01 の減衰スライダー 8 → 12 (c = 値 × 1.5) | 行き過ぎが減り、滑らかに収まる |
| 反発を上げる | 02 の反発スライダー 0.62 → 0.88 | ボールが跳ね返り続けて床を鳴らす |
08の揺さぶりのように、人が与えた入力を次の自動処理へ渡す席はn8n ワークフロー サンプルがノードでつなぎます。9つをまとめたアーカイブのパスワードは qc5pgrv8 で、開くとデモごとに素の HTML 版と React 版が1組ずつ入っています。
アクセシビリティ (reduced-motion)
9つとも role="application" と説明用の aria-label を持ち、ポインタでできることは方向キーでもできます — 目標を動かす(01・04・06)、角度を変える(03)、パドルを送る(05)、風を吹かせる(07)、箱を揺らす(08)、カードを1枚ずつ送る(09)。02 は Enter とスペースで落とし直します。数字が変わる場面には aria-live="polite" を置き、振り子の角度(03)とスコア(05)を読み上げます。prefers-reduced-motion: reduce では二重に止めます。JavaScript は reduce.matches を見て揺れをスキップし、目標や吸着先へ直接置きます(01・03・09)。CSS はプレビューのキーフレームを animation: none で止め、それぞれの場面を一番読みやすい静止の1枚に固定します — 動きが消えても、何を触る画面なのかは残ります。
FAQ
ライブラリなしで本当に足りますか?
毎フレームの計算は requestAnimationFrame、画面への反映は transform だけで、9つとも外部のコードを読み込んでいません。剛体の回転や連結した関節が必要になるところからは専用エンジンの出番ですが、追従・落下・振り子・慣性の範囲なら20行ほどの積分ループで足ります(requestAnimationFrame のドキュメント)。積分まで行かず曲線1本で近い弾みを出したいときは、css イージング関数に貼って使える値がまとまっています。
スマホのタッチでも同じように動きますか?
入力はすべて Pointer Events なので、マウスと指の区別なく同じ関数へ届きます(Pointer Events のドキュメント)。指で引く場面はブラウザのスクロールに取られないよう touch-action: none を指定し、横方向だけを使う 09 は縦スクロールを残すため touch-action: pan-y にしてあります。
物理シミュレーションアプリ のように端末でフレームレートが違っても速さは変わりませんか?
9つとも位置と速度を dt(秒)で積分しているので、120Hz でも 60Hz でも同じ時間に同じ距離を進みます。09 の摩擦だけは掛け算の繰り返しなので Math.pow(0.91, dt * 60) と書き、1フレームぶんの減衰率を実際の経過時間に合わせて伸び縮みさせています。









