종이 넘기는 애니메이션 9가지 — 캔버스 페이지 컬
종이 넘기는 애니메이션은 캔버스 계산만으로 종이를 접고 마는 아홉 가지 패턴이다. 이 페이지 컬은 라이브러리 없이도 앞으로 계속 이렇게 움직일 것이다 — 01은 포인터가 닿은 자리부터 말리고, 07은 흔들림 값만큼 비행기가 기울며, 09는 그 둘을 한 책상 장면으로 합쳐 보여줄 것이다.
01 page-curl-corner-drag
02 page-flip-shadow-fold-line
03 paper-stack-multipage-flip
04 book-open-gutter-bend-shade
05 paper-fold-crease-origami-angle
06 paper-texture-crumple-noise-shade
07 paper-airplane-flight-arc-drift
08 paper-doll-joint-swing-chain
09 page-turn-hero-desk-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 포인터로 당기면 모서리부터 말리는 페이지 컬
- 02 접힘선을 따라 그림자가 계산되는 페이지 플립
- 03 여러 장이 차례로 넘어가는 책 스택 플립
- 04 책 중심 골 곡률로 어두워지는 펼침 장면
- 05 접선 각도로 꺾이는 오리가미 폴드
- 06 구겨진 표면이 노이즈로 음영지는 종이 질감
- 07 포물선과 흔들림 값으로 나는 종이비행기
- 08 관절 값이 이어져 흔들리는 종이 인형
- 09 위 장면을 한 책상 위에 모은 페이지 넘김 히어로
종이라는 재질감은 어디서 오나 — 아홉 계산이 갈리는 지점
아홉 장면 모두 <canvas> 위에 매 프레임 fillRect·createLinearGradient만 다시 그린다. 종이처럼 보이는 이유는 이미지가 아니라 각도(theta)의 사인·코사인, 노이즈 격자, 관절 사슬 같은 값 하나가 명도와 위치를 계산해서다. 아래 아홉 개는 그 값이 어느 자리에 꽂히는지를 하나씩 보여준다.
01포인터로 당기면 모서리부터 말리는 페이지 컬
모서리를 당긴 거리(dx)를 CURL_RADIUS_PX(60)로 나눈 값이 회전각(theta)이 되고, 그 각의 사인·코사인으로 원기둥에 감듯 앞뒤 면의 위치·밝기를 계산한다. 짧게 당기면 살짝만 말리고, 거리가 반경을 넘어서면 뒷면 색이 드러날 만큼 완전히 뒤집힌다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">포인터로 당기면 모서리부터 말리는 페이지 컬</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
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;
touch-action: none;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { 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);
}
.vd {
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);
touch-action: none;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200;
var PAGE_L = 46, PAGE_T = 22, PAGE_R = 274, PAGE_B = 176;
var CURL_RADIUS_PX = 60;
var STRIP_W = 4;
var SCENE_BG = '#17141a';
var PAPER = '#fff7e6';
var PAPER_BACK = '#caa96b';
var HANDLE_COLOR = '#ffd23f';
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41301);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function shadeHex(hex, f) {
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
var rest = { x: PAGE_R, y: PAGE_B };
var pulled = { x: PAGE_L + 60, y: PAGE_T + 26 };
var drag = { x: rest.x, y: rest.y };
var target = { x: rest.x, y: rest.y };
var pointerActive = false;
var keys = { left: false, right: false, up: false, down: false };
var CYCLE_MS = 1050;
function autoplay(tMs) {
if (pointerActive || reduce) return;
var p = (tMs % CYCLE_MS) / CYCLE_MS;
var w = p < 0.5 ? p * 2 : (1 - p) * 2; // 삼각파 0→1→0, 2초 창에 2회 왕복
target.x = rest.x + (pulled.x - rest.x) * w;
target.y = rest.y + (pulled.y - rest.y) * w;
}
function dragStep(dt) {
var k = 1 - Math.pow(1 - 0.22, dt * 60);
drag.x += (target.x - drag.x) * k;
drag.y += (target.y - drag.y) * k;
}
function keyStep(dt) {
var v = 90 * dt;
if (keys.left) target.x -= v;
if (keys.right) target.x += v;
if (keys.up) target.y -= v;
if (keys.down) target.y += v;
target.x = clamp(target.x, PAGE_L, PAGE_R);
target.y = clamp(target.y, PAGE_T, PAGE_B);
}
function foldTheta(dx) { return dx / CURL_RADIUS_PX; }
function curlPage() {
var foldX = clamp(drag.x, PAGE_L, PAGE_R);
var skew = (drag.y - PAGE_B) / (PAGE_B - PAGE_T); // 위로 당길수록 음수 — 컬 축의 기울기
var h = PAGE_B - PAGE_T;
ctx.fillStyle = PAPER;
ctx.fillRect(PAGE_L, PAGE_T, Math.max(0, foldX - PAGE_L), h);
var maxDx = Math.max(1, PAGE_R - foldX);
for (var x = foldX; x <= PAGE_R + STRIP_W; x += STRIP_W) {
var dx = Math.min(x - foldX, maxDx);
var theta = foldTheta(dx);
if (theta > Math.PI + 0.15) continue;
var sxp = foldX + Math.sin(Math.min(theta, Math.PI)) * CURL_RADIUS_PX;
var front = Math.cos(theta) > 0;
var lift = (dx / maxDx) * skew * 30;
ctx.fillStyle = front ? shadeHex(PAPER, 0.86 + 0.14 * Math.cos(theta)) : shadeHex(PAPER_BACK, 0.5 + 0.3 * (-Math.cos(theta)));
ctx.fillRect(sxp, PAGE_T + lift, STRIP_W + 1, h);
}
var depth = clamp(maxDx / CURL_RADIUS_PX, 0, 1.6);
if (depth > 0.02) {
var g = ctx.createLinearGradient(foldX - 26, 0, foldX + 6, 0);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,' + clamp(0.1 + depth * 0.24, 0, 0.42) + ')');
ctx.fillStyle = g;
ctx.fillRect(foldX - 26, PAGE_T, 32, h);
}
}
function draw() {
ctx.fillStyle = SCENE_BG;
ctx.fillRect(0, 0, LW, LH);
ctx.fillStyle = 'rgba(0,0,0,.32)';
ctx.fillRect(PAGE_L - 6, PAGE_B + 3, PAGE_R - PAGE_L + 14, 6);
curlPage();
ctx.fillStyle = 'rgba(255,209,63,.9)';
ctx.beginPath(); ctx.arc(drag.x, drag.y, 4, 0, Math.PI * 2); ctx.fill();
}
function update(dt, tMs) {
autoplay(tMs);
keyStep(dt);
dragStep(dt);
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
draw();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) {
root.classList.remove('is-demo');
drag.x = pulled.x; drag.y = pulled.y; target.x = pulled.x; target.y = pulled.y;
}
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
var relY = (t0.clientY - r.top) / r.height * LH;
target.x = clamp(relX, PAGE_L, PAGE_R);
target.y = clamp(relY, PAGE_T, PAGE_B);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { dragStep(1); render(); } });
wrap.addEventListener('pointermove', function (e) { if (!pointerActive) return; calcFromEvent(e); if (reduce) { dragStep(1); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') keys.left = true;
else if (e.key === 'ArrowRight') keys.right = true;
else if (e.key === 'ArrowUp') keys.up = true;
else if (e.key === 'ArrowDown') keys.down = true;
else return;
pointerActive = true;
if (reduce) { keyStep(0.05); dragStep(1); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
else if (e.key === 'ArrowUp') keys.up = false;
else if (e.key === 'ArrowDown') keys.down = false;
if (!keys.left && !keys.right && !keys.up && !keys.down) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PageCurlCornerDrag.module.scss";
export default function PageCurlCornerDrag() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200;
const PAGE_L = 46, PAGE_T = 22, PAGE_R = 274, PAGE_B = 176;
const CURL_RADIUS_PX = 60;
const STRIP_W = 4;
const SCENE_BG = "#17141a";
const PAPER = "#fff7e6";
const PAPER_BACK = "#caa96b";
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41301);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function shadeHex(hex: string, f: number): string {
let r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return "rgb(" + r + "," + g + "," + b + ")";
}
const rest = { x: PAGE_R, y: PAGE_B };
const pulled = { x: PAGE_L + 60, y: PAGE_T + 26 };
const drag = { x: rest.x, y: rest.y };
const target = { x: rest.x, y: rest.y };
let pointerActive = false;
const keys = { left: false, right: false, up: false, down: false };
const CYCLE_MS = 1050;
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const p = (tMs % CYCLE_MS) / CYCLE_MS;
const w = p < 0.5 ? p * 2 : (1 - p) * 2;
target.x = rest.x + (pulled.x - rest.x) * w;
target.y = rest.y + (pulled.y - rest.y) * w;
}
function dragStep(dt: number): void {
const k = 1 - Math.pow(1 - 0.22, dt * 60);
drag.x += (target.x - drag.x) * k;
drag.y += (target.y - drag.y) * k;
}
function keyStep(dt: number): void {
const v = 90 * dt;
if (keys.left) target.x -= v;
if (keys.right) target.x += v;
if (keys.up) target.y -= v;
if (keys.down) target.y += v;
target.x = clamp(target.x, PAGE_L, PAGE_R);
target.y = clamp(target.y, PAGE_T, PAGE_B);
}
function foldTheta(dx: number): number { return dx / CURL_RADIUS_PX; }
function curlPage(): void {
const foldX = clamp(drag.x, PAGE_L, PAGE_R);
const skew = (drag.y - PAGE_B) / (PAGE_B - PAGE_T);
const h = PAGE_B - PAGE_T;
ctx!.fillStyle = PAPER;
ctx!.fillRect(PAGE_L, PAGE_T, Math.max(0, foldX - PAGE_L), h);
const maxDx = Math.max(1, PAGE_R - foldX);
for (let x = foldX; x <= PAGE_R + STRIP_W; x += STRIP_W) {
const dx = Math.min(x - foldX, maxDx);
const theta = foldTheta(dx);
if (theta > Math.PI + 0.15) continue;
const sxp = foldX + Math.sin(Math.min(theta, Math.PI)) * CURL_RADIUS_PX;
const front = Math.cos(theta) > 0;
const lift = (dx / maxDx) * skew * 30;
ctx!.fillStyle = front ? shadeHex(PAPER, 0.86 + 0.14 * Math.cos(theta)) : shadeHex(PAPER_BACK, 0.5 + 0.3 * (-Math.cos(theta)));
ctx!.fillRect(sxp, PAGE_T + lift, STRIP_W + 1, h);
}
const depth = clamp(maxDx / CURL_RADIUS_PX, 0, 1.6);
if (depth > 0.02) {
const g = ctx!.createLinearGradient(foldX - 26, 0, foldX + 6, 0);
g.addColorStop(0, "rgba(0,0,0,0)");
g.addColorStop(1, "rgba(0,0,0," + clamp(0.1 + depth * 0.24, 0, 0.42) + ")");
ctx!.fillStyle = g;
ctx!.fillRect(foldX - 26, PAGE_T, 32, h);
}
}
function draw(): void {
ctx!.fillStyle = SCENE_BG;
ctx!.fillRect(0, 0, LW, LH);
ctx!.fillStyle = "rgba(0,0,0,.32)";
ctx!.fillRect(PAGE_L - 6, PAGE_B + 3, PAGE_R - PAGE_L + 14, 6);
curlPage();
ctx!.fillStyle = "rgba(255,209,63,.9)";
ctx!.beginPath(); ctx!.arc(drag.x, drag.y, 4, 0, Math.PI * 2); ctx!.fill();
}
function update(dt: number, tMs: number): void {
autoplay(tMs);
keyStep(dt);
dragStep(dt);
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
draw();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) {
root.classList.remove("is-demo");
drag.x = pulled.x; drag.y = pulled.y; target.x = pulled.x; target.y = pulled.y;
}
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
const relY = ((e.clientY - r.top) / r.height) * LH;
target.x = clamp(relX, PAGE_L, PAGE_R);
target.y = clamp(relY, PAGE_T, PAGE_B);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { dragStep(1); render(); } }
function onPointerMove(e: PointerEvent): void { if (!pointerActive) return; calcFromEvent(e); if (reduce) { dragStep(1); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = true;
else if (e.key === "ArrowRight") keys.right = true;
else if (e.key === "ArrowUp") keys.up = true;
else if (e.key === "ArrowDown") keys.down = true;
else return;
pointerActive = true;
if (reduce) { keyStep(0.05); dragStep(1); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
else if (e.key === "ArrowUp") keys.up = false;
else if (e.key === "ArrowDown") keys.down = false;
if (!keys.left && !keys.right && !keys.up && !keys.down) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>포인터로 당기면 모서리부터 말리는 페이지 컬</p>
</div>
</main>
);
}01 · 포인터로 당기면 모서리부터 말리는 페이지 컬 — 측정 누적면적 35.251% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (var x = foldX; x <= PAGE_R + STRIP_W; x += STRIP_W) {
var dx = Math.min(x - foldX, maxDx);
var theta = foldTheta(dx);
if (theta > Math.PI + 0.15) continue;
var sxp = foldX + Math.sin(Math.min(theta, Math.PI)) * CURL_RADIUS_PX;
var front = Math.cos(theta) > 0;
var lift = (dx / maxDx) * skew * 30;
ctx.fillStyle = front ? shadeHex(PAPER, 0.86 + 0.14 * Math.cos(theta)) : shadeHex(PAPER_BACK, 0.5 + 0.3 * (-Math.cos(theta)));
ctx.fillRect(sxp, PAGE_T + lift, STRIP_W + 1, h);
}
01 항목은 누적면적 35.3%·강도 평균 90.2·23프레임 중 23프레임에서 움직였다.
02접힘선을 따라 그림자가 계산되는 페이지 플립
접힘선(FOLD_X)에서 각도(angle)가 0부터 π까지 도는 동안 ctx.scale(Math.cos(angle), 1)로 페이지 폭을 눌러 두께를 표현하고, 같은 각의 사인 값만큼 그림자 폭과 진하기를 함께 키운다. 각도가 π/2를 지나면 뒷면 색이 채워져 방향이 뒤집힌다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">접힘선을 따라 그림자가 계산되는 페이지 플립</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200;
var BOOK_L = 96, BOOK_T = 26, BOOK_R = 244, BOOK_B = 176;
var FOLD_X = BOOK_L;
var SCENE_BG = '#17141a';
var BACK_PAGE = '#e7dcc2';
var FRONT_PAGE = '#fff7e6';
var FRONT_PAGE_BACK = '#caa96b';
var LINE_COLOR = 'rgba(23,20,26,.28)';
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41302);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function drawLines(x, y, w, h, color) {
ctx.strokeStyle = color;
ctx.lineWidth = 1.4;
for (var i = 0; i < 5; i += 1) {
var ly = y + h * (0.18 + i * 0.16);
ctx.beginPath();
ctx.moveTo(x + w * 0.08, ly);
ctx.lineTo(x + w * (0.92 - (i % 2) * 0.18), ly);
ctx.stroke();
}
}
var FLIP_MS = 900;
var angle = 0;
function flipAngle(tMs) {
var p = (tMs % FLIP_MS) / FLIP_MS; // 0→1, 2.2회/2초
var w = p < 0.5 ? p * 2 : (1 - p) * 2; // 삼각파
return w * Math.PI;
}
function pageFlip() {
var w = BOOK_R - BOOK_L, h = BOOK_B - BOOK_T;
ctx.fillStyle = BACK_PAGE;
ctx.fillRect(BOOK_L, BOOK_T, w, h);
drawLines(BOOK_L, BOOK_T, w, h, LINE_COLOR);
var c = Math.cos(angle);
var frontFacing = c >= 0;
var scaleW = Math.max(0.001, Math.abs(c)) * w;
ctx.save();
ctx.translate(FOLD_X, 0);
ctx.scale(c === 0 ? 0.001 : c, 1);
ctx.fillStyle = frontFacing ? FRONT_PAGE : FRONT_PAGE_BACK;
ctx.fillRect(0, BOOK_T, w, h);
if (frontFacing) drawLines(6, BOOK_T, w - 12, h, LINE_COLOR);
ctx.restore();
var shadowW = 6 + Math.abs(Math.sin(angle)) * 46;
var shadowA = 0.08 + Math.abs(Math.sin(angle)) * 0.34;
var gx = FOLD_X + scaleW * (frontFacing ? 1 : -1);
var dir = frontFacing ? -1 : 1;
var g = ctx.createLinearGradient(gx, 0, gx + shadowW * dir, 0);
g.addColorStop(0, 'rgba(0,0,0,' + shadowA + ')');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(Math.min(gx, gx + shadowW * dir), BOOK_T, shadowW, h);
}
function draw() {
ctx.fillStyle = SCENE_BG;
ctx.fillRect(0, 0, LW, LH);
ctx.fillStyle = 'rgba(0,0,0,.3)';
ctx.fillRect(BOOK_L - 6, BOOK_B + 3, BOOK_R - BOOK_L + 14, 6);
ctx.strokeStyle = 'rgba(255,247,230,.5)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(FOLD_X, BOOK_T - 4); ctx.lineTo(FOLD_X, BOOK_B + 4); ctx.stroke();
pageFlip();
}
function update(dt, tMs) {
if (reduce) return;
angle = flipAngle(tMs);
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
draw();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) {
root.classList.remove('is-demo');
angle = Math.PI / 2;
}
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PageFlipShadowFoldLine.module.scss";
export default function PageFlipShadowFoldLine() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200;
const BOOK_L = 96, BOOK_T = 26, BOOK_R = 244, BOOK_B = 176;
const FOLD_X = BOOK_L;
const SCENE_BG = "#17141a";
const BACK_PAGE = "#e7dcc2";
const FRONT_PAGE = "#fff7e6";
const FRONT_PAGE_BACK = "#caa96b";
const LINE_COLOR = "rgba(23,20,26,.28)";
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41302);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function drawLines(x: number, y: number, w: number, h: number, color: string): void {
ctx!.strokeStyle = color;
ctx!.lineWidth = 1.4;
for (let i = 0; i < 5; i += 1) {
const ly = y + h * (0.18 + i * 0.16);
ctx!.beginPath();
ctx!.moveTo(x + w * 0.08, ly);
ctx!.lineTo(x + w * (0.92 - (i % 2) * 0.18), ly);
ctx!.stroke();
}
}
const FLIP_MS = 900;
let angle = 0;
function flipAngle(tMs: number): number {
const p = (tMs % FLIP_MS) / FLIP_MS;
const w = p < 0.5 ? p * 2 : (1 - p) * 2;
return w * Math.PI;
}
function pageFlip(): void {
const w = BOOK_R - BOOK_L, h = BOOK_B - BOOK_T;
ctx!.fillStyle = BACK_PAGE;
ctx!.fillRect(BOOK_L, BOOK_T, w, h);
drawLines(BOOK_L, BOOK_T, w, h, LINE_COLOR);
const c = Math.cos(angle);
const frontFacing = c >= 0;
const scaleW = Math.max(0.001, Math.abs(c)) * w;
ctx!.save();
ctx!.translate(FOLD_X, 0);
ctx!.scale(c === 0 ? 0.001 : c, 1);
ctx!.fillStyle = frontFacing ? FRONT_PAGE : FRONT_PAGE_BACK;
ctx!.fillRect(0, BOOK_T, w, h);
if (frontFacing) drawLines(6, BOOK_T, w - 12, h, LINE_COLOR);
ctx!.restore();
const shadowW = 6 + Math.abs(Math.sin(angle)) * 46;
const shadowA = 0.08 + Math.abs(Math.sin(angle)) * 0.34;
const gx = FOLD_X + scaleW * (frontFacing ? 1 : -1);
const dir = frontFacing ? -1 : 1;
const g = ctx!.createLinearGradient(gx, 0, gx + shadowW * dir, 0);
g.addColorStop(0, "rgba(0,0,0," + shadowA + ")");
g.addColorStop(1, "rgba(0,0,0,0)");
ctx!.fillStyle = g;
ctx!.fillRect(Math.min(gx, gx + shadowW * dir), BOOK_T, shadowW, h);
}
function draw(): void {
ctx!.fillStyle = SCENE_BG;
ctx!.fillRect(0, 0, LW, LH);
ctx!.fillStyle = "rgba(0,0,0,.3)";
ctx!.fillRect(BOOK_L - 6, BOOK_B + 3, BOOK_R - BOOK_L + 14, 6);
ctx!.strokeStyle = "rgba(255,247,230,.5)";
ctx!.lineWidth = 2;
ctx!.beginPath(); ctx!.moveTo(FOLD_X, BOOK_T - 4); ctx!.lineTo(FOLD_X, BOOK_B + 4); ctx!.stroke();
pageFlip();
}
function update(dt: number, tMs: number): void {
if (reduce) return;
angle = flipAngle(tMs);
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
draw();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) {
root.classList.remove("is-demo");
angle = Math.PI / 2;
}
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>접힘선을 따라 그림자가 계산되는 페이지 플립</p>
</div>
</main>
);
}02 · 접힘선을 따라 그림자가 계산되는 페이지 플립 — 측정 누적면적 41.608% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var c = Math.cos(angle);
var frontFacing = c >= 0;
var scaleW = Math.max(0.001, Math.abs(c)) * w;
ctx.save();
ctx.translate(FOLD_X, 0);
ctx.scale(c === 0 ? 0.001 : c, 1);
ctx.fillStyle = frontFacing ? FRONT_PAGE : FRONT_PAGE_BACK;
ctx.fillRect(0, BOOK_T, w, h);
if (frontFacing) drawLines(6, BOOK_T, w - 12, h, LINE_COLOR);
ctx.restore();
var shadowW = 6 + Math.abs(Math.sin(angle)) * 46;
var shadowA = 0.08 + Math.abs(Math.sin(angle)) * 0.34;
var gx = FOLD_X + scaleW * (frontFacing ? 1 : -1);
var dir = frontFacing ? -1 : 1;
var g = ctx.createLinearGradient(gx, 0, gx + shadowW * dir, 0);
g.addColorStop(0, 'rgba(0,0,0,' + shadowA + ')');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(Math.min(gx, gx + shadowW * dir), BOOK_T, shadowW, h);
}
02 항목은 누적면적 41.6%·강도 평균 73.4·23프레임 중 20프레임에서 움직였다.
03여러 장이 차례로 넘어가는 책 스택 플립
남은 장 수(remaining)에서 지금 넘어가는 한 장을 뺀 값을 stackThickness가 2.2배로 늘려, 쌓인 종이 두께만큼 그림자 사각형을 오른쪽 아래로 밀어낸다. 다섯 장(STACK_TOTAL)을 다 넘기면 remaining이 다시 처음 값으로 돌아가 순환한다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">여러 장이 차례로 넘어가는 책 스택 플립</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.vd {
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;
cursor: pointer;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: pointer; }
}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);
}
.vd {
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);
cursor: pointer;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: pointer;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200;
var BOOK_L = 96, BOOK_T = 24, BOOK_R = 250, BOOK_B = 176;
var FOLD_X = BOOK_L;
var STACK_TOTAL = 5;
var SCENE_BG = '#17141a';
var PAGE_COLORS = ['#fff7e6', '#f6eede', '#efe4cd', '#e7dcc2'];
var PAGE_BACK = '#caa96b';
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41303);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
var remaining = STACK_TOTAL; // 아직 안 넘긴 페이지 수
var flipping = false;
var angle = 0;
var FLIP_MS = 460;
var flipT = 0;
function stackThickness(n) { return clamp(n, 0, STACK_TOTAL) * 2.2; }
function startFlip() {
if (flipping || remaining <= 0) return;
flipping = true; flipT = 0;
}
function nextPage() {
if (flipping || remaining <= 0) { remaining = STACK_TOTAL; return; }
startFlip();
}
function stepFlip(dt) {
if (!flipping) return;
flipT += dt * 1000;
var p = clamp(flipT / FLIP_MS, 0, 1);
angle = p * Math.PI;
if (p >= 1) {
flipping = false; angle = 0;
remaining -= 1;
if (remaining <= 0) remaining = STACK_TOTAL; // 다 넘기면 처음부터
}
}
var AUTO_PERIOD = 620;
var autoTimer = 0;
function autoplay(dt) {
if (pointerActive || reduce) return;
autoTimer += dt * 1000;
if (autoTimer >= AUTO_PERIOD) { autoTimer = 0; nextPage(); }
}
function drawStackShadow() {
var th = stackThickness(remaining - (flipping ? 1 : 0));
ctx.fillStyle = 'rgba(23,20,26,.5)';
ctx.fillRect(BOOK_L + th, BOOK_T + th, BOOK_R - BOOK_L, BOOK_B - BOOK_T);
}
function drawLines(x, y, w, h) {
ctx.strokeStyle = 'rgba(23,20,26,.24)';
ctx.lineWidth = 1.4;
for (var i = 0; i < 4; i += 1) {
var ly = y + h * (0.2 + i * 0.18);
ctx.beginPath(); ctx.moveTo(x + w * 0.1, ly); ctx.lineTo(x + w * 0.9, ly); ctx.stroke();
}
}
function pageAt(color) {
var w = BOOK_R - BOOK_L, h = BOOK_B - BOOK_T;
ctx.fillStyle = color;
ctx.fillRect(BOOK_L, BOOK_T, w, h);
drawLines(BOOK_L, BOOK_T, w, h);
}
function draw() {
ctx.fillStyle = SCENE_BG;
ctx.fillRect(0, 0, LW, LH);
drawStackShadow();
var w = BOOK_R - BOOK_L, h = BOOK_B - BOOK_T;
var belowIdx = Math.max(0, STACK_TOTAL - remaining) % PAGE_COLORS.length;
pageAt(PAGE_COLORS[(belowIdx + 1) % PAGE_COLORS.length]);
if (flipping) {
var c = Math.cos(angle);
var frontFacing = c >= 0;
ctx.save();
ctx.translate(FOLD_X, 0);
ctx.scale(c === 0 ? 0.001 : c, 1);
ctx.fillStyle = frontFacing ? PAGE_COLORS[belowIdx] : PAGE_BACK;
ctx.fillRect(0, BOOK_T, w, h);
if (frontFacing) drawLines(6, BOOK_T, w - 12, h);
ctx.restore();
var shadowA = 0.06 + Math.abs(Math.sin(angle)) * 0.3;
ctx.fillStyle = 'rgba(0,0,0,' + shadowA + ')';
ctx.fillRect(FOLD_X, BOOK_T, 36, h);
} else if (remaining > 0) {
pageAt(PAGE_COLORS[belowIdx]);
}
ctx.fillStyle = 'rgba(255,247,230,.9)';
ctx.font = '11px sans-serif';
ctx.fillText(String(remaining) + ' / ' + String(STACK_TOTAL), BOOK_R - 34, BOOK_T - 8);
}
var pointerActive = false;
var pointerCooldown = 0;
function update(dt, tMs) {
stepFlip(dt);
if (pointerCooldown > 0) { pointerCooldown -= dt * 1000; if (pointerCooldown <= 0) pointerActive = false; }
autoplay(dt);
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
draw();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); remaining = 3; }
function onClick() {
pointerActive = true; pointerCooldown = 1400;
nextPage();
if (reduce) render();
}
wrap.addEventListener('click', onClick);
wrap.setAttribute('tabindex', '0');
wrap.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowRight') { e.preventDefault(); onClick(); }
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PaperStackMultipageFlip.module.scss";
export default function PaperStackMultipageFlip() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200;
const BOOK_L = 96, BOOK_T = 24, BOOK_R = 250, BOOK_B = 176;
const FOLD_X = BOOK_L;
const STACK_TOTAL = 5;
const SCENE_BG = "#17141a";
const PAGE_COLORS = ["#fff7e6", "#f6eede", "#efe4cd", "#e7dcc2"];
const PAGE_BACK = "#caa96b";
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41303);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
let remaining = STACK_TOTAL;
let flipping = false;
let angle = 0;
const FLIP_MS = 460;
let flipT = 0;
function stackThickness(n: number): number { return clamp(n, 0, STACK_TOTAL) * 2.2; }
function startFlip(): void {
if (flipping || remaining <= 0) return;
flipping = true; flipT = 0;
}
function nextPage(): void {
if (flipping || remaining <= 0) { remaining = STACK_TOTAL; return; }
startFlip();
}
function stepFlip(dt: number): void {
if (!flipping) return;
flipT += dt * 1000;
const p = clamp(flipT / FLIP_MS, 0, 1);
angle = p * Math.PI;
if (p >= 1) {
flipping = false; angle = 0;
remaining -= 1;
if (remaining <= 0) remaining = STACK_TOTAL;
}
}
const AUTO_PERIOD = 620;
let autoTimer = 0;
function autoplay(dt: number): void {
if (pointerActive || reduce) return;
autoTimer += dt * 1000;
if (autoTimer >= AUTO_PERIOD) { autoTimer = 0; nextPage(); }
}
function drawStackShadow(): void {
const th = stackThickness(remaining - (flipping ? 1 : 0));
ctx!.fillStyle = "rgba(23,20,26,.5)";
ctx!.fillRect(BOOK_L + th, BOOK_T + th, BOOK_R - BOOK_L, BOOK_B - BOOK_T);
}
function drawLines(x: number, y: number, w: number, h: number): void {
ctx!.strokeStyle = "rgba(23,20,26,.24)";
ctx!.lineWidth = 1.4;
for (let i = 0; i < 4; i += 1) {
const ly = y + h * (0.2 + i * 0.18);
ctx!.beginPath(); ctx!.moveTo(x + w * 0.1, ly); ctx!.lineTo(x + w * 0.9, ly); ctx!.stroke();
}
}
function pageAt(color: string): void {
const w = BOOK_R - BOOK_L, h = BOOK_B - BOOK_T;
ctx!.fillStyle = color;
ctx!.fillRect(BOOK_L, BOOK_T, w, h);
drawLines(BOOK_L, BOOK_T, w, h);
}
function draw(): void {
ctx!.fillStyle = SCENE_BG;
ctx!.fillRect(0, 0, LW, LH);
drawStackShadow();
const w = BOOK_R - BOOK_L, h = BOOK_B - BOOK_T;
const belowIdx = Math.max(0, STACK_TOTAL - remaining) % PAGE_COLORS.length;
pageAt(PAGE_COLORS[(belowIdx + 1) % PAGE_COLORS.length]);
if (flipping) {
const c = Math.cos(angle);
const frontFacing = c >= 0;
ctx!.save();
ctx!.translate(FOLD_X, 0);
ctx!.scale(c === 0 ? 0.001 : c, 1);
ctx!.fillStyle = frontFacing ? PAGE_COLORS[belowIdx] : PAGE_BACK;
ctx!.fillRect(0, BOOK_T, w, h);
if (frontFacing) drawLines(6, BOOK_T, w - 12, h);
ctx!.restore();
const shadowA = 0.06 + Math.abs(Math.sin(angle)) * 0.3;
ctx!.fillStyle = "rgba(0,0,0," + shadowA + ")";
ctx!.fillRect(FOLD_X, BOOK_T, 36, h);
} else if (remaining > 0) {
pageAt(PAGE_COLORS[belowIdx]);
}
ctx!.fillStyle = "rgba(255,247,230,.9)";
ctx!.font = "11px sans-serif";
ctx!.fillText(String(remaining) + " / " + String(STACK_TOTAL), BOOK_R - 34, BOOK_T - 8);
}
let pointerActive = false;
let pointerCooldown = 0;
function update(dt: number, tMs: number): void {
stepFlip(dt);
if (pointerCooldown > 0) { pointerCooldown -= dt * 1000; if (pointerCooldown <= 0) pointerActive = false; }
autoplay(dt);
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
draw();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); remaining = 3; }
function onClick(): void {
pointerActive = true; pointerCooldown = 1400;
nextPage();
if (reduce) render();
}
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowRight") { e.preventDefault(); onClick(); }
}
wrap.addEventListener("click", onClick);
wrap.setAttribute("tabindex", "0");
wrap.addEventListener("keydown", onKeyDown);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("click", onClick);
wrap.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>여러 장이 차례로 넘어가는 책 스택 플립</p>
</div>
</main>
);
}03 · 여러 장이 차례로 넘어가는 책 스택 플립 — 측정 누적면적 45.378% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function drawStackShadow() {
var th = stackThickness(remaining - (flipping ? 1 : 0));
ctx.fillStyle = 'rgba(23,20,26,.5)';
ctx.fillRect(BOOK_L + th, BOOK_T + th, BOOK_R - BOOK_L, BOOK_B - BOOK_T);
}
function drawLines(x, y, w, h) {
ctx.strokeStyle = 'rgba(23,20,26,.24)';
ctx.lineWidth = 1.4;
for (var i = 0; i < 4; i += 1) {
var ly = y + h * (0.2 + i * 0.18);
ctx.beginPath(); ctx.moveTo(x + w * 0.1, ly); ctx.lineTo(x + w * 0.9, ly); ctx.stroke();
}
}
03 항목은 누적면적 45.4%·강도 평균 75.7·23프레임 중 18프레임에서 움직였다.
04책 중심 골 곡률로 어두워지는 펼침 장면
책 중심(CENTER)에서 바깥 가장자리로 갈수록 줄어드는 비율(1-t)에 곡률 값(bendAngle)을 곱해 각 줄무늬가 눌리는 위치(comp)와 명도(shade)를 함께 정한다. 각도가 BEND_MAX_RAD(26도)에 가까워질수록 골 쪽 면이 더 짙게 어두워진다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">책 중심 골 곡률로 어두워지는 펼침 장면</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}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);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200;
var CENTER = LW / 2;
var PAGE_T = 26, PAGE_B = 176;
var HALF_W = 108;
var SCENE_BG = '#17141a';
var PAPER = '#fff7e6';
var BEND_MAX_RAD = Math.PI / 180 * 26;
var PERIOD_MS = 1000;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41304);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function shadeHex(hex, f) {
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
var bendAngle = 0;
function gutterBend(tMs) {
return BEND_MAX_RAD * (0.5 - 0.5 * Math.cos(2 * Math.PI * tMs / PERIOD_MS));
}
function drawLines(x, y, w, h) {
ctx.strokeStyle = 'rgba(23,20,26,.22)';
ctx.lineWidth = 1.2;
for (var i = 0; i < 5; i += 1) {
var ly = y + h * (0.16 + i * 0.17);
ctx.beginPath(); ctx.moveTo(x, ly); ctx.lineTo(x + w, ly); ctx.stroke();
}
}
function drawPageSide(sign) {
var strip = 4, h = PAGE_B - PAGE_T;
for (var dx = 0; dx <= HALF_W; dx += strip) {
var t = dx / HALF_W; // 0=골 · 1=바깥 가장자리
var curveTheta = (1 - t) * bendAngle; // 골에 가까울수록 곡률이 크다
var comp = Math.sin(curveTheta) * 12; // 골 쪽으로 눌리는 폭(px)
var x = CENTER + sign * (dx - comp);
var shade = 1 - (1 - t) * (bendAngle / BEND_MAX_RAD) * 0.42;
ctx.fillStyle = shadeHex(PAPER, shade);
ctx.fillRect(sign > 0 ? x : x - strip, PAGE_T, strip + 1, h);
}
}
function draw() {
ctx.fillStyle = SCENE_BG;
ctx.fillRect(0, 0, LW, LH);
ctx.fillStyle = 'rgba(0,0,0,.3)';
ctx.fillRect(CENTER - HALF_W - 6, PAGE_B + 3, HALF_W * 2 + 12, 6);
drawPageSide(-1);
drawPageSide(1);
drawLines(CENTER - HALF_W + 8, PAGE_T, HALF_W - 16, PAGE_B - PAGE_T);
drawLines(CENTER + 8, PAGE_T, HALF_W - 16, PAGE_B - PAGE_T);
var gutterA = 0.12 + (bendAngle / BEND_MAX_RAD) * 0.42;
var gutterW = 8 + (bendAngle / BEND_MAX_RAD) * 20;
var g = ctx.createLinearGradient(CENTER - gutterW, 0, CENTER + gutterW, 0);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(0.5, 'rgba(0,0,0,' + gutterA + ')');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(CENTER - gutterW, PAGE_T, gutterW * 2, PAGE_B - PAGE_T);
}
function update(dt, tMs) {
if (reduce) return;
bendAngle = gutterBend(tMs);
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
draw();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); bendAngle = BEND_MAX_RAD * 0.6; }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BookOpenGutterBendShade.module.scss";
export default function BookOpenGutterBendShade() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200;
const CENTER = LW / 2;
const PAGE_T = 26, PAGE_B = 176;
const HALF_W = 108;
const SCENE_BG = "#17141a";
const PAPER = "#fff7e6";
const BEND_MAX_RAD = (Math.PI / 180) * 26;
const PERIOD_MS = 1000;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41304);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function shadeHex(hex: string, f: number): string {
let r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return "rgb(" + r + "," + g + "," + b + ")";
}
let bendAngle = 0;
function gutterBend(tMs: number): number {
return BEND_MAX_RAD * (0.5 - 0.5 * Math.cos((2 * Math.PI * tMs) / PERIOD_MS));
}
function drawLines(x: number, y: number, w: number, h: number): void {
ctx!.strokeStyle = "rgba(23,20,26,.22)";
ctx!.lineWidth = 1.2;
for (let i = 0; i < 5; i += 1) {
const ly = y + h * (0.16 + i * 0.17);
ctx!.beginPath(); ctx!.moveTo(x, ly); ctx!.lineTo(x + w, ly); ctx!.stroke();
}
}
function drawPageSide(sign: number): void {
const strip = 4, h = PAGE_B - PAGE_T;
for (let dx = 0; dx <= HALF_W; dx += strip) {
const t = dx / HALF_W;
const curveTheta = (1 - t) * bendAngle;
const comp = Math.sin(curveTheta) * 12;
const x = CENTER + sign * (dx - comp);
const shade = 1 - (1 - t) * (bendAngle / BEND_MAX_RAD) * 0.42;
ctx!.fillStyle = shadeHex(PAPER, shade);
ctx!.fillRect(sign > 0 ? x : x - strip, PAGE_T, strip + 1, h);
}
}
function draw(): void {
ctx!.fillStyle = SCENE_BG;
ctx!.fillRect(0, 0, LW, LH);
ctx!.fillStyle = "rgba(0,0,0,.3)";
ctx!.fillRect(CENTER - HALF_W - 6, PAGE_B + 3, HALF_W * 2 + 12, 6);
drawPageSide(-1);
drawPageSide(1);
drawLines(CENTER - HALF_W + 8, PAGE_T, HALF_W - 16, PAGE_B - PAGE_T);
drawLines(CENTER + 8, PAGE_T, HALF_W - 16, PAGE_B - PAGE_T);
const gutterA = 0.12 + (bendAngle / BEND_MAX_RAD) * 0.42;
const gutterW = 8 + (bendAngle / BEND_MAX_RAD) * 20;
const g = ctx!.createLinearGradient(CENTER - gutterW, 0, CENTER + gutterW, 0);
g.addColorStop(0, "rgba(0,0,0,0)");
g.addColorStop(0.5, "rgba(0,0,0," + gutterA + ")");
g.addColorStop(1, "rgba(0,0,0,0)");
ctx!.fillStyle = g;
ctx!.fillRect(CENTER - gutterW, PAGE_T, gutterW * 2, PAGE_B - PAGE_T);
}
function update(dt: number, tMs: number): void {
if (reduce) return;
bendAngle = gutterBend(tMs);
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
draw();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); bendAngle = BEND_MAX_RAD * 0.6; }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>책 중심 골 곡률로 어두워지는 펼침 장면</p>
</div>
</main>
);
}04 · 책 중심 골 곡률로 어두워지는 펼침 장면 — 측정 누적면적 31.942% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function drawPageSide(sign) {
var strip = 4, h = PAGE_B - PAGE_T;
for (var dx = 0; dx <= HALF_W; dx += strip) {
var t = dx / HALF_W; // 0=골 · 1=바깥 가장자리
var curveTheta = (1 - t) * bendAngle; // 골에 가까울수록 곡률이 크다
var comp = Math.sin(curveTheta) * 12; // 골 쪽으로 눌리는 폭(px)
var x = CENTER + sign * (dx - comp);
var shade = 1 - (1 - t) * (bendAngle / BEND_MAX_RAD) * 0.42;
ctx.fillStyle = shadeHex(PAPER, shade);
ctx.fillRect(sign > 0 ? x : x - strip, PAGE_T, strip + 1, h);
}
}
04 항목은 누적면적 31.9%·강도 평균 22.8·23프레임 중 23프레임에서 움직였다.
05접선 각도로 꺾이는 오리가미 폴드
접힘 각도(angleDeg)를 라디안으로 바꾼 코사인 값 하나로 두 면의 폭(ctx.scale)과 밝기(brightness)를 동시에 계산해, 각도가 5도에 가까우면 거의 평평하고 150도에 가까우면 뒷면이 정면으로 접혀 보인다. 접힘선 가운데 세로줄은 앞면일 때만 그려 넣어 구김 자국을 흉내 낸다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">접선 각도로 꺾이는 오리가미 폴드</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
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;
touch-action: none;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { 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);
}
.vd {
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);
touch-action: none;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200;
var CENTER = LW / 2, TOP = 30, BOTTOM = 170;
var FACET_W = 92;
var CREASE_ANGLE_DEG = 30;
var ANGLE_MIN = 5, ANGLE_MAX = 150;
var SCENE_BG = '#17141a';
var PAPER = '#fff7e6';
var PAPER_BACK = '#caa96b';
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41305);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function shadeHex(hex, f) {
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
var angleDeg = CREASE_ANGLE_DEG;
var targetDeg = CREASE_ANGLE_DEG;
var pointerActive = false;
var keys = { left: false, right: false };
var CYCLE_MS = 950;
function autoplay(tMs) {
if (pointerActive || reduce) return;
var p = (tMs % CYCLE_MS) / CYCLE_MS;
var w = p < 0.5 ? p * 2 : (1 - p) * 2;
targetDeg = ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * w;
}
function angleStep(dt) {
var k = 1 - Math.pow(1 - 0.2, dt * 60);
angleDeg += (targetDeg - angleDeg) * k;
if (keys.left) targetDeg -= 90 * dt;
if (keys.right) targetDeg += 90 * dt;
targetDeg = clamp(targetDeg, ANGLE_MIN, ANGLE_MAX);
}
function drawFacet(sign) {
var rad = angleDeg * Math.PI / 180;
var c = Math.cos(rad);
var frontFacing = c >= 0;
var h = BOTTOM - TOP;
ctx.save();
ctx.translate(CENTER, 0);
ctx.scale(sign * (c === 0 ? 0.001 : c), 1);
var brightness = frontFacing ? (0.62 + 0.38 * c) : (0.3 + 0.2 * (-c));
ctx.fillStyle = frontFacing ? shadeHex(PAPER, brightness) : shadeHex(PAPER_BACK, brightness);
ctx.fillRect(0, TOP, FACET_W, h);
if (frontFacing) {
ctx.strokeStyle = 'rgba(23,20,26,.18)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(FACET_W * 0.5, TOP + 8); ctx.lineTo(FACET_W * 0.5, BOTTOM - 8); ctx.stroke();
}
ctx.restore();
}
function draw() {
ctx.fillStyle = SCENE_BG;
ctx.fillRect(0, 0, LW, LH);
ctx.fillStyle = 'rgba(0,0,0,.3)';
ctx.fillRect(CENTER - FACET_W - 4, BOTTOM + 3, FACET_W * 2 + 8, 6);
drawFacet(-1);
drawFacet(1);
ctx.strokeStyle = 'rgba(255,209,63,.7)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(CENTER, TOP - 6); ctx.lineTo(CENTER, BOTTOM + 6); ctx.stroke();
ctx.fillStyle = 'rgba(255,247,230,.85)';
ctx.font = '11px sans-serif';
ctx.fillText(Math.round(angleDeg) + '°', CENTER - 10, TOP - 10);
}
function update(dt, tMs) {
autoplay(tMs);
angleStep(dt);
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
draw();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); angleDeg = 80; targetDeg = 80; }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
var d = Math.abs(relX - CENTER);
targetDeg = clamp(ANGLE_MIN + (d / FACET_W) * (ANGLE_MAX - ANGLE_MIN), ANGLE_MIN, ANGLE_MAX);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { angleStep(1); render(); } });
wrap.addEventListener('pointermove', function (e) { if (!pointerActive) return; calcFromEvent(e); if (reduce) { angleStep(1); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
wrap.setAttribute('tabindex', '0');
wrap.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') keys.left = true;
else if (e.key === 'ArrowRight') keys.right = true;
else return;
pointerActive = true;
if (reduce) { angleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PaperFoldCreaseOrigamiAngle.module.scss";
export default function PaperFoldCreaseOrigamiAngle() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200;
const CENTER = LW / 2, TOP = 30, BOTTOM = 170;
const FACET_W = 92;
const CREASE_ANGLE_DEG = 30;
const ANGLE_MIN = 5, ANGLE_MAX = 150;
const SCENE_BG = "#17141a";
const PAPER = "#fff7e6";
const PAPER_BACK = "#caa96b";
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41305);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function shadeHex(hex: string, f: number): string {
let r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return "rgb(" + r + "," + g + "," + b + ")";
}
let angleDeg = CREASE_ANGLE_DEG;
let targetDeg = CREASE_ANGLE_DEG;
let pointerActive = false;
const keys = { left: false, right: false };
const CYCLE_MS = 950;
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const p = (tMs % CYCLE_MS) / CYCLE_MS;
const w = p < 0.5 ? p * 2 : (1 - p) * 2;
targetDeg = ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * w;
}
function angleStep(dt: number): void {
const k = 1 - Math.pow(1 - 0.2, dt * 60);
angleDeg += (targetDeg - angleDeg) * k;
if (keys.left) targetDeg -= 90 * dt;
if (keys.right) targetDeg += 90 * dt;
targetDeg = clamp(targetDeg, ANGLE_MIN, ANGLE_MAX);
}
function drawFacet(sign: number): void {
const radv = (angleDeg * Math.PI) / 180;
const c = Math.cos(radv);
const frontFacing = c >= 0;
const h = BOTTOM - TOP;
ctx!.save();
ctx!.translate(CENTER, 0);
ctx!.scale(sign * (c === 0 ? 0.001 : c), 1);
const brightness = frontFacing ? 0.62 + 0.38 * c : 0.3 + 0.2 * -c;
ctx!.fillStyle = frontFacing ? shadeHex(PAPER, brightness) : shadeHex(PAPER_BACK, brightness);
ctx!.fillRect(0, TOP, FACET_W, h);
if (frontFacing) {
ctx!.strokeStyle = "rgba(23,20,26,.18)";
ctx!.lineWidth = 1;
ctx!.beginPath(); ctx!.moveTo(FACET_W * 0.5, TOP + 8); ctx!.lineTo(FACET_W * 0.5, BOTTOM - 8); ctx!.stroke();
}
ctx!.restore();
}
function draw(): void {
ctx!.fillStyle = SCENE_BG;
ctx!.fillRect(0, 0, LW, LH);
ctx!.fillStyle = "rgba(0,0,0,.3)";
ctx!.fillRect(CENTER - FACET_W - 4, BOTTOM + 3, FACET_W * 2 + 8, 6);
drawFacet(-1);
drawFacet(1);
ctx!.strokeStyle = "rgba(255,209,63,.7)";
ctx!.lineWidth = 2;
ctx!.beginPath(); ctx!.moveTo(CENTER, TOP - 6); ctx!.lineTo(CENTER, BOTTOM + 6); ctx!.stroke();
ctx!.fillStyle = "rgba(255,247,230,.85)";
ctx!.font = "11px sans-serif";
ctx!.fillText(Math.round(angleDeg) + "°", CENTER - 10, TOP - 10);
}
function update(dt: number, tMs: number): void {
autoplay(tMs);
angleStep(dt);
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
draw();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); angleDeg = 80; targetDeg = 80; }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
const d = Math.abs(relX - CENTER);
targetDeg = clamp(ANGLE_MIN + (d / FACET_W) * (ANGLE_MAX - ANGLE_MIN), ANGLE_MIN, ANGLE_MAX);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { angleStep(1); render(); } }
function onPointerMove(e: PointerEvent): void { if (!pointerActive) return; calcFromEvent(e); if (reduce) { angleStep(1); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.setAttribute("tabindex", "0");
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = true;
else if (e.key === "ArrowRight") keys.right = true;
else return;
pointerActive = true;
if (reduce) { angleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
wrap.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("keyup", onKeyUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>접선 각도로 꺾이는 오리가미 폴드</p>
</div>
</main>
);
}05 · 접선 각도로 꺾이는 오리가미 폴드 — 측정 누적면적 29.063% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function drawFacet(sign) {
var rad = angleDeg * Math.PI / 180;
var c = Math.cos(rad);
var frontFacing = c >= 0;
var h = BOTTOM - TOP;
ctx.save();
ctx.translate(CENTER, 0);
ctx.scale(sign * (c === 0 ? 0.001 : c), 1);
var brightness = frontFacing ? (0.62 + 0.38 * c) : (0.3 + 0.2 * (-c));
ctx.fillStyle = frontFacing ? shadeHex(PAPER, brightness) : shadeHex(PAPER_BACK, brightness);
ctx.fillRect(0, TOP, FACET_W, h);
if (frontFacing) {
ctx.strokeStyle = 'rgba(23,20,26,.18)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(FACET_W * 0.5, TOP + 8); ctx.lineTo(FACET_W * 0.5, BOTTOM - 8); ctx.stroke();
}
ctx.restore();
}
05 항목은 누적면적 29.1%·강도 평균 104.3·23프레임 중 23프레임에서 움직였다.
06구겨진 표면이 노이즈로 음영지는 종이 질감
24×24 격자에 심어 둔 난수(lattice)를 좌표 사이에서 smoothStep으로 부드럽게 보간하는 2D 노이즈 함수가 값을 만들고, 서로 다른 배율·위상으로 부른 노이즈 두 겹을 섞은 게 crumpleHeight다. 이 높낮이 값이 구겨진 종이 표면의 픽셀별 명도로 그대로 옮겨진다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">구겨진 표면이 노이즈로 음영지는 종이 질감</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200;
var PAGE_L = 30, PAGE_T = 20, PAGE_R = 290, PAGE_B = 180;
var CELL = 6;
var SCENE_BG = '#17141a';
var PAPER = [255, 247, 230];
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function smoothStep(t) { return t * t * (3 - 2 * t); }
function makeValueNoise(seed) {
var rnd = seedRand(seed);
var GRID = 24;
var lattice = [];
for (var i = 0; i < GRID * GRID; i += 1) lattice.push(rnd());
function latAt(xi, yi) {
xi = ((xi % GRID) + GRID) % GRID; yi = ((yi % GRID) + GRID) % GRID;
return lattice[yi * GRID + xi];
}
return function valueNoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var v00 = latAt(xi, yi), v10 = latAt(xi + 1, yi), v01 = latAt(xi, yi + 1), v11 = latAt(xi + 1, yi + 1);
var sx = smoothStep(xf), sy = smoothStep(yf);
var a = v00 + (v10 - v00) * sx;
var b = v01 + (v11 - v01) * sx;
return a + (b - a) * sy;
};
}
var valueNoise = makeValueNoise(41306);
function crumpleHeight(x, y, phase) {
var n1 = valueNoise(x * 0.16 + phase, y * 0.16);
var n2 = valueNoise(x * 0.4 - phase * 1.4, y * 0.4 + phase * 0.6);
return n1 * 0.66 + n2 * 0.34;
}
var phase = 0;
var CYCLE_MS = 900;
function draw() {
ctx.fillStyle = SCENE_BG;
ctx.fillRect(0, 0, LW, LH);
ctx.fillStyle = 'rgba(0,0,0,.3)';
ctx.fillRect(PAGE_L - 6, PAGE_B + 3, PAGE_R - PAGE_L + 12, 6);
var lightX = -0.7, lightY = -0.7; // 좌상단 광원 방향 (정규화 근사)
for (var y = PAGE_T; y < PAGE_B; y += CELL) {
for (var x = PAGE_L; x < PAGE_R; x += CELL) {
var u = (x - PAGE_L) / CELL, v = (y - PAGE_T) / CELL;
var h0 = crumpleHeight(u, v, phase);
var hx = crumpleHeight(u + 1, v, phase);
var hy = crumpleHeight(u, v + 1, phase);
var gx = (hx - h0) * 6, gy = (hy - h0) * 6; // 높낮이 기울기 = 음영의 근거
var lambert = clamp(0.72 - (gx * lightX + gy * lightY) * 0.9, 0.28, 1.15);
var r = clamp(Math.round(PAPER[0] * lambert), 0, 255);
var g = clamp(Math.round(PAPER[1] * lambert), 0, 255);
var b = clamp(Math.round(PAPER[2] * lambert), 0, 255);
ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
ctx.fillRect(x, y, CELL + 1, CELL + 1);
}
}
// 대각으로 훑는 광택 — 노이즈만으론 프레임 간 변화가 작아 별도로 움직임을 더한다
var p = (nowMs % CYCLE_MS) / CYCLE_MS;
var sweepX = PAGE_L - 60 + p * (PAGE_R - PAGE_L + 120);
var g2 = ctx.createLinearGradient(sweepX - 34, 0, sweepX + 34, 0);
g2.addColorStop(0, 'rgba(255,255,255,0)');
g2.addColorStop(0.5, 'rgba(255,255,255,.16)');
g2.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g2;
ctx.fillRect(PAGE_L, PAGE_T, PAGE_R - PAGE_L, PAGE_B - PAGE_T);
}
var nowMs = 0;
function update(dt, tMs) {
if (reduce) return;
nowMs = tMs;
phase += dt * 0.5;
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
draw();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); phase = 1.2; nowMs = 0; }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PaperTextureCrumpleNoiseShade.module.scss";
export default function PaperTextureCrumpleNoiseShade() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200;
const PAGE_L = 30, PAGE_T = 20, PAGE_R = 290, PAGE_B = 180;
const CELL = 6;
const SCENE_BG = "#17141a";
const PAPER: [number, number, number] = [255, 247, 230];
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function smoothStep(t: number): number { return t * t * (3 - 2 * t); }
function makeValueNoise(seed: number) {
const rnd = seedRand(seed);
const GRID = 24;
const lattice: number[] = [];
for (let i = 0; i < GRID * GRID; i += 1) lattice.push(rnd());
function latAt(xi: number, yi: number): number {
xi = ((xi % GRID) + GRID) % GRID; yi = ((yi % GRID) + GRID) % GRID;
return lattice[yi * GRID + xi];
}
return function valueNoise(x: number, y: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const v00 = latAt(xi, yi), v10 = latAt(xi + 1, yi), v01 = latAt(xi, yi + 1), v11 = latAt(xi + 1, yi + 1);
const sx = smoothStep(xf), sy = smoothStep(yf);
const a = v00 + (v10 - v00) * sx;
const b = v01 + (v11 - v01) * sx;
return a + (b - a) * sy;
};
}
const valueNoise = makeValueNoise(41306);
function crumpleHeight(x: number, y: number, phase: number): number {
const n1 = valueNoise(x * 0.16 + phase, y * 0.16);
const n2 = valueNoise(x * 0.4 - phase * 1.4, y * 0.4 + phase * 0.6);
return n1 * 0.66 + n2 * 0.34;
}
let phase = 0;
const CYCLE_MS = 900;
function draw(): void {
ctx!.fillStyle = SCENE_BG;
ctx!.fillRect(0, 0, LW, LH);
ctx!.fillStyle = "rgba(0,0,0,.3)";
ctx!.fillRect(PAGE_L - 6, PAGE_B + 3, PAGE_R - PAGE_L + 12, 6);
const lightX = -0.7, lightY = -0.7;
for (let y = PAGE_T; y < PAGE_B; y += CELL) {
for (let x = PAGE_L; x < PAGE_R; x += CELL) {
const u = (x - PAGE_L) / CELL, v = (y - PAGE_T) / CELL;
const h0 = crumpleHeight(u, v, phase);
const hx = crumpleHeight(u + 1, v, phase);
const hy = crumpleHeight(u, v + 1, phase);
const gx = (hx - h0) * 6, gy = (hy - h0) * 6;
const lambert = clamp(0.72 - (gx * lightX + gy * lightY) * 0.9, 0.28, 1.15);
const r = clamp(Math.round(PAPER[0] * lambert), 0, 255);
const g = clamp(Math.round(PAPER[1] * lambert), 0, 255);
const b = clamp(Math.round(PAPER[2] * lambert), 0, 255);
ctx!.fillStyle = "rgb(" + r + "," + g + "," + b + ")";
ctx!.fillRect(x, y, CELL + 1, CELL + 1);
}
}
const p = (nowMs % CYCLE_MS) / CYCLE_MS;
const sweepX = PAGE_L - 60 + p * (PAGE_R - PAGE_L + 120);
const g2 = ctx!.createLinearGradient(sweepX - 34, 0, sweepX + 34, 0);
g2.addColorStop(0, "rgba(255,255,255,0)");
g2.addColorStop(0.5, "rgba(255,255,255,.16)");
g2.addColorStop(1, "rgba(255,255,255,0)");
ctx!.fillStyle = g2;
ctx!.fillRect(PAGE_L, PAGE_T, PAGE_R - PAGE_L, PAGE_B - PAGE_T);
}
let nowMs = 0;
function update(dt: number, tMs: number): void {
if (reduce) return;
nowMs = tMs;
phase += dt * 0.5;
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
draw();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); phase = 1.2; nowMs = 0; }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>구겨진 표면이 노이즈로 음영지는 종이 질감</p>
</div>
</main>
);
}06 · 구겨진 표면이 노이즈로 음영지는 종이 질감 — 측정 누적면적 50.638% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var rnd = seedRand(seed);
var GRID = 24;
var lattice = [];
for (var i = 0; i < GRID * GRID; i += 1) lattice.push(rnd());
function latAt(xi, yi) {
xi = ((xi % GRID) + GRID) % GRID; yi = ((yi % GRID) + GRID) % GRID;
return lattice[yi * GRID + xi];
}
return function valueNoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var v00 = latAt(xi, yi), v10 = latAt(xi + 1, yi), v01 = latAt(xi, yi + 1), v11 = latAt(xi + 1, yi + 1);
var sx = smoothStep(xf), sy = smoothStep(yf);
var a = v00 + (v10 - v00) * sx;
var b = v01 + (v11 - v01) * sx;
return a + (b - a) * sy;
};
}
var valueNoise = makeValueNoise(41306);
function crumpleHeight(x, y, phase) {
var n1 = valueNoise(x * 0.16 + phase, y * 0.16);
var n2 = valueNoise(x * 0.4 - phase * 1.4, y * 0.4 + phase * 0.6);
return n1 * 0.66 + n2 * 0.34;
}
06 항목은 누적면적 50.6%·강도 평균 20.7·23프레임 중 23프레임에서 움직였다.
07포물선과 흔들림 값으로 나는 종이비행기
이륙(LAUNCH)에서 착륙(LAND)까지 4·t·(1-t) 포물선으로 오르내리는 좌표에 사인파 흔들림(DRIFT_AMPLITUDE_PX)을 더해 위치를 구하고, 같은 포물선의 높이값을 반대로 그림자 반경·투명도에 반영해 비행기가 높이 뜰수록 그림자는 작고 흐려진다. 종이 비행기 애니메이션치고는 드물게 그림자까지 고도 값 하나로 따라온다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">포물선과 흔들림 값으로 나는 종이비행기</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}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);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200;
var LAUNCH = { x: 30, y: 158 };
var LAND = { x: 296, y: 66 };
var ARC_HEIGHT = 60;
var DRIFT_AMPLITUDE_PX = 8;
var DRIFT_CYCLES = 2.4;
var FLIGHT_MS = 900;
var SCENE_BG = '#17141a';
var PAPER = '#fff7e6';
var PLANE_SCALE = 4.5;
var FLOOR_Y = 184;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41307);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function driftFlight(t) {
var x = lerp(LAUNCH.x, LAND.x, t) + Math.sin(t * DRIFT_CYCLES * Math.PI * 2) * DRIFT_AMPLITUDE_PX;
var y = lerp(LAUNCH.y, LAND.y, t) - ARC_HEIGHT * 4 * t * (1 - t);
return { x: x, y: y };
}
var pos = driftFlight(0), heading = 0, curT = 0;
var trail = [];
var TRAIL_MAX = 18;
function drawShadow(x, t) {
var alt = ARC_HEIGHT * 4 * t * (1 - t);
var rx = clamp(34 - alt * 0.28, 8, 34);
var ry = rx * 0.36;
var alpha = clamp(0.32 - alt * 0.003, 0.06, 0.32);
ctx.fillStyle = 'rgba(10,8,12,' + alpha + ')';
ctx.beginPath();
ctx.ellipse(x, FLOOR_Y, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawPlane(x, y, angle) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(PLANE_SCALE, PLANE_SCALE);
ctx.fillStyle = PAPER;
ctx.beginPath();
ctx.moveTo(14, 0);
ctx.lineTo(-10, -7);
ctx.lineTo(-4, 0);
ctx.lineTo(-10, 7);
ctx.closePath();
ctx.fill();
ctx.fillStyle = 'rgba(23,20,26,.22)';
ctx.beginPath();
ctx.moveTo(14, 0);
ctx.lineTo(-4, -0.5);
ctx.lineTo(-4, 0.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function draw() {
ctx.fillStyle = SCENE_BG;
ctx.fillRect(0, 0, LW, LH);
ctx.strokeStyle = 'rgba(255,247,230,.14)';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (var i = 0; i <= 30; i += 1) {
var p = driftFlight(i / 30);
if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
drawShadow(pos.x, curT);
for (var j = 0; j < trail.length; j += 1) {
var tp = trail[j];
var a = (j + 1) / trail.length * 0.55;
ctx.fillStyle = 'rgba(255,247,230,' + a + ')';
ctx.beginPath(); ctx.arc(tp.x, tp.y, 3.4, 0, Math.PI * 2); ctx.fill();
}
drawPlane(pos.x, pos.y, heading);
}
function update(dt, tMs) {
if (reduce) return;
var t = (tMs % FLIGHT_MS) / FLIGHT_MS;
curT = t;
var next = driftFlight(t);
var ahead = driftFlight(Math.min(1, t + 0.01));
heading = Math.atan2(ahead.y - next.y, ahead.x - next.x);
pos = next;
trail.push({ x: pos.x, y: pos.y });
if (trail.length > TRAIL_MAX) trail.shift();
if (t < 0.02) trail.length = 0;
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
draw();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) {
root.classList.remove('is-demo');
pos = driftFlight(0.5);
curT = 0.5;
var a2 = driftFlight(0.51);
heading = Math.atan2(a2.y - pos.y, a2.x - pos.x);
}
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PaperAirplaneFlightArcDrift.module.scss";
export default function PaperAirplaneFlightArcDrift() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200;
const LAUNCH = { x: 30, y: 158 };
const LAND = { x: 296, y: 66 };
const ARC_HEIGHT = 60;
const DRIFT_AMPLITUDE_PX = 8;
const DRIFT_CYCLES = 2.4;
const FLIGHT_MS = 900;
const SCENE_BG = "#17141a";
const PAPER = "#fff7e6";
const PLANE_SCALE = 4.5;
const FLOOR_Y = 184;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41307);
function clamp(v: number, a: number, b: number): number { return v < a ? a : v > b ? b : v; }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function driftFlight(t: number): { x: number; y: number } {
const x = lerp(LAUNCH.x, LAND.x, t) + Math.sin(t * DRIFT_CYCLES * Math.PI * 2) * DRIFT_AMPLITUDE_PX;
const y = lerp(LAUNCH.y, LAND.y, t) - ARC_HEIGHT * 4 * t * (1 - t);
return { x, y };
}
let pos = driftFlight(0), heading = 0, curT = 0;
let trail: { x: number; y: number }[] = [];
const TRAIL_MAX = 18;
function drawShadow(x: number, t: number): void {
const alt = ARC_HEIGHT * 4 * t * (1 - t);
const rx = clamp(34 - alt * 0.28, 8, 34);
const ry = rx * 0.36;
const alpha = clamp(0.32 - alt * 0.003, 0.06, 0.32);
ctx!.fillStyle = "rgba(10,8,12," + alpha + ")";
ctx!.beginPath();
ctx!.ellipse(x, FLOOR_Y, rx, ry, 0, 0, Math.PI * 2);
ctx!.fill();
}
function drawPlane(x: number, y: number, angle: number): void {
ctx!.save();
ctx!.translate(x, y);
ctx!.rotate(angle);
ctx!.scale(PLANE_SCALE, PLANE_SCALE);
ctx!.fillStyle = PAPER;
ctx!.beginPath();
ctx!.moveTo(14, 0);
ctx!.lineTo(-10, -7);
ctx!.lineTo(-4, 0);
ctx!.lineTo(-10, 7);
ctx!.closePath();
ctx!.fill();
ctx!.fillStyle = "rgba(23,20,26,.22)";
ctx!.beginPath();
ctx!.moveTo(14, 0);
ctx!.lineTo(-4, -0.5);
ctx!.lineTo(-4, 0.5);
ctx!.closePath();
ctx!.fill();
ctx!.restore();
}
function draw(): void {
ctx!.fillStyle = SCENE_BG;
ctx!.fillRect(0, 0, LW, LH);
ctx!.strokeStyle = "rgba(255,247,230,.14)";
ctx!.lineWidth = 1.5;
ctx!.beginPath();
for (let i = 0; i <= 30; i += 1) {
const p = driftFlight(i / 30);
if (i === 0) ctx!.moveTo(p.x, p.y); else ctx!.lineTo(p.x, p.y);
}
ctx!.stroke();
drawShadow(pos.x, curT);
for (let j = 0; j < trail.length; j += 1) {
const tp = trail[j];
const a = ((j + 1) / trail.length) * 0.55;
ctx!.fillStyle = "rgba(255,247,230," + a + ")";
ctx!.beginPath(); ctx!.arc(tp.x, tp.y, 3.4, 0, Math.PI * 2); ctx!.fill();
}
drawPlane(pos.x, pos.y, heading);
}
function update(dt: number, tMs: number): void {
if (reduce) return;
const t = (tMs % FLIGHT_MS) / FLIGHT_MS;
curT = t;
const next = driftFlight(t);
const ahead = driftFlight(Math.min(1, t + 0.01));
heading = Math.atan2(ahead.y - next.y, ahead.x - next.x);
pos = next;
trail.push({ x: pos.x, y: pos.y });
if (trail.length > TRAIL_MAX) trail.shift();
if (t < 0.02) trail = [];
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
draw();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) {
root.classList.remove("is-demo");
pos = driftFlight(0.5);
curT = 0.5;
const a2 = driftFlight(0.51);
heading = Math.atan2(a2.y - pos.y, a2.x - pos.x);
}
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>포물선과 흔들림 값으로 나는 종이비행기</p>
</div>
</main>
);
}07 · 포물선과 흔들림 값으로 나는 종이비행기 — 측정 누적면적 20.715% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function driftFlight(t) {
var x = lerp(LAUNCH.x, LAND.x, t) + Math.sin(t * DRIFT_CYCLES * Math.PI * 2) * DRIFT_AMPLITUDE_PX;
var y = lerp(LAUNCH.y, LAND.y, t) - ARC_HEIGHT * 4 * t * (1 - t);
return { x: x, y: y };
}
var pos = driftFlight(0), heading = 0, curT = 0;
var trail = [];
var TRAIL_MAX = 18;
function drawShadow(x, t) {
var alt = ARC_HEIGHT * 4 * t * (1 - t);
var rx = clamp(34 - alt * 0.28, 8, 34);
var ry = rx * 0.36;
var alpha = clamp(0.32 - alt * 0.003, 0.06, 0.32);
ctx.fillStyle = 'rgba(10,8,12,' + alpha + ')';
ctx.beginPath();
ctx.ellipse(x, FLOOR_Y, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
}
07 항목은 누적면적 20.7%·강도 평균 188.1·23프레임 중 23프레임에서 움직였다.
08관절 값이 이어져 흔들리는 종이 인형
어깨부터 손끝까지 관절마다 FK_DELAY_RAD만큼 위상을 늦춘 사인파(sway)가 이전 관절의 누적 각도(cum)에 더해져, 팔 하나를 흔들면 다음 관절이 시차를 두고 뒤따르는 사슬(FK chain)이 만들어진다. 종이 인형 애니메이션답게 관절 감쇠(0.82를 관절 순서만큼 거듭제곱한 값)가 바깥 관절일수록 흔들림을 줄여 준다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">관절 값이 이어져 흔들리는 종이 인형</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}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);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200;
var SCENE_BG = '#17141a';
var PAPER = '#fff7e6';
var ANCHOR = { x: 160, y: 26 };
var FK_DELAY_RAD = 0.7;
var SWING_PERIOD_MS = 900;
var SWING_AMP_DEG = 34;
var FLOOR_Y = 184;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41308);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function deg(r) { return r * 180 / Math.PI; }
function rad(d) { return d * Math.PI / 180; }
var ARM_L = [ { seg: [22, 20], base: rad(150), delay: FK_DELAY_RAD * 0 },
{ seg: [22, 20], base: rad(150), delay: FK_DELAY_RAD * 1 } ];
var ARM_R = [ { seg: [22, 20], base: rad(30), delay: FK_DELAY_RAD * 0.6 },
{ seg: [22, 20], base: rad(30), delay: FK_DELAY_RAD * 1.6 } ];
var LEG_L = [ { seg: [24, 22], base: rad(100), delay: FK_DELAY_RAD * 0.4 } ];
var LEG_R = [ { seg: [24, 22], base: rad(80), delay: FK_DELAY_RAD * 1.2 } ];
function jointAngle(t, def, chainIndex) {
var w = Math.PI * 2 * t;
var sway = Math.sin(w - chainIndex * FK_DELAY_RAD) * rad(SWING_AMP_DEG) * Math.pow(0.82, chainIndex);
return def.base + sway;
}
function swayFactor(t) { return Math.sin(Math.PI * 2 * t); }
function fkChain(originX, originY, segs, t) {
var x = originX, y = originY, cum = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < segs.length; i += 1) {
cum += jointAngle(t, segs[i], i);
x += Math.cos(cum) * segs[i].seg[0];
y += Math.sin(cum) * segs[i].seg[0];
pts.push({ x: x, y: y });
}
return pts;
}
function strokeChain(pts, w) {
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = PAPER;
for (var i = 0; i < pts.length - 1; i += 1) {
ctx.lineWidth = w - i * 2;
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
for (var j = 1; j < pts.length; j += 1) {
ctx.fillStyle = 'rgba(23,20,26,.35)';
ctx.beginPath(); ctx.arc(pts[j].x, pts[j].y, 15, 0, Math.PI * 2); ctx.fill();
}
}
function drawLimbShadow(pts) {
var tip = pts[pts.length - 1];
var diff = Math.abs(tip.x - ANCHOR.x);
var rx = clamp(18 + diff * 0.26, 18, 40);
var alpha = clamp(0.2 + diff * 0.006, 0.18, 0.45);
ctx.fillStyle = 'rgba(10,8,12,' + alpha + ')';
ctx.beginPath();
ctx.ellipse(tip.x, FLOOR_Y, rx, rx * 0.32, 0, 0, Math.PI * 2);
ctx.fill();
}
var tNow = 0;
function draw() {
ctx.fillStyle = SCENE_BG;
ctx.fillRect(0, 0, LW, LH);
var sway = swayFactor(tNow);
var glowAlpha = clamp(0.1 + Math.abs(sway) * 0.1, 0.1, 0.22);
var glow = ctx.createRadialGradient(ANCHOR.x, ANCHOR.y + 74, 10, ANCHOR.x, ANCHOR.y + 74, 170);
glow.addColorStop(0, 'rgba(255,247,230,' + glowAlpha + ')');
glow.addColorStop(1, 'rgba(255,247,230,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, LW, LH);
ctx.strokeStyle = 'rgba(255,247,230,.4)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ANCHOR.x, 4); ctx.lineTo(ANCHOR.x, ANCHOR.y); ctx.stroke();
var shoulderY = ANCHOR.y + 44;
var hipY = ANCHOR.y + 92;
ctx.fillStyle = 'rgba(0,0,0,.28)';
ctx.beginPath(); ctx.ellipse(ANCHOR.x + sway * 10, FLOOR_Y, 82 + Math.abs(sway) * 16, 17, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = PAPER;
ctx.beginPath(); ctx.arc(ANCHOR.x, ANCHOR.y + 18, 26, 0, Math.PI * 2); ctx.fill();
ctx.fillRect(ANCHOR.x - 31, shoulderY - 6, 62, hipY - shoulderY + 6);
var armL = fkChain(ANCHOR.x - 24, shoulderY, ARM_L, tNow);
var armR = fkChain(ANCHOR.x + 24, shoulderY, ARM_R, tNow);
var legL = fkChain(ANCHOR.x - 12, hipY, LEG_L, tNow);
var legR = fkChain(ANCHOR.x + 12, hipY, LEG_R, tNow);
drawLimbShadow(armL); drawLimbShadow(armR); drawLimbShadow(legL); drawLimbShadow(legR);
strokeChain(armL, 34);
strokeChain(armR, 34);
strokeChain(legL, 40);
strokeChain(legR, 40);
}
function update(dt, tMs) {
if (reduce) return;
tNow = (tMs % SWING_PERIOD_MS) / SWING_PERIOD_MS;
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
draw();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); tNow = 0.25; }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PaperDollJointSwingChain.module.scss";
type JointDef = { seg: [number, number]; base: number; delay: number };
export default function PaperDollJointSwingChain() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200;
const SCENE_BG = "#17141a";
const PAPER = "#fff7e6";
const ANCHOR = { x: 160, y: 26 };
const FK_DELAY_RAD = 0.7;
const SWING_PERIOD_MS = 900;
const SWING_AMP_DEG = 34;
const FLOOR_Y = 184;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41308);
function clamp(v: number, a: number, b: number): number { return v < a ? a : v > b ? b : v; }
function rad(d: number): number { return (d * Math.PI) / 180; }
const ARM_L: JointDef[] = [
{ seg: [22, 20], base: rad(150), delay: FK_DELAY_RAD * 0 },
{ seg: [22, 20], base: rad(150), delay: FK_DELAY_RAD * 1 },
];
const ARM_R: JointDef[] = [
{ seg: [22, 20], base: rad(30), delay: FK_DELAY_RAD * 0.6 },
{ seg: [22, 20], base: rad(30), delay: FK_DELAY_RAD * 1.6 },
];
const LEG_L: JointDef[] = [{ seg: [24, 22], base: rad(100), delay: FK_DELAY_RAD * 0.4 }];
const LEG_R: JointDef[] = [{ seg: [24, 22], base: rad(80), delay: FK_DELAY_RAD * 1.2 }];
function jointAngle(t: number, def: JointDef, chainIndex: number): number {
const w = Math.PI * 2 * t;
const sway = Math.sin(w - chainIndex * FK_DELAY_RAD) * rad(SWING_AMP_DEG) * Math.pow(0.82, chainIndex);
return def.base + sway;
}
function swayFactor(t: number): number { return Math.sin(Math.PI * 2 * t); }
function fkChain(originX: number, originY: number, segs: JointDef[], t: number): { x: number; y: number }[] {
let x = originX, y = originY, cum = 0;
const pts = [{ x, y }];
for (let i = 0; i < segs.length; i += 1) {
cum += jointAngle(t, segs[i], i);
x += Math.cos(cum) * segs[i].seg[0];
y += Math.sin(cum) * segs[i].seg[0];
pts.push({ x, y });
}
return pts;
}
function strokeChain(pts: { x: number; y: number }[], w: number): void {
ctx!.lineCap = "round";
ctx!.lineJoin = "round";
ctx!.strokeStyle = PAPER;
for (let i = 0; i < pts.length - 1; i += 1) {
ctx!.lineWidth = w - i * 2;
ctx!.beginPath();
ctx!.moveTo(pts[i].x, pts[i].y);
ctx!.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx!.stroke();
}
for (let j = 1; j < pts.length; j += 1) {
ctx!.fillStyle = "rgba(23,20,26,.35)";
ctx!.beginPath(); ctx!.arc(pts[j].x, pts[j].y, 15, 0, Math.PI * 2); ctx!.fill();
}
}
function drawLimbShadow(pts: { x: number; y: number }[]): void {
const tip = pts[pts.length - 1];
const diff = Math.abs(tip.x - ANCHOR.x);
const rx = clamp(18 + diff * 0.26, 18, 40);
const alpha = clamp(0.2 + diff * 0.006, 0.18, 0.45);
ctx!.fillStyle = "rgba(10,8,12," + alpha + ")";
ctx!.beginPath();
ctx!.ellipse(tip.x, FLOOR_Y, rx, rx * 0.32, 0, 0, Math.PI * 2);
ctx!.fill();
}
let tNow = 0;
function draw(): void {
ctx!.fillStyle = SCENE_BG;
ctx!.fillRect(0, 0, LW, LH);
const sway = swayFactor(tNow);
const glowAlpha = clamp(0.1 + Math.abs(sway) * 0.1, 0.1, 0.22);
const glow = ctx!.createRadialGradient(ANCHOR.x, ANCHOR.y + 74, 10, ANCHOR.x, ANCHOR.y + 74, 170);
glow.addColorStop(0, "rgba(255,247,230," + glowAlpha + ")");
glow.addColorStop(1, "rgba(255,247,230,0)");
ctx!.fillStyle = glow;
ctx!.fillRect(0, 0, LW, LH);
ctx!.strokeStyle = "rgba(255,247,230,.4)";
ctx!.lineWidth = 1;
ctx!.beginPath(); ctx!.moveTo(ANCHOR.x, 4); ctx!.lineTo(ANCHOR.x, ANCHOR.y); ctx!.stroke();
const shoulderY = ANCHOR.y + 44;
const hipY = ANCHOR.y + 92;
ctx!.fillStyle = "rgba(0,0,0,.28)";
ctx!.beginPath(); ctx!.ellipse(ANCHOR.x + sway * 10, FLOOR_Y, 82 + Math.abs(sway) * 16, 17, 0, 0, Math.PI * 2); ctx!.fill();
ctx!.fillStyle = PAPER;
ctx!.beginPath(); ctx!.arc(ANCHOR.x, ANCHOR.y + 18, 26, 0, Math.PI * 2); ctx!.fill();
ctx!.fillRect(ANCHOR.x - 31, shoulderY - 6, 62, hipY - shoulderY + 6);
const armL = fkChain(ANCHOR.x - 24, shoulderY, ARM_L, tNow);
const armR = fkChain(ANCHOR.x + 24, shoulderY, ARM_R, tNow);
const legL = fkChain(ANCHOR.x - 12, hipY, LEG_L, tNow);
const legR = fkChain(ANCHOR.x + 12, hipY, LEG_R, tNow);
drawLimbShadow(armL); drawLimbShadow(armR); drawLimbShadow(legL); drawLimbShadow(legR);
strokeChain(armL, 34);
strokeChain(armR, 34);
strokeChain(legL, 40);
strokeChain(legR, 40);
}
function update(dt: number, tMs: number): void {
if (reduce) return;
tNow = (tMs % SWING_PERIOD_MS) / SWING_PERIOD_MS;
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
draw();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); tNow = 0.25; }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>관절 값이 이어져 흔들리는 종이 인형</p>
</div>
</main>
);
}08 · 관절 값이 이어져 흔들리는 종이 인형 — 측정 누적면적 17.033% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function jointAngle(t, def, chainIndex) {
var w = Math.PI * 2 * t;
var sway = Math.sin(w - chainIndex * FK_DELAY_RAD) * rad(SWING_AMP_DEG) * Math.pow(0.82, chainIndex);
return def.base + sway;
}
function swayFactor(t) { return Math.sin(Math.PI * 2 * t); }
function fkChain(originX, originY, segs, t) {
var x = originX, y = originY, cum = 0;
var pts = [{ x: x, y: y }];
for (var i = 0; i < segs.length; i += 1) {
cum += jointAngle(t, segs[i], i);
x += Math.cos(cum) * segs[i].seg[0];
y += Math.sin(cum) * segs[i].seg[0];
pts.push({ x: x, y: y });
}
return pts;
}
08 항목은 누적면적 17.0%·강도 평균 95.2·23프레임 중 23프레임에서 움직였다.
09위 장면을 한 책상 위에 모은 페이지 넘김 히어로
책상 장면은 compositeValue 값 하나를 책 골 곡률·컬 반경·비행기 흔들림 폭에 동시에 곱해, 값이 0에 가까우면 셋 다 얌전하고 1에 가까우면 함께 크게 움직인다. 01의 원기둥 투영과 04의 골 음영, 07의 표류가 한 좌표계에서 같은 값을 공유한다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">위 장면을 한 책상 위에 모은 페이지 넘김 히어로</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
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;
touch-action: none;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { 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);
}
.vd {
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);
touch-action: none;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200;
var SCENE_BG = '#17141a';
var PAPER = '#fff7e6';
var PAPER_BACK = '#caa96b';
var COMPOSITE_VALUE_MIN = 0, COMPOSITE_VALUE_MAX = 1;
var CURL_RADIUS_MAX_PX = 46;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41309);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function shadeHex(hex, f) {
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
var compositeValue = 0.35, targetValue = 0.35;
var pointerActive = false;
var keys = { left: false, right: false };
var CYCLE_MS = 1000;
function autoplay(tMs) {
if (pointerActive || reduce) return;
var p = (tMs % CYCLE_MS) / CYCLE_MS;
var w = p < 0.5 ? p * 2 : (1 - p) * 2;
targetValue = COMPOSITE_VALUE_MIN + (COMPOSITE_VALUE_MAX - COMPOSITE_VALUE_MIN) * w;
}
function valueStep(dt) {
var k = 1 - Math.pow(1 - 0.22, dt * 60);
compositeValue += (targetValue - compositeValue) * k;
if (keys.left) targetValue -= 0.6 * dt;
if (keys.right) targetValue += 0.6 * dt;
targetValue = clamp(targetValue, COMPOSITE_VALUE_MIN, COMPOSITE_VALUE_MAX);
}
// --- 왼쪽: 펼친 책 (거터 벤드는 값에 비례) ---
var BOOK_CX = 76, BOOK_TOP = 118, BOOK_BOT = 190, BOOK_HALF = 58;
function gutterBend(t) { return t * 0.85; }
function drawBookSide(sign, t) {
var bend = gutterBend(t);
var c = Math.cos(bend * sign * 0.001 + bend);
ctx.save();
ctx.translate(BOOK_CX, 0);
ctx.transform(sign * Math.max(0.18, Math.cos(bend)), 0, Math.sin(bend) * 0.22, 1, 0, 0);
var bright = 0.55 + 0.45 * Math.cos(bend);
ctx.fillStyle = shadeHex(PAPER, bright);
ctx.fillRect(0, BOOK_TOP, BOOK_HALF, BOOK_BOT - BOOK_TOP);
ctx.restore();
}
function drawBook(t) {
ctx.fillStyle = 'rgba(0,0,0,.28)';
ctx.fillRect(BOOK_CX - BOOK_HALF - 4, BOOK_BOT + 2, BOOK_HALF * 2 + 8, 5);
drawBookSide(-1, t);
drawBookSide(1, t);
}
// --- 가운데: 낱장 컬 (반지름이 값에 비례) ---
var CURL_CX = 204, CURL_TOP = 108, CURL_BOT = 190, CURL_W = 70;
function curlPage(t) {
var radius = 6 + t * CURL_RADIUS_MAX_PX;
var stripCount = 14;
for (var i = 0; i < stripCount; i += 1) {
var u = i / stripCount;
var localX = u * CURL_W;
var theta = Math.max(0, localX - (CURL_W - radius * 2.4)) / Math.max(1, radius);
theta = clamp(theta, 0, Math.PI);
var sx = CURL_CX + CURL_W - CURL_W * (1 - u) - Math.min(localX, CURL_W - radius * 2.4);
var screenX = CURL_CX + (CURL_W - radius * 2.4) * u * 0 + localX - (1 - Math.cos(theta)) * radius;
var front = Math.cos(theta) >= 0;
var bright = front ? (0.6 + 0.4 * Math.cos(theta)) : (0.32 + 0.18 * (-Math.cos(theta)));
ctx.fillStyle = front ? shadeHex(PAPER, bright) : shadeHex(PAPER_BACK, bright);
var stripX = CURL_CX + localX - (localX > (CURL_W - radius * 2.4) ? (1 - Math.cos(theta)) * radius : 0);
ctx.fillRect(stripX, CURL_TOP, CURL_W / stripCount + 1, CURL_BOT - CURL_TOP);
}
}
function drawCurl(t) {
ctx.fillStyle = 'rgba(0,0,0,.28)';
ctx.fillRect(CURL_CX - 4, CURL_BOT + 2, CURL_W + 8, 5);
curlPage(t);
}
// --- 오른쪽 상단: 종이비행기 (드리프트 폭이 값에 비례) ---
var LAUNCH = { x: 214, y: 40 };
var LAND = { x: 300, y: 78 };
function planePos(phase, t) {
var amp = 4 + t * 22;
var x = lerp(LAUNCH.x, LAND.x, phase) + Math.sin(phase * Math.PI * 2 * 2) * amp;
var y = lerp(LAUNCH.y, LAND.y, phase) - 18 * 4 * phase * (1 - phase);
return { x: x, y: y };
}
var planePhase = 0;
function drawPlane(t) {
var p0 = planePos(planePhase, t);
var p1 = planePos(Math.min(1, planePhase + 0.01), t);
var angle = Math.atan2(p1.y - p0.y, p1.x - p0.x);
ctx.save();
ctx.translate(p0.x, p0.y);
ctx.rotate(angle);
ctx.fillStyle = PAPER;
ctx.beginPath();
ctx.moveTo(11, 0); ctx.lineTo(-8, -6); ctx.lineTo(-3, 0); ctx.lineTo(-8, 6);
ctx.closePath(); ctx.fill();
ctx.restore();
}
function draw() {
ctx.fillStyle = SCENE_BG;
ctx.fillRect(0, 0, LW, LH);
ctx.fillStyle = 'rgba(255,247,230,.06)';
ctx.fillRect(0, 96, LW, 4);
drawBook(compositeValue);
drawCurl(compositeValue);
drawPlane(compositeValue);
ctx.fillStyle = 'rgba(255,247,230,.85)';
ctx.font = '11px sans-serif';
ctx.fillText('value ' + compositeValue.toFixed(2), 12, 18);
ctx.strokeStyle = 'rgba(255,209,63,.65)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(12, 26); ctx.lineTo(12 + compositeValue * 90, 26);
ctx.stroke();
}
function update(dt, tMs) {
autoplay(tMs);
valueStep(dt);
if (!reduce) planePhase = (tMs % 900) / 900;
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
draw();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); compositeValue = 0.55; targetValue = 0.55; planePhase = 0.4; }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width;
targetValue = clamp(relX, COMPOSITE_VALUE_MIN, COMPOSITE_VALUE_MAX);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { valueStep(1); render(); } });
wrap.addEventListener('pointermove', function (e) { if (!pointerActive) return; calcFromEvent(e); if (reduce) { valueStep(1); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
wrap.setAttribute('tabindex', '0');
wrap.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') keys.left = true;
else if (e.key === 'ArrowRight') keys.right = true;
else return;
pointerActive = true;
if (reduce) { valueStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PageTurnHeroDeskScene.module.scss";
export default function PageTurnHeroDeskScene() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200;
const SCENE_BG = "#17141a";
const PAPER = "#fff7e6";
const PAPER_BACK = "#caa96b";
const COMPOSITE_VALUE_MIN = 0, COMPOSITE_VALUE_MAX = 1;
const CURL_RADIUS_MAX_PX = 46;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41309);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function shadeHex(hex: string, f: number): string {
let r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
r = clamp(Math.round(r * f), 0, 255); g = clamp(Math.round(g * f), 0, 255); b = clamp(Math.round(b * f), 0, 255);
return "rgb(" + r + "," + g + "," + b + ")";
}
let compositeValue = 0.35, targetValue = 0.35;
let pointerActive = false;
const keys = { left: false, right: false };
const CYCLE_MS = 1000;
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const p = (tMs % CYCLE_MS) / CYCLE_MS;
const w = p < 0.5 ? p * 2 : (1 - p) * 2;
targetValue = COMPOSITE_VALUE_MIN + (COMPOSITE_VALUE_MAX - COMPOSITE_VALUE_MIN) * w;
}
function valueStep(dt: number): void {
const k = 1 - Math.pow(1 - 0.22, dt * 60);
compositeValue += (targetValue - compositeValue) * k;
if (keys.left) targetValue -= 0.6 * dt;
if (keys.right) targetValue += 0.6 * dt;
targetValue = clamp(targetValue, COMPOSITE_VALUE_MIN, COMPOSITE_VALUE_MAX);
}
const BOOK_CX = 76, BOOK_TOP = 118, BOOK_BOT = 190, BOOK_HALF = 58;
function gutterBend(t: number): number { return t * 0.85; }
function drawBookSide(sign: number, t: number): void {
const bend = gutterBend(t);
ctx!.save();
ctx!.translate(BOOK_CX, 0);
ctx!.transform(sign * Math.max(0.18, Math.cos(bend)), 0, Math.sin(bend) * 0.22, 1, 0, 0);
const bright = 0.55 + 0.45 * Math.cos(bend);
ctx!.fillStyle = shadeHex(PAPER, bright);
ctx!.fillRect(0, BOOK_TOP, BOOK_HALF, BOOK_BOT - BOOK_TOP);
ctx!.restore();
}
function drawBook(t: number): void {
ctx!.fillStyle = "rgba(0,0,0,.28)";
ctx!.fillRect(BOOK_CX - BOOK_HALF - 4, BOOK_BOT + 2, BOOK_HALF * 2 + 8, 5);
drawBookSide(-1, t);
drawBookSide(1, t);
}
const CURL_CX = 204, CURL_TOP = 108, CURL_BOT = 190, CURL_W = 70;
function curlPage(t: number): void {
const radius = 6 + t * CURL_RADIUS_MAX_PX;
const stripCount = 14;
for (let i = 0; i < stripCount; i += 1) {
const u = i / stripCount;
const localX = u * CURL_W;
let theta = Math.max(0, localX - (CURL_W - radius * 2.4)) / Math.max(1, radius);
theta = clamp(theta, 0, Math.PI);
const front = Math.cos(theta) >= 0;
const bright = front ? 0.6 + 0.4 * Math.cos(theta) : 0.32 + 0.18 * -Math.cos(theta);
ctx!.fillStyle = front ? shadeHex(PAPER, bright) : shadeHex(PAPER_BACK, bright);
const stripX = CURL_CX + localX - (localX > CURL_W - radius * 2.4 ? (1 - Math.cos(theta)) * radius : 0);
ctx!.fillRect(stripX, CURL_TOP, CURL_W / stripCount + 1, CURL_BOT - CURL_TOP);
}
}
function drawCurl(t: number): void {
ctx!.fillStyle = "rgba(0,0,0,.28)";
ctx!.fillRect(CURL_CX - 4, CURL_BOT + 2, CURL_W + 8, 5);
curlPage(t);
}
const LAUNCH = { x: 214, y: 40 };
const LAND = { x: 300, y: 78 };
function planePos(phase: number, t: number): { x: number; y: number } {
const amp = 4 + t * 22;
const x = lerp(LAUNCH.x, LAND.x, phase) + Math.sin(phase * Math.PI * 2 * 2) * amp;
const y = lerp(LAUNCH.y, LAND.y, phase) - 18 * 4 * phase * (1 - phase);
return { x, y };
}
let planePhase = 0;
function drawPlane(t: number): void {
const p0 = planePos(planePhase, t);
const p1 = planePos(Math.min(1, planePhase + 0.01), t);
const angle = Math.atan2(p1.y - p0.y, p1.x - p0.x);
ctx!.save();
ctx!.translate(p0.x, p0.y);
ctx!.rotate(angle);
ctx!.fillStyle = PAPER;
ctx!.beginPath();
ctx!.moveTo(11, 0); ctx!.lineTo(-8, -6); ctx!.lineTo(-3, 0); ctx!.lineTo(-8, 6);
ctx!.closePath(); ctx!.fill();
ctx!.restore();
}
function draw(): void {
ctx!.fillStyle = SCENE_BG;
ctx!.fillRect(0, 0, LW, LH);
ctx!.fillStyle = "rgba(255,247,230,.06)";
ctx!.fillRect(0, 96, LW, 4);
drawBook(compositeValue);
drawCurl(compositeValue);
drawPlane(compositeValue);
ctx!.fillStyle = "rgba(255,247,230,.85)";
ctx!.font = "11px sans-serif";
ctx!.fillText("value " + compositeValue.toFixed(2), 12, 18);
ctx!.strokeStyle = "rgba(255,209,63,.65)";
ctx!.lineWidth = 3;
ctx!.beginPath();
ctx!.moveTo(12, 26); ctx!.lineTo(12 + compositeValue * 90, 26);
ctx!.stroke();
}
function update(dt: number, tMs: number): void {
autoplay(tMs);
valueStep(dt);
if (!reduce) planePhase = (tMs % 900) / 900;
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
draw();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); compositeValue = 0.55; targetValue = 0.55; planePhase = 0.4; }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = (e.clientX - r.left) / r.width;
targetValue = clamp(relX, COMPOSITE_VALUE_MIN, COMPOSITE_VALUE_MAX);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { valueStep(1); render(); } }
function onPointerMove(e: PointerEvent): void { if (!pointerActive) return; calcFromEvent(e); if (reduce) { valueStep(1); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.setAttribute("tabindex", "0");
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = true;
else if (e.key === "ArrowRight") keys.right = true;
else return;
pointerActive = true;
if (reduce) { valueStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
wrap.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("keyup", onKeyUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>위 장면을 한 책상 위에 모은 페이지 넘김 히어로</p>
</div>
</main>
);
}09 · 위 장면을 한 책상 위에 모은 페이지 넘김 히어로 — 측정 누적면적 17.499% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function curlPage(t) {
var radius = 6 + t * CURL_RADIUS_MAX_PX;
var stripCount = 14;
for (var i = 0; i < stripCount; i += 1) {
var u = i / stripCount;
var localX = u * CURL_W;
var theta = Math.max(0, localX - (CURL_W - radius * 2.4)) / Math.max(1, radius);
theta = clamp(theta, 0, Math.PI);
var sx = CURL_CX + CURL_W - CURL_W * (1 - u) - Math.min(localX, CURL_W - radius * 2.4);
var screenX = CURL_CX + (CURL_W - radius * 2.4) * u * 0 + localX - (1 - Math.cos(theta)) * radius;
var front = Math.cos(theta) >= 0;
var bright = front ? (0.6 + 0.4 * Math.cos(theta)) : (0.32 + 0.18 * (-Math.cos(theta)));
ctx.fillStyle = front ? shadeHex(PAPER, bright) : shadeHex(PAPER_BACK, bright);
var stripX = CURL_CX + localX - (localX > (CURL_W - radius * 2.4) ? (1 - Math.cos(theta)) * radius : 0);
ctx.fillRect(stripX, CURL_TOP, CURL_W / stripCount + 1, CURL_BOT - CURL_TOP);
}
}
09 항목은 누적면적 17.5%·강도 평균 123.2·23프레임 중 23프레임에서 움직였다.
이 아홉 장면이 실제로 쓰이는 화면
01 모서리 컬은 전자책 리더 페이지 넘김에, 02 접힘선 플립은 갤러리 다음 이미지 버튼에, 03 스택 플립은 카탈로그 뷰어에, 04 골 음영은 레시피 펼침 화면에 붙는다.
05 오리가미 폴드는 소개 인트로에, 06 구김 질감은 메모 카드 배경에, 07 종이비행기는 빈 상태 일러스트에, 08 종이 인형은 로딩 마스코트에, 09 책상 장면은 랜딩 히어로에 값 하나로 쓰인다.
컬 반경·접힘 각도·흔들림 폭이 갈리는 자리
값이 꽂히는 자리는 셋이다. 01·09는 컬 반경이 커질수록 말리는 폭이 넓어지고, 04·05는 접힘 각도가 커질수록 깊어진다. 07은 흔들림 폭으로 출렁임을 바꾼다.
| NN 이름 | 누적면적 % | 강도 평균 | x/23 |
|---|---|---|---|
| 01 모서리 컬 | 35.3 | 90.2 | 23/23 |
| 02 접힘선 플립 | 41.6 | 73.4 | 20/23 |
| 03 다중 페이지 스택 | 45.4 | 75.7 | 18/23 |
| 04 책 골 음영 | 31.9 | 22.8 | 23/23 |
| 05 오리가미 폴드 | 29.1 | 104.3 | 23/23 |
| 06 구김 질감 | 50.6 | 20.7 | 23/23 |
| 07 종이비행기 | 20.7 | 188.1 | 23/23 |
| 08 종이 인형 | 17.0 | 95.2 | 23/23 |
| 09 책상 히어로 | 17.5 | 123.2 | 23/23 |
동작 줄임 설정에서도 리스너가 남으면 깨지는 지점
prefers-reduced-motion: reduce가 참이면 아홉 항목 모두 is-demo 클래스를 지우고 자동 재생 루프를 끈다. 다만 pointerdown·keydown 리스너는 남아 01·05·09는 화살표 키(←→)로 여전히 움직인다 — "reduce면 전부 정지"로 오해하면 QA에서 깨지는 지점이다. 저사양 기기에서는 06이 가장 부담이 크다. 320×200 격자 안에서 매 프레임 valueNoise를 픽셀 단위로 호출해 명도를 다시 계산하는데, requestAnimationFrame 콜백 안에서 이 계산이 밀리면 프레임이 그대로 떨어진다.
값 세 가지만 바꿔보는 변형과 접근성
| 변형 | 바꾼 값 | 느낌 |
|---|---|---|
| 페이지 컬 반경 값 조정 | 01 CURL_RADIUS_PX 60 → 160 | 값을 줄이면 모서리가 아주 좁게 말려 살짝 접힌 듯 보이고, 값을 늘리면 훨씬 넓게 말려 원통에 가까운 굴림으로 보여야 한다 |
| 접힘 각도 값 조정 | 05 CREASE_ANGLE_DEG 30 → 120 | 값을 줄이면 살짝만 꺾여 거의 평평한 채로 남고, 값을 늘리면 깊게 꺾여 완전히 접힌 입체 형태로 보여야 한다 |
| 종이비행기 흔들림 값 조정 | 07 DRIFT_AMPLITUDE_PX 8 → 30 | 값을 줄이면 거의 직선에 가깝게 날아가고, 값을 늘리면 좌우로 크게 흔들리며 날아가야 한다 |
접근성은 두 층으로 나뉜다. 첫째, 아홉 항목 전부 window.matchMedia('(prefers-reduced-motion: reduce)').matches 값을 시작할 때 한 번 읽어 자동 재생 루프를 끈다. 둘째, 01·05·09는 포인터뿐 아니라 키보드 화살표(←→, 01·09는 ↑↓도)로도 같은 값을 조작할 수 있어 마우스 없이도 컬 반경·접힘 각도·책상 장면의 값을 직접 확인할 수 있다. seedRand로 고정한 시드값 덕분에 .is-demo 자동 시연은 매번 같은 경로로 움직이며, 실제 사용자 입력을 흉내 낸 것일 뿐 진짜 클릭을 대신하지는 않는다.
zip 비밀번호 3zppfspp로 폴더를 풀면 fit() 함수가 컨테이너 크기에 맞춰 캔버스를 다시 스케일하는 부분도 그대로 들어 있다 — 고정 폭(LW 320) 컨테이너 없이 반응형 폭에 바로 얹으면 레이아웃이 살짝 흔들릴 수 있다.
종이 접기 애니메이션과 이 페이지 컬 데모, 어디서 갈라지는가?
종이 접기 애니메이션이라는 이름으로 검색하면 대부분 transform: rotateY와 backface-visibility: hidden을 쓴 단일 힌지 접기가 나온다 — 면이 두 장뿐이라 CSS 트랜지션만으로 충분하다. 이 페이지 컬은 getContext('2d') 위에서 각도마다 픽셀 줄무늬를 따로 계산해 원기둥에 감기는 곡면·구겨진 표면의 노이즈 높낮이·관절이 이어지는 사슬까지 만든다는 점에서 다루는 계산 자체가 다르다. 카드가 뒤집히는 단순한 두 면 전환은 CSS 카드 뒤집기 애니메이션 9종에서, 스크롤에 맞춰 장면이 바뀌는 연출은 스크롤 스토리텔링 섹션 9종에서, 종이 대신 로프·래그돌처럼 관절이 물리적으로 반응하는 계산은 자바스크립트 물리엔진 원리 9종에서 각각 다룬다. 캔버스 2D API 자체의 좌표계·변환 문법은 MDN Canvas API 문서에 정리돼 있다.
종이 넘기는 애니메이션을 처음 붙일 때 자주 나오는 질문
CSS transform 만으로 페이지 컬을 만들 수 있나요?
단일 힌지 접기 정도는 rotateY와 backface-visibility로 충분하다. 다만 01처럼 모서리가 원기둥에 감기듯 곡면을 그리려면 각도마다 다른 폭·명도를 가진 줄무늬를 따로 그려야 하는데, 이건 CSS 단독 문법으로는 표현할 수 없고 requestAnimationFrame 루프 안에서 픽셀 단위로 계산해야 한다.
종이책 애니메이션처럼 여러 장을 순서대로 넘기려면 어떤 항목을 보면 되나요?
03 다중 페이지 스택이 그 방식이다. remaining 값이 줄어들 때마다 stackThickness가 남은 장 수만큼 그림자 두께를 다시 계산해, 종이책 애니메이션에서 기대하는 "쌓인 만큼 두꺼워 보이는" 인상을 값 하나로 유지한다.
포인터가 없는 터치 전용 기기에서도 컬이 동작하나요?
01의 calcFromEvent 함수는 e.touches && e.touches[0]을 먼저 확인해 터치 좌표를 우선 읽고, 없으면 마우스 이벤트 좌표로 넘어간다. pointerdown·pointermove는 Pointer Events API라 마우스·펜·터치를 같은 코드로 받으므로 별도 분기 없이도 동작한다.









