03Pendulum swing
Pull the bob and let go: the angle turns into height and the swing sustains itself. The axis is the fixed pivot, so rotate gets its transform-origin on the top edge.
Best forA pull-down notification panel, the snap-back easing of swipe cards
rotatetransform-originrequestAnimationFrame

<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>Pull the bob and let go, and the angle turns into height so it keeps swinging on its own</span></p>
<div class="pd__field" id="field" role="application" aria-label="Pendulum test board" 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="Pendulum bob">
<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 deg</span><span class="pd__angleLoop" aria-hidden="true"><i>26 deg</i><i>0 deg</i><i>26 deg</i><i>0 deg</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>Release at 30 deg</span>
</button>
<button class="pd__reset" id="reset" type="button">
<svg class="pd__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>Stop</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 는 deg 변환 상수다.
// -(g/L)sinθ 는 라디안/s² 이라 deg 단위인 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 + ' deg') { angleText.textContent = deg + ' deg'; }
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// 각가속도 = -(g/L)·sinθ - Damping. 각도가 높이(에너지)로 바뀌는 게 진자의 물리다
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 Pendulum swing — Measured area 5.61% · moving frames 23/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


