물리 시뮬레이션 만들기 9종 — 스프링부터 관성 스크럽까지
물리 시뮬레이션 만들기는 중력·스프링·마찰 같은 힘을 프레임마다 계산해 화면 요소의 자리를 직접 정하는 일입니다. 여기 실은 아홉 판은 라이브러리 없이 requestAnimationFrame 과 transform 두 가지만으로 짜서, 클릭·드래그·훑기에 그 자리에서 반응합니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개를 이 순서로 늘어놓은 기준은 물리 기반 애니메이션에서 힘이 어디서 오는가입니다. 앞의 셋은 힘이 하나뿐인 판 — 목표로 끌어당기는 스프링(01), 아래로만 잡아당기는 중력(02), 각도가 곧 상태값인 진자(03)입니다. 가운데 셋은 움직이는 몸체가 여럿이라 서로 부딪히거나 끌어당기는 판으로, 철가루 서른두 개(04)와 벽돌 열여덟 칸(05), 점 예순 개의 천(06)이 이어집니다. 마지막 셋은 사람 손의 속도가 그대로 힘이 되는 판 — 커서가 지나간 방향이 바람이 되고(07), 상자를 흔든 가속이 공에 반대로 걸리고(08), 손을 놓는 순간의 속도가 미끄러짐이 됩니다(09). 무대 색은 잉크 넷·노랑 둘·페이퍼 둘·주황 하나로 돌렸습니다. 포인터를 각도로 바꿔 입체를 돌리는 쪽은 마우스를 따라오는 CSS 3D 회전 장면 9종에, 소리를 그림으로 바꾸는 쪽은 오디오 비주얼라이저 9종에 있습니다. 04 의 철가루처럼 많은 점을 규칙 하나로 배치하되 움직임 대신 무늬로 남기는 쪽은 CSS 배경 패턴 만들기가 시드 하나로 풉니다.
01스프링 따라오기
무엇이 보이나
판을 클릭하거나 방향키로 목표를 옮기면, 칩은 강성·감쇠 두 다이얼이 시키는 만큼 목표를 지나쳤다 되돌아와 붙습니다. 가속을 먼저 속도에 넣고 그 속도로 위치를 옮기는 준암시적 오일러이고, dt 를 0.033초에서 자르는 한 줄이 다른 탭에 갔다 돌아온 첫 프레임에 칩이 판 밖으로 날아가는 것을 막습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
실제로 눌러 보면 목표 320px 을 362.4px 까지 지나쳤다가 320.0px 에 멎고(run/292/_probe/interact.json), 격자 미리보기는 누적면적 11.9%, 강도 106.9, 움직인 프레임 23/23 을 기록했습니다.
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중력 드롭
물리 시뮬레이션 코딩은 어디서 시작하나
공 넷이 함께 떨어져 바닥에서 반발 슬라이더 값만큼 되튀고, 남은 세로 속도가 초당 40px 아래로 내려가면 바닥에 붙어 끝납니다. 공끼리는 겹친 거리의 절반씩 서로 밀어내기만 하므로, 속도를 주고받는 계산 없이도 무더기가 자연스럽게 갈라집니다.

