08관성 상자
상자를 잡고 흔들면 상자의 가속이 반대 방향 힘이 되어 공들이 굴러다닙니다. 상자와 공은 같은 translate 로 옮기되 프레임마다 가속을 공에 더해 관성을 냅니다.
쓰임드래그로 여는 알림 목록, 흔들어 섞는 갤러리 셔플
requestAnimationFramepointerdowntranslate

<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 포함 · 물리 시뮬레이션 만들기 9종 — 스프링부터 관성 스크럽까지
이 데모가 속한 글
물리 시뮬레이션 만들기 9종 — 스프링부터 관성 스크럽까지물리 시뮬레이션 만들기는 중력·스프링·마찰 같은 힘을 프레임마다 계산해 화면 요소의 자리를 직접 정하는 일입니다. 여기 실은 아홉 판은 라이브러리 없이 requestAnimationFrame 과 transform 두 이 글의 데모 전부 보기


