08꽂이 막대에 나란히 걸린 영수증 아홉 장이 장마다 다른 길이의 진자로 흔들리다 짧은 장부터 가라앉는 보관 장면
장마다 진자 길이를 다르게 두고 같은 충격을 주면 위상이 어긋나며 흔들린다. 감쇠가 짧은 장일수록 커서 짧은 장부터 멈추고, 충격은 주기로 되풀이된다.
쓰임지출 내역 보관함·메모 꽂이 UI
SHEET_NLEN_MINDAMPKICK

<main class="stage">
<div class="rc is-demo" id="rc">
<canvas class="rc__canvas" data-role="canvas" width="480" height="300" aria-label="꽂이에 걸린 영수증 아홉 장이 길이마다 다른 진자로 흔들리다 짧은 장부터 멈추는 장면"></canvas>
<p class="rc__status" data-role="status" aria-live="polite">흔들리는 중</p>
<div class="rc__tools">
<button class="rc__btn" data-role="btn" type="button">툭 치기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.rc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.rc__canvas {
display: block;
width: 100%;
height: 100%;
}
.rc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.rc__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.rc__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.rc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.rc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.rc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.rc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.rc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rc { cursor: default; }
.rc__chip { cursor: default; }
}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);
}
.rc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.rc__canvas {
display: block;
width: 100%;
height: 100%;
}
.rc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.rc__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.rc__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.rc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.rc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.rc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.rc__btn {
font-size: 10px;
padding: 4px 8px;
}
.rc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc {
cursor: default;
}
.rc__chip {
cursor: default;
}
}(function () {
const root = document.getElementById("rc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 종이를 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
function fitCanvas() {
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
if (reduceQuery.matches) {
draw();
}
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
const SHEET_N = 9;
const LEN_MIN = 70;
const LEN_STEP = 22;
const LEN_REF = 160;
const DAMP = 0.9;
const KICK = 2;
const KICK_MS = 1800;
const GRAV = 3600;
const SHEET_W = 56;
const PIVOT_Y = 24;
const GAP_X = 40;
const TXT_KICK = '툭 쳤다';
const TXT_SWAY = '흔들리는 중';
const TXT_STILL = '가만히 걸려 있다';
const kickBtn = root.querySelector('[data-role="btn"]');
const ang = [];
const vel = [];
const len = [];
const tints = [PAPER, mix(PAPER, PINK, 0.28), mix(PAPER, SKY, 0.28), mix(PAPER, MINT, 0.28), mix(PAPER, LILAC, 0.3)];
for (let i = 0; i < SHEET_N; i += 1) {
ang.push(0);
vel.push(0);
len.push(LEN_MIN + i * LEN_STEP);
}
let clock = 0;
let nextKick = 200;
let sign = 1;
let barShift = 0;
let barVel = 0;
// 판 하나의 각도 가속 — 길이가 길수록 느리고, 짧은 판일수록 공기 저항이 크다
function accel(i, a, v) {
return -(GRAV / len[i]) * Math.sin(a) - DAMP * (LEN_REF / len[i]) * v;
}
function kick(dir) {
for (let i = 0; i < SHEET_N; i += 1) {
vel[i] += dir * KICK * (LEN_REF / len[i]);
}
barVel += dir * 60;
}
function step() {
clock += FIXED_DT;
const dt = FIXED_DT / 1000;
if (clock >= nextKick) {
kick(sign);
sign = -sign;
nextKick = clock + KICK_MS;
}
for (let i = 0; i < SHEET_N; i += 1) {
vel[i] += accel(i, ang[i], vel[i]) * dt;
ang[i] += vel[i] * dt;
}
barVel += (-90 * barShift - 9 * barVel) * dt;
barShift += barVel * dt;
}
function settle() {
for (let i = 0; i < SHEET_N; i += 1) {
ang[i] = 0;
vel[i] = 0;
}
status.textContent = TXT_STILL;
}
function drawSheet(i) {
const px = W / 2 + (i - (SHEET_N - 1) / 2) * GAP_X + barShift;
const L = len[i];
ctx.save();
ctx.translate(px, PIVOT_Y);
ctx.rotate(ang[i]);
ctx.fillStyle = rgba([10, 8, 22], 0.4);
ctx.fillRect(-SHEET_W / 2 + 5, 8, SHEET_W, L);
const t = tints[i % tints.length];
const g = ctx.createLinearGradient(-SHEET_W / 2, 0, SHEET_W / 2, 0);
g.addColorStop(0, rgba(mix(t, INK, 0.12), 1));
g.addColorStop(0.3, rgba(t, 1));
g.addColorStop(1, rgba(mix(t, INK, 0.06), 1));
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(-SHEET_W / 2, 0);
ctx.lineTo(SHEET_W / 2, 0);
ctx.lineTo(SHEET_W / 2, L);
for (let k = 7; k >= 0; k -= 1) {
ctx.lineTo(-SHEET_W / 2 + (SHEET_W * k) / 7, L + (k % 2 === 0 ? 0 : 5));
}
ctx.closePath();
ctx.fill();
// 글줄 막대 — 장마다 줄 수가 다르다
ctx.fillStyle = rgba(INK, 0.55);
const rows = Math.floor((L - 30) / 14);
for (let r = 0; r < rows; r += 1) {
const w = SHEET_W - 18 - ((r * 7 + i * 5) % 14);
ctx.fillRect(-SHEET_W / 2 + 9, 22 + r * 14, w, 3);
}
// 걸린 구멍
ctx.fillStyle = rgba(BG, 1);
ctx.beginPath();
ctx.arc(0, 9, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function draw() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
// 꽂이 막대
ctx.fillStyle = rgba(mix(BG, LILAC, 0.5), 1);
ctx.fillRect(34 + barShift, PIVOT_Y - 12, W - 68, 6);
// 긴 장을 뒤에 그린다
for (let i = SHEET_N - 1; i >= 0; i -= 1) {
drawSheet(i);
}
ctx.fillStyle = rgba(PEACH, 1);
for (let i = 0; i < SHEET_N; i += 1) {
ctx.beginPath();
ctx.arc(W / 2 + (i - (SHEET_N - 1) / 2) * GAP_X + barShift, PIVOT_Y - 9, 4.5, 0, Math.PI * 2);
ctx.fill();
}
}
function userKick() {
status.textContent = TXT_KICK;
if (reduceQuery.matches) {
return;
}
kick(sign);
sign = -sign;
nextKick = clock + KICK_MS;
}
if (kickBtn) {
kickBtn.addEventListener('click', userKick);
}
cv.addEventListener('pointerdown', userKick);
status.textContent = TXT_SWAY;
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (reduceQuery.matches) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// RecSpikeStackPendulumSway - vanilla 판(08-rec-spike-stack-pendulum-sway) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RecSpikeStackPendulumSway.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RecSpikeStackPendulumSway() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 종이를 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
function fitCanvas(): void {
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
if (reduceQuery.matches) { draw(); }
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
const SHEET_N = 9;
const LEN_MIN = 70;
const LEN_STEP = 22;
const LEN_REF = 160;
const DAMP = 0.9;
const KICK = 2;
const KICK_MS = 1800;
const GRAV = 3600;
const SHEET_W = 56;
const PIVOT_Y = 24;
const GAP_X = 40;
const TXT_KICK = '툭 쳤다';
const TXT_SWAY = '흔들리는 중';
const TXT_STILL = '가만히 걸려 있다';
const kickBtn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
const ang: number[] = [];
const vel: number[] = [];
const len: number[] = [];
const tints: RGB[] = [PAPER, mix(PAPER, PINK, 0.28), mix(PAPER, SKY, 0.28), mix(PAPER, MINT, 0.28), mix(PAPER, LILAC, 0.3)];
for (let i = 0; i < SHEET_N; i += 1) {
ang.push(0);
vel.push(0);
len.push(LEN_MIN + i * LEN_STEP);
}
let clock = 0;
let nextKick = 200;
let sign = 1;
let barShift = 0;
let barVel = 0;
// 판 하나의 각도 가속 — 길이가 길수록 느리고, 짧은 판일수록 공기 저항이 크다
function accel(i: number, a: number, v: number): number {
return -(GRAV / len[i]) * Math.sin(a) - DAMP * (LEN_REF / len[i]) * v;
}
function kick(dir: number): void {
for (let i = 0; i < SHEET_N; i += 1) { vel[i] += dir * KICK * (LEN_REF / len[i]); }
barVel += dir * 60;
}
function step(): void {
clock += FIXED_DT;
const dt = FIXED_DT / 1000;
if (clock >= nextKick) {
kick(sign);
sign = -sign;
nextKick = clock + KICK_MS;
}
for (let i = 0; i < SHEET_N; i += 1) {
vel[i] += accel(i, ang[i], vel[i]) * dt;
ang[i] += vel[i] * dt;
}
barVel += (-90 * barShift - 9 * barVel) * dt;
barShift += barVel * dt;
}
function settle(): void {
for (let i = 0; i < SHEET_N; i += 1) { ang[i] = 0; vel[i] = 0; }
status.textContent = TXT_STILL;
}
function drawSheet(i: number): void {
const px = W / 2 + (i - (SHEET_N - 1) / 2) * GAP_X + barShift;
const L = len[i];
ctx.save();
ctx.translate(px, PIVOT_Y);
ctx.rotate(ang[i]);
ctx.fillStyle = rgba([10, 8, 22], 0.4);
ctx.fillRect(-SHEET_W / 2 + 5, 8, SHEET_W, L);
const t = tints[i % tints.length];
const g = ctx.createLinearGradient(-SHEET_W / 2, 0, SHEET_W / 2, 0);
g.addColorStop(0, rgba(mix(t, INK, 0.12), 1));
g.addColorStop(0.3, rgba(t, 1));
g.addColorStop(1, rgba(mix(t, INK, 0.06), 1));
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(-SHEET_W / 2, 0);
ctx.lineTo(SHEET_W / 2, 0);
ctx.lineTo(SHEET_W / 2, L);
for (let k = 7; k >= 0; k -= 1) { ctx.lineTo(-SHEET_W / 2 + (SHEET_W * k) / 7, L + (k % 2 === 0 ? 0 : 5)); }
ctx.closePath();
ctx.fill();
// 글줄 막대 — 장마다 줄 수가 다르다
ctx.fillStyle = rgba(INK, 0.55);
const rows = Math.floor((L - 30) / 14);
for (let r = 0; r < rows; r += 1) {
const w = SHEET_W - 18 - ((r * 7 + i * 5) % 14);
ctx.fillRect(-SHEET_W / 2 + 9, 22 + r * 14, w, 3);
}
// 걸린 구멍
ctx.fillStyle = rgba(BG, 1);
ctx.beginPath();
ctx.arc(0, 9, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function draw(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
// 꽂이 막대
ctx.fillStyle = rgba(mix(BG, LILAC, 0.5), 1);
ctx.fillRect(34 + barShift, PIVOT_Y - 12, W - 68, 6);
// 긴 장을 뒤에 그린다
for (let i = SHEET_N - 1; i >= 0; i -= 1) { drawSheet(i); }
ctx.fillStyle = rgba(PEACH, 1);
for (let i = 0; i < SHEET_N; i += 1) {
ctx.beginPath();
ctx.arc(W / 2 + (i - (SHEET_N - 1) / 2) * GAP_X + barShift, PIVOT_Y - 9, 4.5, 0, Math.PI * 2);
ctx.fill();
}
}
function userKick(): void {
status.textContent = TXT_KICK;
if (reduceQuery.matches) { return; }
kick(sign);
sign = -sign;
nextKick = clock + KICK_MS;
}
if (kickBtn) { kickBtn.addEventListener('click', userKick); }
cv.addEventListener('pointerdown', userKick);
status.textContent = TXT_SWAY;
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (reduceQuery.matches) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("rc") + " is-demo"} ref={rootRef}>
<canvas className={cx("rc__canvas")} data-role="canvas" width={480} height={300} aria-label="꽂이에 걸린 영수증 아홉 장이 길이마다 다른 진자로 흔들리다 짧은 장부터 멈추는 장면"></canvas>
<p className={cx("rc__status")} data-role="status" aria-live="polite">흔들리는 중</p>
<div className={cx("rc__tools")}>
<button className={cx("rc__btn")} data-role="btn" type="button">툭 치기</button>
</div>
</div>
);
}08 꽂이 막대에 나란히 걸린 영수증 아홉 장이 장마다 다른 길이의 진자로 흔들리다 짧은 장부터 가라앉는 보관 장면 — 측정 누적면적 41.086% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css receipt 9가지 — 한 줄씩 인쇄·절취선 찢기·바코드 스캔·나눠 내기
이 데모가 속한 글
css receipt 9가지 — 한 줄씩 인쇄·절취선 찢기·바코드 스캔·나눠 내기css receipt 한 장 안에는 인쇄 머리의 떨림, 종이의 말림, 합계의 굴림이 겹쳐 있다 — 01번은 줄마다 밀리는 거리에 떨림을 얹고, 06번은 롤에서 풀리는 종이의 원통 음영을 픽셀마다 계산한다. 가게 이름 이 글의 데모 전부 보기