<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>판을 클릭하면 네 공이 다시 떨어지고 반발 슬라이더 값만큼 튀어 오릅니다</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
미리보기는 누적면적 5.9%, 강도 187.6, 움직인 프레임 22/23 이고, 드래그해서 놓은 파일 아이콘이 떨어지는 자리나 게임 느낌의 보상 효과에 씁니다.
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; }
}
03진자 흔들기
어떤 움직임인가
추를 잡아끌면 커서가 가리키는 방향이 그대로 팔의 각도가 되고, 놓으면 -(g/L)·sinθ 에서 감쇠를 뺀 각가속도가 매 프레임 각도에 쌓입니다. 회전축이 위쪽 경첩이라 transform-origin 을 맨 위 변(50% 0)에 두는 것이 팔을 흔드는 전부이고, 끌 수 있는 각도는 ±75도에서 잘라 팔이 뒤집히지 않게 했습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
격자 미리보기는 감쇠가 없는 ±26도 사인 흔들림을 2초 주기로 재생해 누적면적 5.6%, 강도 119.3, 움직인 프레임 23/23 을 기록했습니다.
// 팔 전체가 고정점을 축으로 돈다 — transform-origin 을 맨 위로 (붙는 지점)
.pd__arm {
position: absolute; left: 50%; top: 10px; width: 0; height: 118px;
transform-origin: 50% 0;
}
@keyframes pdSwing {
0% { transform: rotate(26deg); }
25% { transform: rotate(0deg); }
50% { transform: rotate(-26deg); }
75% { transform: rotate(0deg); }
100% { transform: rotate(26deg); }
}
04자석 파티클
무엇이 보이나
철가루 서른두 개가 8×4 격자에 지터를 준 자리에 흩어져 있고, 커서가 자석이 되어 버튼으로 고른 방향에 따라 빨아들이거나 밀어냅니다. 힘은 거리 제곱에 반비례하되 분모에 900 을 더해 커서에 닿는 순간 값이 폭발하지 않게 막았고, 제자리로 당기는 스프링과 0.86 배 감쇠가 함께 걸려 무리가 흩어지지 않고 돌아옵니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
미리보기는 누적면적 18.8%, 강도 158.9, 움직인 프레임 23/23 이고, 히어로 배경처럼 넓은 면을 덮는 자리에 씁니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
벽돌 격자의 원점은 BX·BY 상수 한 쌍뿐이라 그리는 자리와 충돌 판정이 같은 값을 보고, 미리보기는 누적면적 6.2%, 강도 164.0, 움직인 프레임 23/23 을 기록했습니다.
// 벽돌 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 });
}
}
06천 출렁이기
어떤 움직임인가
12×5 로 놓인 점 예순 개 가운데 맨 윗줄 열두 개는 핀으로 고정되고, 커서 반경 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
제자리 좌표는 CSS 변수 --hx·--hy 가 들고 있고 자바스크립트는 오프셋만 calc() 로 얹어서 창 폭이 바뀌어도 격자가 흐트러지지 않으며, 미리보기는 누적면적 12.9%, 강도 114.4, 움직인 프레임 23/23 입니다.
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)';
}
// 이웃 끌림 — 왼쪽·오른쪽·위 점의 현재 변위를 조금 따라간다 (천의 가로 실)
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;
07플로우 필드
무엇이 보이나
대시 서른여섯 개가 커서의 이동 방향을 바람으로 받아 그쪽으로 흐르고, 진행 방향에 맞춰 rotate 까지 돌아 결이 눈에 보입니다. 마지막 입력이 0.9초 이내면 조타력이 2.4, 그보다 오래되면 0.4 로 떨어져 손을 멈춘 뒤 흐름이 천천히 풀리고, 속도는 190 에서 잘라 대시가 순간이동처럼 건너뛰지 않게 했습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
가장자리에 닿은 대시는 반대편으로 돌아 들어와 화면이 비지 않고, 미리보기는 누적면적 19.1%, 강도 164.0, 움직인 프레임 23/23 입니다.
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);
}
08관성 상자
화면에서 일어나는 일
상자를 잡고 좌우로 흔들면 상자 가속의 반대 방향 힘이 공 셋에 걸려 공이 뒤로 굴러가고, 손을 떼면 상자가 스프링으로 가운데에 돌아옵니다. 공은 벽에서 0.7 배로 되튀고 속도가 초당 20px 밑으로 떨어지면 그 자리에 서며, 미리보기는 누적면적 11.8%, 강도 145.4, 움직인 프레임 21/23 입니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
가장 잘 깨지는 곳은 가속을 지우는 자리인데, 손을 뗀 프레임마다 boxAX 를 0 으로 되돌리지 않으면 화살표 키로 준 힘이 영원히 남아 공이 한쪽 벽에 붙고 루프도 멎지 않습니다.
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);
}
09관성 스크럽
어떤 움직임인가
카드 줄을 세게 훑고 놓으면 최근 120밀리초 표본의 기울기가 속도가 되어 미끄러지다가, 마찰로 느려지면 가장 가까운 칸으로 당겨져 붙습니다. 감속을 Math.pow(0.91, dt * 60) 로 둔 덕분에 프레임이 밀려도 같은 시간에 같은 만큼 줄고, 카드 폭 120px 에 간격 12px 을 더한 132px 이 칸의 눈금입니다.

