08Inertia box
Grab and shake the box: the box's acceleration becomes a force in the opposite direction and the balls roll. Box and balls both move through translate, but each frame adds the box's acceleration to the balls to make inertia.
Best forA notification tray you drag open, a gallery shuffle you shake
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>Grab the box and shake it sideways, and its acceleration becomes an opposite force that rolls the balls</span></p>
<div class="ib__field" id="field" role="application" aria-label="Inertia test board" 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>Straighten</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 Inertia box — Measured area 11.817% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · JavaScript Physics Simulation: 9 Demos, No Library
The post this demo lives in
JavaScript Physics Simulation: 9 Demos, No LibraryA JavaScript physics simulation is arithmetic repeated once per frame: force adds to velocity, velocity adds to position See all demos in this post
If you like this effect - similar demos
Creative UI09 Liquid dropletVerlet Physics 9 Demos: No Library, Just Canvas
Creative UI05 Draw your own line — sketch it, and circles redraw it liveFourier Epicycles, 9 Ways (No Library)
Creative UI07 Launch a comet — drag angle and power to throw a star in yourselfN Body Simulation, 9 Ways (Galaxy, No Library)