06A Cylinder Gallery Where Cards Turn Around a Drum and Perspective Sets Their Size, Brightness and Order
Each card has an angle on the drum, and its 3D position is projected with perspective for screen position and scale. Cards are re-sorted by depth and drawn far to near, with the back ones dimmed.
Best forAlbum, product showcase and portfolio cover displays
RADIUSCAM_DSTEP_ANGBACK_DIM

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A gallery of twelve cards turning around a cylinder, changing size and brightness with perspective"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Stopped</p>
<div class="hs__tools">
<button class="hs__btn" data-role="prev" type="button">Previous</button>
<button class="hs__btn" data-role="next" type="button">Next</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
}
.hs__canvas:active {
cursor: grabbing;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
}
.hs__canvas:active {
cursor: grabbing;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 카드를 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 휠 한 번의 이동량을 px 로 — 줄 단위(deltaMode 1)·쪽 단위(deltaMode 2) 장치도 같은 눈금으로 맞춘다.
// 가로·세로 중 큰 쪽을 쓴다: 트랙패드의 가로 쓸기와 마우스 휠의 세로 굴림이 같은 방향으로 들어온다
function wheelPx(ev) {
const unit = ev.deltaMode === 1 ? 16 : (ev.deltaMode === 2 ? W : 1);
const d = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
return d * unit;
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function roundRectPath(c, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
c.beginPath();
c.moveTo(x + rr, y);
c.lineTo(x + w - rr, y);
c.quadraticCurveTo(x + w, y, x + w, y + rr);
c.lineTo(x + w, y + h - rr);
c.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
c.lineTo(x + rr, y + h);
c.quadraticCurveTo(x, y + h, x, y + h - rr);
c.lineTo(x, y + rr);
c.quadraticCurveTo(x, y, x + rr, y);
c.closePath();
}
// 카드 한 장 — 사진 대신 계산한 그림 칸(그라디언트 하늘·해·언덕) + 글줄 막대 두 개 + 번호
function drawCard(c, x, y, w, h, i, light) {
const t1 = TINTS[i % TINTS.length];
const t2 = TINTS[(i + 2) % TINTS.length];
const k = clamp(light, 0, 1);
roundRectPath(c, x, y, w, h, 10);
c.fillStyle = rgba(mix(BG, PAPER, k), 1);
c.fill();
const ih = h * 0.58;
const g = c.createLinearGradient(x, y, x + w, y + ih);
g.addColorStop(0, rgba(mix(BG, t1, k), 1));
g.addColorStop(1, rgba(mix(BG, t2, k * 0.92), 1));
c.save();
roundRectPath(c, x + 6, y + 6, w - 12, ih - 6, 7);
c.fillStyle = g;
c.fill();
c.clip();
c.fillStyle = rgba(mix(BG, PAPER, k * 0.9), 0.9);
c.beginPath();
c.arc(x + w * 0.7, y + ih * 0.36, Math.min(w, ih) * 0.13, 0, Math.PI * 2);
c.fill();
c.fillStyle = rgba(mix(BG, INK, k), 0.55);
c.beginPath();
c.moveTo(x, y + ih);
c.lineTo(x + w * 0.32, y + ih * 0.55);
c.lineTo(x + w * 0.55, y + ih * 0.8);
c.lineTo(x + w * 0.78, y + ih * 0.62);
c.lineTo(x + w, y + ih);
c.closePath();
c.fill();
c.restore();
c.fillStyle = rgba(mix(BG, INK, k), 0.75);
c.fillRect(x + 10, y + ih + 12, w * 0.62, 6);
c.fillStyle = rgba(mix(BG, INK, k), 0.4);
c.fillRect(x + 10, y + ih + 24, w * 0.44, 5);
c.fillStyle = rgba(mix(BG, INK, k), 0.9);
c.font = '700 13px sans-serif';
c.textAlign = 'right';
c.textBaseline = 'alphabetic';
c.fillText(String(i + 1).padStart(2, '0'), x + w - 9, y + h - 10);
}
const CARD_N = 12;
const RADIUS = 250;
const CAM_D = 520;
const HORIZON = 22;
const LIFT = 150;
const CARD_W = 118;
const CARD_H = 150;
const STEP_ANG = (Math.PI * 2) / CARD_N;
const HALF = CARD_W / (2 * RADIUS);
const MAX_ROT = (CARD_N - 1) * STEP_ANG;
const FRICTION = 0.93;
const ANG_GAIN = 0.00016;
const BACK_DIM = 0.35;
const STOP_AV = 0.0004;
const DEMO_LOOP = 4400;
const TXT_SPIN = 'The cylinder is turning';
const TXT_STOP = 'Stopped';
const prevBtn = root.querySelector('[data-role="prev"]');
const nextBtn = root.querySelector('[data-role="next"]');
let rot = 0;
let av = 0;
let spinning = false;
// 원통 위 각도 φ 의 점 → 화면 x 와 원근 배율. 카메라는 축에서 CAM_D 떨어져 있고 맨 앞 카드가 배율 1
function project(phi) {
const X = RADIUS * Math.sin(phi);
const Z = CAM_D - RADIUS * Math.cos(phi);
const s = (CAM_D - RADIUS) / Z;
return [W / 2 + X * s, s];
}
// 높이 h(카드 가운데 0, 아래 +) 의 화면 y — 카메라가 LIFT 만큼 위에 있어 먼 카드일수록 지평선(HORIZON) 쪽으로 올라간다
function screenY(h, s) { return HORIZON + (h + LIFT) * s; }
// 카메라 쪽을 보고 있는가 — 바깥 법선과 카메라 방향의 내적 = CAM_D·cosφ − R
function facing(phi) { return CAM_D * Math.cos(phi) - RADIUS; }
function canMove(d) { return d > 0 ? rot < MAX_ROT - 0.001 : rot > 0.001; }
function spin(d) {
av += d * ANG_GAIN;
spinning = true;
say(TXT_SPIN);
}
// 목표 각도에 정확히 멈추는 처음 각속도 — 등비급수 거꾸로
function speedFor(distance) { return distance * (1 - FRICTION) / FRICTION; }
function step() {
rot += av;
av *= FRICTION;
if (rot < 0) {
rot = 0;
av = -av * 0.3;
}
if (rot > MAX_ROT) {
rot = MAX_ROT;
av = -av * 0.3;
}
if (spinning && Math.abs(av) < STOP_AV) {
av = 0;
spinning = false;
say(TXT_STOP);
}
}
function settle() {
rot = 0;
av = 0;
say(TXT_STOP);
}
function demoTick(t) {
for (let k = 0; k < 6; k += 1) {
if (crossed(t, 200 + k * 40, DEMO_LOOP)) {
spin(100);
}
}
for (let k = 0; k < 6; k += 1) {
if (crossed(t, 2300 + k * 40, DEMO_LOOP)) {
spin(-100);
}
}
}
function drawCardOn(i, phi) {
const a = project(phi - HALF);
const b = project(phi + HALF);
const front = facing(phi) > 0;
// 앞을 보는 카드는 밝게, 등을 보이는 뒤쪽 카드는 어둡게 — cosφ 를 0~1 로 옮겨 섞는다
const lit = lerp(BACK_DIM, 1, (Math.cos(phi) + 1) / 2);
const tint = TINTS[i % TINTS.length];
const ta = screenY(-CARD_H / 2, a[1]);
const ba = screenY(CARD_H / 2, a[1]);
const tb = screenY(-CARD_H / 2, b[1]);
const bb = screenY(CARD_H / 2, b[1]);
ctx.beginPath();
ctx.moveTo(a[0], ta);
ctx.lineTo(b[0], tb);
ctx.lineTo(b[0], bb);
ctx.lineTo(a[0], ba);
ctx.closePath();
if (!front) {
ctx.fillStyle = rgba(mix(BG, mix(INK, tint, 0.3), lit), 1);
ctx.fill();
return;
}
const g = ctx.createLinearGradient(a[0], 0, b[0], 0);
g.addColorStop(0, rgba(mix(BG, tint, lit * 0.9), 1));
g.addColorStop(1, rgba(mix(BG, mix(tint, PAPER, 0.35), lit), 1));
ctx.fillStyle = g;
ctx.fill();
// 글줄 막대와 번호 — 양쪽 모서리 높이를 선형으로 이어 원근을 따른다
const rows = [0.5, 0.64, 0.76];
for (let r = 0; r < rows.length; r += 1) {
const ya = lerp(ta, ba, rows[r]);
const yb = lerp(tb, bb, rows[r]);
const w = r === 0 ? 0.75 : 0.5;
ctx.strokeStyle = rgba(mix(BG, INK, lit), r === 0 ? 0.85 : 0.45);
ctx.lineWidth = (r === 0 ? 6 : 4) * (a[1] + b[1]) / 2;
ctx.beginPath();
ctx.moveTo(lerp(a[0], b[0], 0.12), lerp(ya, yb, 0.12));
ctx.lineTo(lerp(a[0], b[0], 0.12 + w * 0.8), lerp(ya, yb, 0.12 + w * 0.8));
ctx.stroke();
}
const mx = (a[0] + b[0]) / 2;
const s = (a[1] + b[1]) / 2;
ctx.fillStyle = rgba(mix(BG, INK, lit), 0.9);
ctx.font = '800 ' + Math.round(30 * s) + 'px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillText(String(i + 1).padStart(2, '0'), mx, lerp((ta + tb) / 2, (ba + bb) / 2, 0.36));
}
function draw() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
// 바닥 고리 — 원통 밑면의 둘레를 같은 투영으로 찍어 잇는다
ctx.strokeStyle = rgba(mix(BG, LILAC, 0.35), 1);
ctx.lineWidth = 2;
ctx.beginPath();
for (let k = 0; k <= 48; k += 1) {
const p = project((k / 48) * Math.PI * 2);
const y = screenY(CARD_H / 2 + 10, p[1]);
if (k === 0) {
ctx.moveTo(p[0], y);
}
else {
ctx.lineTo(p[0], y);
}
}
ctx.stroke();
// 화가 알고리즘 — 카메라에서 먼(cosφ 가 작은) 카드부터 그린다
const order = [];
for (let i = 0; i < CARD_N; i += 1) {
order.push(i);
}
order.sort(function (p, q) {
return Math.cos(p * STEP_ANG - rot) - Math.cos(q * STEP_ANG - rot);
});
for (let k = 0; k < order.length; k += 1) {
const i = order[k];
drawCardOn(i, i * STEP_ANG - rot);
}
}
function onWheel(ev) {
const d = wheelPx(ev);
// 첫 카드·마지막 카드가 앞에 오면 더 돌지 않고 페이지 스크롤에 넘긴다
if (d === 0 || !canMove(d)) {
return;
}
ev.preventDefault();
wake();
if (still) {
rot = clamp(rot + d / RADIUS, 0, MAX_ROT);
say(TXT_STOP);
draw();
return;
}
spin(d);
}
cv.addEventListener('wheel', onWheel, { passive: false });
let dragId = -1;
let dragX = 0;
let dragT = 0;
let dragAv = 0;
cv.addEventListener('pointerdown', function (ev) {
wake();
dragId = ev.pointerId;
cv.setPointerCapture(ev.pointerId);
dragX = toScene(ev)[0];
dragT = ev.timeStamp;
dragAv = 0;
av = 0;
});
cv.addEventListener('pointermove', function (ev) {
if (ev.pointerId !== dragId) {
return;
}
const sx = toScene(ev)[0];
// 끈 거리(px)를 원통 둘레의 호로 보고 각도로 바꾼다
const da = -(sx - dragX) / RADIUS;
const steps = Math.max(1, (ev.timeStamp - dragT) / FIXED_DT);
rot = clamp(rot + da, 0, MAX_ROT);
dragAv = lerp(dragAv, da / steps, 0.5);
dragX = sx;
dragT = ev.timeStamp;
if (still) {
draw();
}
});
function endDrag(ev) {
if (ev.pointerId !== dragId) {
return;
}
dragId = -1;
if (still) {
return;
}
av = clamp(dragAv, -0.08, 0.08);
spinning = true;
say(TXT_SPIN);
}
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function goCard(k) {
const goal = clamp(k, 0, CARD_N - 1) * STEP_ANG;
if (Math.abs(goal - rot) < 0.001) {
return false;
}
if (still) {
rot = goal;
say(TXT_STOP);
draw();
return true;
}
av = speedFor(goal - rot);
spinning = true;
say(TXT_SPIN);
return true;
}
cv.addEventListener('keydown', function (ev) {
const cur = Math.round(rot / STEP_ANG);
let moved = false;
if (ev.key === 'ArrowRight') {
moved = goCard(cur + 1);
}
else if (ev.key === 'ArrowLeft') {
moved = goCard(cur - 1);
}
else if (ev.key === 'Home') {
moved = goCard(0);
}
else if (ev.key === 'End') {
moved = goCard(CARD_N - 1);
}
if (!moved) {
return;
}
ev.preventDefault();
wake();
});
if (prevBtn) {
prevBtn.addEventListener('click', function () { wake(); goCard(Math.round(rot / STEP_ANG) - 1); });
}
if (nextBtn) {
nextBtn.addEventListener('click', function () { wake(); goCard(Math.round(rot / STEP_ANG) + 1); });
}
say(TXT_STOP);
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// HscCylinderGalleryPerspectiveDepthSort - vanilla 판(06-hsc-cylinder-gallery-perspective-depth-sort) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./HscCylinderGalleryPerspectiveDepthSort.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HscCylinderGalleryPerspectiveDepthSort() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 카드를 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 휠 한 번의 이동량을 px 로 — 줄 단위(deltaMode 1)·쪽 단위(deltaMode 2) 장치도 같은 눈금으로 맞춘다.
// 가로·세로 중 큰 쪽을 쓴다: 트랙패드의 가로 쓸기와 마우스 휠의 세로 굴림이 같은 방향으로 들어온다
function wheelPx(ev: WheelEvent): number {
const unit = ev.deltaMode === 1 ? 16 : (ev.deltaMode === 2 ? W : 1);
const d = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
return d * unit;
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function roundRectPath(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
c.beginPath();
c.moveTo(x + rr, y);
c.lineTo(x + w - rr, y);
c.quadraticCurveTo(x + w, y, x + w, y + rr);
c.lineTo(x + w, y + h - rr);
c.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
c.lineTo(x + rr, y + h);
c.quadraticCurveTo(x, y + h, x, y + h - rr);
c.lineTo(x, y + rr);
c.quadraticCurveTo(x, y, x + rr, y);
c.closePath();
}
// 카드 한 장 — 사진 대신 계산한 그림 칸(그라디언트 하늘·해·언덕) + 글줄 막대 두 개 + 번호
function drawCard(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, i: number, light: number): void {
const t1 = TINTS[i % TINTS.length];
const t2 = TINTS[(i + 2) % TINTS.length];
const k = clamp(light, 0, 1);
roundRectPath(c, x, y, w, h, 10);
c.fillStyle = rgba(mix(BG, PAPER, k), 1);
c.fill();
const ih = h * 0.58;
const g = c.createLinearGradient(x, y, x + w, y + ih);
g.addColorStop(0, rgba(mix(BG, t1, k), 1));
g.addColorStop(1, rgba(mix(BG, t2, k * 0.92), 1));
c.save();
roundRectPath(c, x + 6, y + 6, w - 12, ih - 6, 7);
c.fillStyle = g;
c.fill();
c.clip();
c.fillStyle = rgba(mix(BG, PAPER, k * 0.9), 0.9);
c.beginPath();
c.arc(x + w * 0.7, y + ih * 0.36, Math.min(w, ih) * 0.13, 0, Math.PI * 2);
c.fill();
c.fillStyle = rgba(mix(BG, INK, k), 0.55);
c.beginPath();
c.moveTo(x, y + ih);
c.lineTo(x + w * 0.32, y + ih * 0.55);
c.lineTo(x + w * 0.55, y + ih * 0.8);
c.lineTo(x + w * 0.78, y + ih * 0.62);
c.lineTo(x + w, y + ih);
c.closePath();
c.fill();
c.restore();
c.fillStyle = rgba(mix(BG, INK, k), 0.75);
c.fillRect(x + 10, y + ih + 12, w * 0.62, 6);
c.fillStyle = rgba(mix(BG, INK, k), 0.4);
c.fillRect(x + 10, y + ih + 24, w * 0.44, 5);
c.fillStyle = rgba(mix(BG, INK, k), 0.9);
c.font = '700 13px sans-serif';
c.textAlign = 'right';
c.textBaseline = 'alphabetic';
c.fillText(String(i + 1).padStart(2, '0'), x + w - 9, y + h - 10);
}
const CARD_N = 12;
const RADIUS = 250;
const CAM_D = 520;
const HORIZON = 22;
const LIFT = 150;
const CARD_W = 118;
const CARD_H = 150;
const STEP_ANG = (Math.PI * 2) / CARD_N;
const HALF = CARD_W / (2 * RADIUS);
const MAX_ROT = (CARD_N - 1) * STEP_ANG;
const FRICTION = 0.93;
const ANG_GAIN = 0.00016;
const BACK_DIM = 0.35;
const STOP_AV = 0.0004;
const DEMO_LOOP = 4400;
const TXT_SPIN = '원통이 도는 중';
const TXT_STOP = '멈춤';
const prevBtn = root.querySelector('[data-role="prev"]') as HTMLButtonElement | null;
const nextBtn = root.querySelector('[data-role="next"]') as HTMLButtonElement | null;
let rot = 0;
let av = 0;
let spinning = false;
// 원통 위 각도 φ 의 점 → 화면 x 와 원근 배율. 카메라는 축에서 CAM_D 떨어져 있고 맨 앞 카드가 배율 1
function project(phi: number): [number, number] {
const X = RADIUS * Math.sin(phi);
const Z = CAM_D - RADIUS * Math.cos(phi);
const s = (CAM_D - RADIUS) / Z;
return [W / 2 + X * s, s];
}
// 높이 h(카드 가운데 0, 아래 +) 의 화면 y — 카메라가 LIFT 만큼 위에 있어 먼 카드일수록 지평선(HORIZON) 쪽으로 올라간다
function screenY(h: number, s: number): number { return HORIZON + (h + LIFT) * s; }
// 카메라 쪽을 보고 있는가 — 바깥 법선과 카메라 방향의 내적 = CAM_D·cosφ − R
function facing(phi: number): number { return CAM_D * Math.cos(phi) - RADIUS; }
function canMove(d: number): boolean { return d > 0 ? rot < MAX_ROT - 0.001 : rot > 0.001; }
function spin(d: number): void {
av += d * ANG_GAIN;
spinning = true;
say(TXT_SPIN);
}
// 목표 각도에 정확히 멈추는 처음 각속도 — 등비급수 거꾸로
function speedFor(distance: number): number { return distance * (1 - FRICTION) / FRICTION; }
function step(): void {
rot += av;
av *= FRICTION;
if (rot < 0) { rot = 0; av = -av * 0.3; }
if (rot > MAX_ROT) { rot = MAX_ROT; av = -av * 0.3; }
if (spinning && Math.abs(av) < STOP_AV) {
av = 0;
spinning = false;
say(TXT_STOP);
}
}
function settle(): void {
rot = 0;
av = 0;
say(TXT_STOP);
}
function demoTick(t: number): void {
for (let k = 0; k < 6; k += 1) { if (crossed(t, 200 + k * 40, DEMO_LOOP)) { spin(100); } }
for (let k = 0; k < 6; k += 1) { if (crossed(t, 2300 + k * 40, DEMO_LOOP)) { spin(-100); } }
}
function drawCardOn(i: number, phi: number): void {
const a = project(phi - HALF);
const b = project(phi + HALF);
const front = facing(phi) > 0;
// 앞을 보는 카드는 밝게, 등을 보이는 뒤쪽 카드는 어둡게 — cosφ 를 0~1 로 옮겨 섞는다
const lit = lerp(BACK_DIM, 1, (Math.cos(phi) + 1) / 2);
const tint = TINTS[i % TINTS.length];
const ta = screenY(-CARD_H / 2, a[1]);
const ba = screenY(CARD_H / 2, a[1]);
const tb = screenY(-CARD_H / 2, b[1]);
const bb = screenY(CARD_H / 2, b[1]);
ctx.beginPath();
ctx.moveTo(a[0], ta);
ctx.lineTo(b[0], tb);
ctx.lineTo(b[0], bb);
ctx.lineTo(a[0], ba);
ctx.closePath();
if (!front) {
ctx.fillStyle = rgba(mix(BG, mix(INK, tint, 0.3), lit), 1);
ctx.fill();
return;
}
const g = ctx.createLinearGradient(a[0], 0, b[0], 0);
g.addColorStop(0, rgba(mix(BG, tint, lit * 0.9), 1));
g.addColorStop(1, rgba(mix(BG, mix(tint, PAPER, 0.35), lit), 1));
ctx.fillStyle = g;
ctx.fill();
// 글줄 막대와 번호 — 양쪽 모서리 높이를 선형으로 이어 원근을 따른다
const rows = [0.5, 0.64, 0.76];
for (let r = 0; r < rows.length; r += 1) {
const ya = lerp(ta, ba, rows[r]);
const yb = lerp(tb, bb, rows[r]);
const w = r === 0 ? 0.75 : 0.5;
ctx.strokeStyle = rgba(mix(BG, INK, lit), r === 0 ? 0.85 : 0.45);
ctx.lineWidth = (r === 0 ? 6 : 4) * (a[1] + b[1]) / 2;
ctx.beginPath();
ctx.moveTo(lerp(a[0], b[0], 0.12), lerp(ya, yb, 0.12));
ctx.lineTo(lerp(a[0], b[0], 0.12 + w * 0.8), lerp(ya, yb, 0.12 + w * 0.8));
ctx.stroke();
}
const mx = (a[0] + b[0]) / 2;
const s = (a[1] + b[1]) / 2;
ctx.fillStyle = rgba(mix(BG, INK, lit), 0.9);
ctx.font = '800 ' + Math.round(30 * s) + 'px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillText(String(i + 1).padStart(2, '0'), mx, lerp((ta + tb) / 2, (ba + bb) / 2, 0.36));
}
function draw(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
// 바닥 고리 — 원통 밑면의 둘레를 같은 투영으로 찍어 잇는다
ctx.strokeStyle = rgba(mix(BG, LILAC, 0.35), 1);
ctx.lineWidth = 2;
ctx.beginPath();
for (let k = 0; k <= 48; k += 1) {
const p = project((k / 48) * Math.PI * 2);
const y = screenY(CARD_H / 2 + 10, p[1]);
if (k === 0) { ctx.moveTo(p[0], y); } else { ctx.lineTo(p[0], y); }
}
ctx.stroke();
// 화가 알고리즘 — 카메라에서 먼(cosφ 가 작은) 카드부터 그린다
const order: number[] = [];
for (let i = 0; i < CARD_N; i += 1) { order.push(i); }
order.sort(function (p: number, q: number): number {
return Math.cos(p * STEP_ANG - rot) - Math.cos(q * STEP_ANG - rot);
});
for (let k = 0; k < order.length; k += 1) {
const i = order[k];
drawCardOn(i, i * STEP_ANG - rot);
}
}
function onWheel(ev: WheelEvent): void {
const d = wheelPx(ev);
// 첫 카드·마지막 카드가 앞에 오면 더 돌지 않고 페이지 스크롤에 넘긴다
if (d === 0 || !canMove(d)) { return; }
ev.preventDefault();
wake();
if (still) { rot = clamp(rot + d / RADIUS, 0, MAX_ROT); say(TXT_STOP); draw(); return; }
spin(d);
}
cv.addEventListener('wheel', onWheel, { passive: false });
let dragId = -1;
let dragX = 0;
let dragT = 0;
let dragAv = 0;
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragId = ev.pointerId;
cv.setPointerCapture(ev.pointerId);
dragX = toScene(ev)[0];
dragT = ev.timeStamp;
dragAv = 0;
av = 0;
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (ev.pointerId !== dragId) { return; }
const sx = toScene(ev)[0];
// 끈 거리(px)를 원통 둘레의 호로 보고 각도로 바꾼다
const da = -(sx - dragX) / RADIUS;
const steps = Math.max(1, (ev.timeStamp - dragT) / FIXED_DT);
rot = clamp(rot + da, 0, MAX_ROT);
dragAv = lerp(dragAv, da / steps, 0.5);
dragX = sx;
dragT = ev.timeStamp;
if (still) { draw(); }
});
function endDrag(ev: PointerEvent): void {
if (ev.pointerId !== dragId) { return; }
dragId = -1;
if (still) { return; }
av = clamp(dragAv, -0.08, 0.08);
spinning = true;
say(TXT_SPIN);
}
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function goCard(k: number): boolean {
const goal = clamp(k, 0, CARD_N - 1) * STEP_ANG;
if (Math.abs(goal - rot) < 0.001) { return false; }
if (still) { rot = goal; say(TXT_STOP); draw(); return true; }
av = speedFor(goal - rot);
spinning = true;
say(TXT_SPIN);
return true;
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const cur = Math.round(rot / STEP_ANG);
let moved = false;
if (ev.key === 'ArrowRight') { moved = goCard(cur + 1); }
else if (ev.key === 'ArrowLeft') { moved = goCard(cur - 1); }
else if (ev.key === 'Home') { moved = goCard(0); }
else if (ev.key === 'End') { moved = goCard(CARD_N - 1); }
if (!moved) { return; }
ev.preventDefault();
wake();
});
if (prevBtn) { prevBtn.addEventListener('click', function (): void { wake(); goCard(Math.round(rot / STEP_ANG) - 1); }); }
if (nextBtn) { nextBtn.addEventListener('click', function (): void { wake(); goCard(Math.round(rot / STEP_ANG) + 1); }); }
say(TXT_STOP);
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="카드 열두 장이 원통 둘레를 따라 돌며 원근으로 크기와 밝기가 바뀌는 갤러리"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">멈춤</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="prev" type="button">이전</button>
<button className={cx("hs__btn")} data-role="next" type="button">다음</button>
</div>
</div>
);
}06 A Cylinder Gallery Where Cards Turn Around a Drum and Perspective Sets Their Size, Brightness and Order — Measured area 28.848% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · Horizontal Scroll Animation CSS: 9 Inertia Scenes
The post this demo lives in
Horizontal Scroll Animation CSS: 9 Inertia ScenesHorizontal scroll animation css is more than turning wheel input sideways — scene 01 glides it on inertia, 06 projects c See all demos in this post