<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>첫 카드</i></span>
<span class="sc__card"><b>2</b><i>둘 카드</i></span>
<span class="sc__card"><b>3</b><i>셋 카드</i></span>
<span class="sc__card"><b>4</b><i>넷 카드</i></span>
<span class="sc__card"><b>5</b><i>다섯 카드</i></span>
<span class="sc__card"><b>6</b><i>여섯 카드</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
실제로 던져 보면 놓은 직후 -272.0px 에서 160밀리초 뒤 -360.6px 을 지나 -396.0px, 즉 눈금 세 칸에 정확히 멎었고(run/292/_probe/interact2.json) 미리보기는 누적면적 33.3%, 강도 180.0, 움직인 프레임 20/23 입니다.
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);
}
어디서 깨지는가 — 함정
물리 코드가 깨진 자리는 대개 식이 아니라 식 바깥이었고, 식 안에서 깨진 한 번은 단위 때문이었습니다. 05 는 자바스크립트가 벽돌에 pb__brick--r0c0 처럼 행과 열을 붙인 클래스만 달아 줬는데 색 규칙은 .pb__brick--r0 을 보고 있어서, 클래스 선택자가 어느 벽돌과도 정확히 맞지 않아 판이 배경만 남은 채 그려졌습니다. 행 클래스를 따로 하나 더 붙이게 고치자 세 줄이 주황·노랑·크림으로 돌아왔고, 지금 판에는 3×6 열여덟 칸이 그 세 색으로 서 있습니다(run/292/_probe/interact2.json 의 05_벽돌색·05_벽돌수). 09 는 반대로 물리가 너무 많이 돌았습니다. 손가락이 아직 붙어 있는 동안에도 rAF 루프가 마찰 감속과 스냅 스프링을 계속 계산해서, 끄는 손이 옮겨 놓은 만큼을 물리가 뒤로 당기며 서로 싸웠습니다. step() 맨 위에 if (dragging) { requestAnimationFrame(step); return; } 한 줄을 넣어 손이 붙어 있는 프레임에는 아무것도 적분하지 않게 하자 끄는 동안 역행이 사라졌습니다.
세 번째는 단위입니다. 03 의 angle·avel 은 도(deg)로 들고 있는데 복원항 -(g/L)·sinθ 가 내놓는 값은 라디안/s² 이라, 변환 없이 도 단위 속도에 더하면 되돌리는 힘이 180/π 배, 그러니까 약 57배 작아집니다. 그래서 30도에서 놓아도 6초 동안 29.1도까지, 겨우 0.9도밖에 움직이지 않았습니다. 지금은 Math.sin(angle / RAD) 으로 각을 라디안으로 넘기고 결과에 다시 RAD 를 곱해 도/s² 로 되돌리며, 중력도 22 에서 1800px/s² 로 올려 02 중력 드롭과 같은 눈금에 맞췄습니다 — 단위만 고쳐 놓으면 팔 118px 에 22px/s² 라 주기가 14.6초여서 여전히 흔들리지 않기 때문입니다. 고친 판을 30도에서 놓아 재 보면 6초 동안 영점통과 8회, 최대 26.9도, 6초 뒤 7.28도로 주기 1.6초짜리 감쇠 진동이 나옵니다(run/292/_probe/swing03.json).
var G = 1800, LEN = 118, DAMP = 0.4, RAD = 180 / Math.PI;
avel += (-(G / LEN) * Math.sin(angle / RAD) * RAD - DAMP * avel) * dt;
더 중요한 것은 이 오류를 어떤 게이트도 잡지 못했다는 사실입니다. 격자 미리보기는 CSS @keyframes pdSwing(±26도)이고 측정.json 은 그 미리보기를 재기 때문에, 03 은 고장 나 있는 내내 누적면적 5.61%, 강도 119.3, 움직인 프레임 23/23 을 똑같이 찍었습니다. 아홉 판이 모두 재는 경로(CSS 키프레임)와 손이 닿는 경로(rAF 적분)를 따로 들고 있으니, 렌더 게이트를 통과했다는 말은 사람이 실제로 만졌을 때 어떤지에 대해 아무것도 말해 주지 않습니다.
네 번째는 격자의 원점을 두 곳에 적어 두는 버릇입니다. 05 의 벽돌은 그리는 laid() 와 충돌을 판정하는 step() 이 같은 BX·BY 를 봐야 하는데, 위 여백을 각자 들고 있으면 눈에 보이는 벽돌과 공이 부딪히는 사각형이 어긋나 공이 아무것도 없는 띠에서 튕깁니다.
| 변형 | 바꾼 값 | 느낌 |
|---|---|---|
| 강성 높임 | 01 강성 슬라이더 3 → 6 (k = 값 x 60) | 도착이 빨라지고 오버슈트가 커진다 |
| 감쇠 높임 | 01 감쇠 슬라이더 8 → 12 (c = 값 x 1.5) | 오버슈트가 줄어 미끄러지듯 붙는다 |
| 반발 높임 | 02 반발 슬라이더 0.62 → 0.88 | 공이 오래 살아남아 바닥을 두드린다 |
그래서 이 아홉 판은 미리보기와 조작 경로를 늘 함께 열어 보아야 합니다. 08 의 흔들림처럼 사람이 준 입력을 다음 자동 처리로 넘기는 자리는 n8n 워크플로우 예제가 노드로 잇습니다. 아홉 판을 담은 압축 파일의 비밀번호는 qc5pgrv8 이고, 풀어 보면 미리보기용 .is-demo 규칙과 실제 적분 루프가 항목마다 나란히 들어 있어 두 경로를 따로 손볼 수 있습니다.
접근성
아홉 판 모두 조작하는 면에 role="application" 과 tabindex="0" 을 주고, 포인터로 하는 일을 화살표 키로도 하게 했습니다. 목표 옮기기(01), Enter 로 다시 떨어뜨리기(02), 15도씩 당기기(03), 자석 옮기기(04), 패들 밀기와 Enter 공 놓기(05), 커서 자리 옮기기(06), 바람 불기(07), 상자 흔들기(08), 한 칸씩 넘기기(09)가 전부 키보드로 됩니다. 끌기가 스크롤과 다투지 않도록 아홉 판의 조작 면에는 touch-action: none 을 걸었고, 각도(03)와 점수(05)처럼 숫자가 바뀌는 곳에는 aria-live="polite" 를 붙여 보이지 않아도 값이 읽히게 했습니다. prefers-reduced-motion: reduce 에서는 미리보기 애니메이션을 전부 끄고 판마다 가장 잘 읽히는 한 장을 세웁니다 — 칩은 출발 자리(01), 공은 바닥(02), 추는 수직(03), 철가루와 천의 점은 제자리 좌표(04·06), 대시는 흩뿌려진 자리(07), 상자는 가운데(08), 카드 줄은 첫 칸(09)입니다. 자바스크립트 쪽도 같은 질의를 matchMedia 로 물어서, 사람이 조작하면 진동 없이 목표 상태로 한 번에 옮깁니다.
FAQ
프레임이 흔들려도 시뮬레이션 결과가 같나요?
아홉 판 모두 dt 를 0.033초에서 자르고 그 dt 를 식에 곱하기 때문에, 프레임이 느려져도 한 프레임에 몰아서 뛰지 않고 다음 프레임에 나눠 갑니다. 지수 감쇠처럼 곱셈으로 줄이는 값은 09 의 Math.pow(0.91, dt * 60) 처럼 지수에 시간을 넣어야 모니터 주사율이 달라도 같은 곡선이 나옵니다. 프레임 콜백의 인자와 호출 규칙은 requestAnimationFrame 문서에 있습니다. 적분 루프까지 갈 것 없이 곡선 하나로 비슷한 탄성을 내고 싶으면 cubic-bezier 이징 커브에 복붙할 값이 모여 있습니다.
터치 화면에서도 같은 코드가 그대로 도나요?
아홉 판은 pointerdown·pointermove·pointerup 하나로 마우스·터치·펜을 같이 받습니다. Pointer Events 지원율은 2026년 9월 12일 기준 96.75%이고 남은 쪽은 오페라 미니 계열이라, 터치 기기에서 브라우저가 먼저 스크롤을 가져가지 않도록 touch-action: none 을 걸고 끌기를 시작할 때 setPointerCapture 로 포인터를 붙잡아 두면 됩니다.
물리 애니메이션을 줄여 달라는 사람에게는 무엇을 남겨야 하나요?
끌기나 스크롤 같은 조작에서 시작된 움직임은 끌 수 있어야 한다는 것이 WCAG 2.2 의 Animation from Interactions 기준입니다. 이 편은 움직임을 아예 없애는 대신 결과만 남기는 쪽을 골라서, 칩은 목표에 바로 붙고 카드는 가장 가까운 칸에 바로 서게 했습니다. 같은 방식의 정지 화면을 다른 편에서 보려면 SVG 그래프 그리기 9종의 접근성 절을 함께 보면 됩니다.









