01The Rate a Thumb Riffles the Pages
On a stack of 12 loose pages the page index, pageIndex, rises FLIP_HZ (12) pages a second and replays a ball that bounces twice. The index is the whole part of the pages riffled so far, and the ball moves only 33.5px on average (55.9px at most) between pages, yet one full pass takes 1 second, and at 3 pages a second it takes 4 and the pages read one by one.
Best forThe smallest way to make motion from pages that each hold one still drawing
FLIP_HZpageIndexframeCount

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="Flipbook stage: left and right arrow keys or Space flip pages, Enter pauses, N steps once, R restarts">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="On the left sits the top page of a stack of twelve loose pages, and on the right a twelve-cell page index. When the page number climbs at twelve pages a second, a ball bounces twice across, and the bright cell moves along with the page. A bar below fills with the flip rate, and when it drops to three pages a second each page reads on its own"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">Flipbook demo</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
<button class="vj__step" data-role="step" type="button">Step</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 쪽 넘기기·모서리 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 파스텔 팔레트 — 면은 연한 색, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [172, 208, 246];
const BALL = [246, 184, 200];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
// 문구 틀의 {a}·{b} 자리에 값을 끼운다 — 화면 문구는 늘 이 틀 한 줄로 두고 로케일 사전이 통째로 바꾼다
function fill(s, a, b) { return s.replace('{a}', a).replace('{b}', b === undefined ? '' : b); }
function r1(v) { return String(Math.round(v * 10) / 10); }
// 읽어 주는 문장은 사람이 직접 하거나 멈춘 때만 숫자를 싣는다 — 자동으로 흐르는 동안은 한 줄이 그대로여서 스크린리더가 매 프레임 읽지 않는다
function calm() { return !(paused || manual || still); }
const FRAME_COUNT = 12;
const FLOOR_Y = 0.8;
const HOP_H = 0.58;
function makePages(n) {
const out = [];
for (let i = 0; i < n; i += 1) {
// 두 번 튀므로 한 번 튀는 사이는 반 바퀴 — b 는 한 번 튀는 안의 위치 0~1, h 는 높이(포물선)
const b = (i * 2 / n) % 1;
const h = 4 * b * (1 - b);
const sy = 0.8 + 0.2 * Math.min(1, h * 3);
out.push({ x: 0.14 + 0.72 * (i / (n - 1)), y: FLOOR_Y - h * HOP_H, sx: 2 - sy, sy: sy });
}
return out;
}
const PAGES = makePages(FRAME_COUNT);
// 한 쪽의 종이 — 흰 바탕과 바닥 선
function paperBox(R, a, edge) {
box(R.x, R.y, R.w, R.h, 5, PAPER, a);
outline(R.x, R.y, R.w, R.h, 5, 1.5, edge, 0.9 * a);
const fy = R.y + R.h * (FLOOR_Y + 0.085);
line(R.x + R.w * 0.06, fy, R.x + R.w * 0.94, fy, 1.5, mix(PAPER, INK2, 0.35), a);
}
// 쪽 안의 공 한 개 — 눌림은 바닥에 닿은 쪽에서 가로로 퍼진다
function pageBall(R, p, c, a, edge, shine) {
if (a <= 0.01) {
return;
}
const rr = R.w * 0.075;
const cx = R.x + R.w * p.x;
const cy = R.y + R.h * p.y;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(p.sx, p.sy);
ctx.beginPath();
ctx.arc(0, 0, rr, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
ctx.lineWidth = 1.4 / Math.max(p.sx, p.sy);
ctx.strokeStyle = rgba(edge, 0.85 * a);
ctx.stroke();
if (shine) {
ctx.beginPath();
ctx.arc(-rr * 0.3, -rr * 0.34, rr * 0.3, 0, TAU);
ctx.fillStyle = rgba([255, 255, 255], 0.45 * a);
ctx.fill();
}
ctx.restore();
}
// 한 쪽을 통째로 — 종이·공·쪽 번호
function drawPage(R, i, a, label) {
paperBox(R, a, INK2);
pageBall(R, PAGES[i], BALL, a, INK, true);
if (label) {
text(String(i + 1), R.x + R.w - 8, R.y + 10, INK2, a, 9, 700, 'right');
}
}
// 정수부 — 쪽 번호는 늘 이 함수를 지난다(소수 쪽 번호가 그려지지 않게)
function wrapIdx(n, count) { return ((Math.floor(n + 1e-9) % count) + count) % count; }
// 쪽 번호 띠 — 작은 칸 count 개, 지금 쪽이 밝다
function thumbs(R, count, cols, cur) {
const rows = Math.ceil(count / cols);
const gap = 4;
const cw = (R.w - gap * (cols - 1)) / cols;
const ch = (R.h - gap * (rows - 1)) / rows;
for (let i = 0; i < count; i += 1) {
const cell = { x: R.x + (i % cols) * (cw + gap), y: R.y + Math.floor(i / cols) * (ch + gap), w: cw, h: ch };
const on = i === cur;
box(cell.x, cell.y, cell.w, cell.h, 3, on ? TILES[2] : PAPER, on ? 1 : 0.9);
outline(cell.x, cell.y, cell.w, cell.h, 3, on ? 2 : 1, on ? INK : mix(PAPER, INK2, 0.4), 1);
const p = PAGES[i % PAGES.length];
disc(cell.x + cell.w * p.x, cell.y + cell.h * p.y, Math.min(cell.w, cell.h) * 0.13, mix(BALL, INK, 0.1), 1);
}
}
const SEG = 8;
const PERSP = 0.0022;
function edgeProject(spineX, a, len, bendRad) {
const out = [{ x: spineX, z: 0, s: 1 }];
let x = spineX;
let z = 0;
const dl = len / SEG;
for (let k = 1; k <= SEG; k += 1) {
const ph = a - bendRad * Math.sin(a) * (k / SEG);
x += dl * Math.cos(ph);
z += dl * Math.sin(ph);
out.push({ x: x, z: z, s: 1 / (1 - z * PERSP) });
}
return out;
}
// 투영한 쪽 한 장을 윗변·아랫변을 따라 다각형으로 칠한다. 칠한 쪽의 평균 깊이를 돌려준다(그리는 순서 정렬용)
function flapPath(pts, cy, hp) {
ctx.beginPath();
ctx.moveTo(pts[0].x, cy - hp / 2 * pts[0].s);
for (let k = 1; k < pts.length; k += 1) {
ctx.lineTo(pts[k].x, cy - hp / 2 * pts[k].s);
}
for (let k = pts.length - 1; k >= 0; k -= 1) {
ctx.lineTo(pts[k].x, cy + hp / 2 * pts[k].s);
}
ctx.closePath();
}
function drawFlap(pts, cy, hp, c, a) {
// 모서리가 앞을 향할수록(옆으로 누울수록) 밝고, 책등 쪽으로 서서 얇아질수록 어둡다
const span = Math.abs(pts[pts.length - 1].x - pts[0].x);
const lit = clamp(span / (pts.length > 1 ? Math.max(1, Math.abs(pts[0].x - pts[1].x) * SEG) : 1), 0, 1);
flapPath(pts, cy, hp);
ctx.fillStyle = rgba(mix(c, [120, 110, 160], 0.4 * (1 - lit)), a);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK2, 0.85 * a);
ctx.stroke();
}
function meanZ(pts) {
let s = 0;
for (const p of pts) {
s += p.z;
}
return s / pts.length;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 판 둘레로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 공 한 개 — 면, 왼쪽 위 하이라이트, 옅은 윤곽
function orb(x, y, r, c, a) {
if (r <= 0.3 || a <= 0) {
return;
}
disc(x, y, r, c, a);
if (r > 5) {
disc(x - r * 0.28, y - r * 0.32, r * 0.42, [255, 255, 255], 0.3 * a);
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(mix(c, INK, 0.28), 0.55 * a);
ctx.stroke();
}
function line(x0, y0, x1, y1, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.3 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 장면이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 처음 판의 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 애니메이션 없이 곧바로 끝 상태로 — 시간을 3초 흘려 움직임을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// Step — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 쪽 넘기기 — 사람이 손대면 시간표는 멈추고 쪽만 바뀐다. 멈춘 채·움직임 줄이기에서도 한 번의 입력이 화면에 바로 반영되게 한다
function navPage(d) {
takeOver();
onNav(d);
if (still || paused) {
settleStill();
}
showNow();
}
// 키보드 — 무대에 포커스가 있을 때만. 왼쪽·위 = Prev 쪽, 오른쪽·아래·Space = 다음 쪽, Enter·P = Pause, N = Step, R = 처음부터
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
navPage(-1);
}
else if (k === 'ArrowRight' || k === 'ArrowDown' || k === ' ') {
e.preventDefault();
navPage(1);
}
else if (k === 'Enter' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 다음 쪽(06 은 모서리 끌기라 따로 받는다)
pad.addEventListener('click', function () {
if (!CLICK_NAV) {
return;
}
navPage(1);
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 01 — 쪽 번호 pageIndex 는 쏟아낸 쪽 수 phase 의 정수 부분이다. phase 는 초당 rate 쪽씩 오르고 rate 는 FLIP_HZ 를 향해 오른다
const FLIP_HZ = 12;
const frameCount = FRAME_COUNT;
const LAP_S = 9.6;
const START_S = 3.2;
const REST_S = 4.0;
const CLICK_NAV = true;
const BOOK_R = { x: 28, y: 92, w: 170, h: 170 };
const GRID_R = { x: 258, y: 82, w: 198, h: 100 };
let phase = 0;
let rate = 0;
let rateTgt = 0;
let pageIndex = 0;
function setRate(v) { rateTgt = v; }
function build() {
phase = 0;
rate = 0;
rateTgt = 0;
pageIndex = 0;
script.push({ t: 0.3, f: function () { setRate(FLIP_HZ); } });
script.push({ t: 5.2, f: function () { setRate(3); } });
script.push({ t: 8.2, f: function () { setRate(0); } });
}
function update(dt) {
if (dt <= 0) {
return;
}
rate += (rateTgt - rate) * (1 - Math.exp(-6 * dt));
if (!manual) {
phase += rate * dt;
}
pageIndex = wrapIdx(phase, frameCount);
}
function drawStack() {
// 맨 위 쪽 뒤로 낱장 더미의 옆면이 비친다
for (let k = 4; k >= 1; k -= 1) {
box(BOOK_R.x + k * 3.5, BOOK_R.y + k * 3.5, BOOK_R.w, BOOK_R.h, 5, mix(PAPER, TILES[(k + 3) % 8], 0.55), 1);
outline(BOOK_R.x + k * 3.5, BOOK_R.y + k * 3.5, BOOK_R.w, BOOK_R.h, 5, 1, INK2, 0.5);
}
drawPage(BOOK_R, pageIndex, 1, true);
}
function draw() {
skyFill(BG_TOP, BG_BOT);
panelTags('Stack - top page', 'Page index');
bed(PANEL_L);
bed({ x: GRID_R.x - 12, y: PANEL_R.y, w: PANEL_R.w, h: PANEL_R.h });
drawStack();
thumbs(GRID_R, frameCount, 4, pageIndex);
// 넘기는 속도 막대 — FLIP_HZ 를 가득 찬 길이로
const bx = 258;
const bw = 198;
box(bx, 206, bw, 14, 7, PAPER, 1);
box(bx, 206, bw * clamp(rate / FLIP_HZ, 0, 1), 14, 7, TILES[1], 1);
outline(bx, 206, bw, 14, 7, 1.5, INK2, 1);
tag(fill('{a} pages/s', r1(rate)), bx + bw / 2, 240, INK, PAPER, 11, 'center');
tag(fill('Page number {a}', String(pageIndex + 1)), bx + bw / 2, 264, PAPER, INK, 10, 'center');
}
function statusText() {
if (calm()) {
return fill('Riffling the stack at {a} pages a second', String(FLIP_HZ));
}
return fill('Page {a} of {b}', String(pageIndex + 1), String(frameCount));
}
// 한 쪽씩 직접 넘긴다 — 속도는 0
function onNav(d) {
rate = 0;
rateTgt = 0;
phase = Math.floor(phase + 1e-9) + d;
pageIndex = wrapIdx(phase, frameCount);
}
function onTake() { rateTgt = 0; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FrameStackRiffleRate - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FrameStackRiffleRate.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FrameStackRiffleRate() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 파스텔 팔레트 — 면은 연한 색, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [172, 208, 246];
const BALL: RGB = [246, 184, 200];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
type Rect = { x: number; y: number; w: number; h: number };
// 문구 틀의 {a}·{b} 자리에 값을 끼운다 — 화면 문구는 늘 이 틀 한 줄로 두고 로케일 사전이 통째로 바꾼다
function fill(s: string, a: string, b?: string): string { return s.replace('{a}', a).replace('{b}', b === undefined ? '' : b); }
function r1(v: number): string { return String(Math.round(v * 10) / 10); }
// 읽어 주는 문장은 사람이 직접 하거나 멈춘 때만 숫자를 싣는다 — 자동으로 흐르는 동안은 한 줄이 그대로여서 스크린리더가 매 프레임 읽지 않는다
function calm(): boolean { return !(paused || manual || still); }
// ── 쪽 그림 ───────────────────────────────────────────────
// 한 쪽에 그린 그림 = 공 하나의 (가로·세로 위치 0~1, 눌림). 12쪽이 한 번의 튀어 오름 두 번을 이룬다
type Pg = { x: number; y: number; sx: number; sy: number };
const FRAME_COUNT = 12;
const FLOOR_Y = 0.8;
const HOP_H = 0.58;
function makePages(n: number): Pg[] {
const out: Pg[] = [];
for (let i = 0; i < n; i += 1) {
// 두 번 튀므로 한 번 튀는 사이는 반 바퀴 — b 는 한 번 튀는 안의 위치 0~1, h 는 높이(포물선)
const b = (i * 2 / n) % 1;
const h = 4 * b * (1 - b);
const sy = 0.8 + 0.2 * Math.min(1, h * 3);
out.push({ x: 0.14 + 0.72 * (i / (n - 1)), y: FLOOR_Y - h * HOP_H, sx: 2 - sy, sy: sy });
}
return out;
}
const PAGES: Pg[] = makePages(FRAME_COUNT);
// 한 쪽의 종이 — 흰 바탕과 바닥 선
function paperBox(R: Rect, a: number, edge: RGB): void {
box(R.x, R.y, R.w, R.h, 5, PAPER, a);
outline(R.x, R.y, R.w, R.h, 5, 1.5, edge, 0.9 * a);
const fy = R.y + R.h * (FLOOR_Y + 0.085);
line(R.x + R.w * 0.06, fy, R.x + R.w * 0.94, fy, 1.5, mix(PAPER, INK2, 0.35), a);
}
// 쪽 안의 공 한 개 — 눌림은 바닥에 닿은 쪽에서 가로로 퍼진다
function pageBall(R: Rect, p: Pg, c: RGB, a: number, edge: RGB, shine: boolean): void {
if (a <= 0.01) { return; }
const rr = R.w * 0.075;
const cx = R.x + R.w * p.x;
const cy = R.y + R.h * p.y;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(p.sx, p.sy);
ctx.beginPath();
ctx.arc(0, 0, rr, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
ctx.lineWidth = 1.4 / Math.max(p.sx, p.sy);
ctx.strokeStyle = rgba(edge, 0.85 * a);
ctx.stroke();
if (shine) {
ctx.beginPath();
ctx.arc(-rr * 0.3, -rr * 0.34, rr * 0.3, 0, TAU);
ctx.fillStyle = rgba([255, 255, 255], 0.45 * a);
ctx.fill();
}
ctx.restore();
}
// 한 쪽을 통째로 — 종이·공·쪽 번호
function drawPage(R: Rect, i: number, a: number, label: boolean): void {
paperBox(R, a, INK2);
pageBall(R, PAGES[i], BALL, a, INK, true);
if (label) { text(String(i + 1), R.x + R.w - 8, R.y + 10, INK2, a, 9, 700, 'right'); }
}
// 정수부 — 쪽 번호는 늘 이 함수를 지난다(소수 쪽 번호가 그려지지 않게)
function wrapIdx(n: number, count: number): number { return ((Math.floor(n + 1e-9) % count) + count) % count; }
// 쪽 번호 띠 — 작은 칸 count 개, 지금 쪽이 밝다
function thumbs(R: Rect, count: number, cols: number, cur: number): void {
const rows = Math.ceil(count / cols);
const gap = 4;
const cw = (R.w - gap * (cols - 1)) / cols;
const ch = (R.h - gap * (rows - 1)) / rows;
for (let i = 0; i < count; i += 1) {
const cell: Rect = { x: R.x + (i % cols) * (cw + gap), y: R.y + Math.floor(i / cols) * (ch + gap), w: cw, h: ch };
const on = i === cur;
box(cell.x, cell.y, cell.w, cell.h, 3, on ? TILES[2] : PAPER, on ? 1 : 0.9);
outline(cell.x, cell.y, cell.w, cell.h, 3, on ? 2 : 1, on ? INK : mix(PAPER, INK2, 0.4), 1);
const p = PAGES[i % PAGES.length];
disc(cell.x + cell.w * p.x, cell.y + cell.h * p.y, Math.min(cell.w, cell.h) * 0.13, mix(BALL, INK, 0.1), 1);
}
}
// ── 책등 투영 ─────────────────────────────────────────────
// 책등(세로 선) 에서 시작하는 쪽 한 장을 SEG 조각으로 잇는다. 쪽 각도 a 는 0 이 오른쪽에 누운 때, π 가 왼쪽에 누운 때.
// 조각 k 의 방향은 a 에서 끝쪽일수록 bendRad × sin(a) 만큼 뒤처진다(책등에 묶인 안쪽이 먼저 돌고 가장자리가 따라온다).
// x 는 방향의 코사인, z(보는 사람 쪽 깊이)는 사인을 쌓은 값이고, 가까울수록(z 가 클수록) 위아래가 PERSP 만큼 커진다
type Pt = { x: number; z: number; s: number };
const SEG = 8;
const PERSP = 0.0022;
function edgeProject(spineX: number, a: number, len: number, bendRad: number): Pt[] {
const out: Pt[] = [{ x: spineX, z: 0, s: 1 }];
let x = spineX;
let z = 0;
const dl = len / SEG;
for (let k = 1; k <= SEG; k += 1) {
const ph = a - bendRad * Math.sin(a) * (k / SEG);
x += dl * Math.cos(ph);
z += dl * Math.sin(ph);
out.push({ x: x, z: z, s: 1 / (1 - z * PERSP) });
}
return out;
}
// 투영한 쪽 한 장을 윗변·아랫변을 따라 다각형으로 칠한다. 칠한 쪽의 평균 깊이를 돌려준다(그리는 순서 정렬용)
function flapPath(pts: Pt[], cy: number, hp: number): void {
ctx.beginPath();
ctx.moveTo(pts[0].x, cy - hp / 2 * pts[0].s);
for (let k = 1; k < pts.length; k += 1) { ctx.lineTo(pts[k].x, cy - hp / 2 * pts[k].s); }
for (let k = pts.length - 1; k >= 0; k -= 1) { ctx.lineTo(pts[k].x, cy + hp / 2 * pts[k].s); }
ctx.closePath();
}
function drawFlap(pts: Pt[], cy: number, hp: number, c: RGB, a: number): void {
// 모서리가 앞을 향할수록(옆으로 누울수록) 밝고, 책등 쪽으로 서서 얇아질수록 어둡다
const span = Math.abs(pts[pts.length - 1].x - pts[0].x);
const lit = clamp(span / (pts.length > 1 ? Math.max(1, Math.abs(pts[0].x - pts[1].x) * SEG) : 1), 0, 1);
flapPath(pts, cy, hp);
ctx.fillStyle = rgba(mix(c, [120, 110, 160], 0.4 * (1 - lit)), a);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK2, 0.85 * a);
ctx.stroke();
}
function meanZ(pts: Pt[]): number {
let s = 0;
for (const p of pts) { s += p.z; }
return s / pts.length;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 판 둘레로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 공 한 개 — 면, 왼쪽 위 하이라이트, 옅은 윤곽
function orb(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.3 || a <= 0) { return; }
disc(x, y, r, c, a);
if (r > 5) { disc(x - r * 0.28, y - r * 0.32, r * 0.42, [255, 255, 255], 0.3 * a); }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(mix(c, INK, 0.28), 0.55 * a);
ctx.stroke();
}
function line(x0: number, y0: number, x1: number, y1: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.3 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 장면이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 처음 판의 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 애니메이션 없이 곧바로 끝 상태로 — 시간을 3초 흘려 움직임을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 쪽 넘기기 — 사람이 손대면 시간표는 멈추고 쪽만 바뀐다. 멈춘 채·움직임 줄이기에서도 한 번의 입력이 화면에 바로 반영되게 한다
function navPage(d: number): void {
takeOver();
onNav(d);
if (still || paused) { settleStill(); }
showNow();
}
// 키보드 — 무대에 포커스가 있을 때만. 왼쪽·위 = 앞 쪽, 오른쪽·아래·Space = 다음 쪽, Enter·P = 일시정지, N = 한 걸음, R = 처음부터
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
navPage(-1);
} else if (k === 'ArrowRight' || k === 'ArrowDown' || k === ' ') {
e.preventDefault();
navPage(1);
} else if (k === 'Enter' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 다음 쪽(06 은 모서리 끌기라 따로 받는다)
pad.addEventListener('click', function (): void {
if (!CLICK_NAV) { return; }
navPage(1);
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 01 — 쪽 번호 pageIndex 는 쏟아낸 쪽 수 phase 의 정수 부분이다. phase 는 초당 rate 쪽씩 오르고 rate 는 FLIP_HZ 를 향해 오른다
const FLIP_HZ = 12;
const frameCount = FRAME_COUNT;
const LAP_S = 9.6;
const START_S = 3.2;
const REST_S = 4.0;
const CLICK_NAV = true;
const BOOK_R: Rect = { x: 28, y: 92, w: 170, h: 170 };
const GRID_R: Rect = { x: 258, y: 82, w: 198, h: 100 };
let phase = 0;
let rate = 0;
let rateTgt = 0;
let pageIndex = 0;
function setRate(v: number): void { rateTgt = v; }
function build(): void {
phase = 0;
rate = 0;
rateTgt = 0;
pageIndex = 0;
script.push({ t: 0.3, f: function (): void { setRate(FLIP_HZ); } });
script.push({ t: 5.2, f: function (): void { setRate(3); } });
script.push({ t: 8.2, f: function (): void { setRate(0); } });
}
function update(dt: number): void {
if (dt <= 0) { return; }
rate += (rateTgt - rate) * (1 - Math.exp(-6 * dt));
if (!manual) { phase += rate * dt; }
pageIndex = wrapIdx(phase, frameCount);
}
function drawStack(): void {
// 맨 위 쪽 뒤로 낱장 더미의 옆면이 비친다
for (let k = 4; k >= 1; k -= 1) {
box(BOOK_R.x + k * 3.5, BOOK_R.y + k * 3.5, BOOK_R.w, BOOK_R.h, 5, mix(PAPER, TILES[(k + 3) % 8], 0.55), 1);
outline(BOOK_R.x + k * 3.5, BOOK_R.y + k * 3.5, BOOK_R.w, BOOK_R.h, 5, 1, INK2, 0.5);
}
drawPage(BOOK_R, pageIndex, 1, true);
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
panelTags('쪽 더미 — 맨 위 쪽', '쪽 번호표');
bed(PANEL_L);
bed({ x: GRID_R.x - 12, y: PANEL_R.y, w: PANEL_R.w, h: PANEL_R.h });
drawStack();
thumbs(GRID_R, frameCount, 4, pageIndex);
// 넘기는 속도 막대 — FLIP_HZ 를 가득 찬 길이로
const bx = 258;
const bw = 198;
box(bx, 206, bw, 14, 7, PAPER, 1);
box(bx, 206, bw * clamp(rate / FLIP_HZ, 0, 1), 14, 7, TILES[1], 1);
outline(bx, 206, bw, 14, 7, 1.5, INK2, 1);
tag(fill('초당 {a}쪽', r1(rate)), bx + bw / 2, 240, INK, PAPER, 11, 'center');
tag(fill('쪽 번호 {a}', String(pageIndex + 1)), bx + bw / 2, 264, PAPER, INK, 10, 'center');
}
function statusText(): string {
if (calm()) { return fill('쪽 더미를 초당 {a}쪽으로 넘기는 중', String(FLIP_HZ)); }
return fill('{b}쪽 중 {a}쪽', String(pageIndex + 1), String(frameCount));
}
// 한 쪽씩 직접 넘긴다 — 속도는 0
function onNav(d: number): void {
rate = 0;
rateTgt = 0;
phase = Math.floor(phase + 1e-9) + d;
pageIndex = wrapIdx(phase, frameCount);
}
function onTake(): void { rateTgt = 0; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'플립북 시연 무대 — 왼쪽·오른쪽 방향키와 Space 로 쪽 넘기기, Enter 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'왼쪽에는 열두 쪽 낱장 더미의 맨 위 쪽이, 오른쪽에는 열두 칸 쪽 번호표가 있다. 쪽 번호가 초당 열두 쪽 속도로 오르면 공이 두 번 튀며 지나가고, 칸의 밝은 표시가 쪽을 따라 옮겨 간다. 아래 막대가 넘기는 속도를 채우다가 초당 세 쪽으로 느려지면 쪽이 하나씩 따로 읽힌다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'플립북 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}01 The Rate a Thumb Riffles the Pages — Measured area 15.619% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Flipbook Animation: 9 Canvas Page Flip Scenes
The post this demo lives in
Flipbook Animation: 9 Canvas Page Flip ScenesHow does a flipbook make motion from a few drawings? 02 overlays faint neighboring pages, 05 measures where the flip rat See all demos in this post
If you like this effect - similar demos
Transitions09 Badge count inertia — a number on the icon that eases toward a changed value frame by frameCart Drawer UI Patterns: 9 Motion Techniques
Transitions05 App-style stack push9 CSS Page Transitions — No Library, Pure CSS
Transitions08 Circle reveal from the icon9 CSS Hamburger Menu Animations, No Library