06A Tail That Drags Along the Path at a Fixed Link Length
The head sits at the progress distance on the path, the trailing links move by the gap from their previous positions, and neighbor distances are snapped back to a set length several times per tick so the tail curls inward through bends.
Best forBrand mascot guide scene
TAIL_SEGMENTSSEGMENT_LENGTHCONSTRAINT_ITERATIONS

<main class="stage">
<div class="sp is-demo" id="sp">
<canvas class="sp__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="sp__scroller" data-role="scroller" tabindex="0" role="region" aria-label="A tail drags along the path while keeping its link length. Scroll inside this box with the wheel or a finger, or use the arrow keys, PageUp, PageDown, Home and End to move through it. The page scroll stays as it is">
<div class="sp__track"></div>
</div>
<p class="sp__status" data-role="status" aria-live="off">Progress 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 무대 안 스크롤 상자 — 장면 위에 투명하게 겹쳐 휠·손가락·키보드를 받는다.
// overscroll-behavior: contain — 상자 끝에 닿아도 남은 스크롤을 바깥 페이지로 넘기지 않는다
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, .55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 굴릴 거리 — 상자 높이의 6배. 상자 높이의 5배를 굴리면 진행률이 0 에서 1 이 된다
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
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;
}
@media (max-width: 340px) {
.sp__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 스크립트가 가상 진행을 멈추고 끝 상태 화면을 그린다. 상자 굴림도 부드러운 이동 없이 바로
@media (prefers-reduced-motion: reduce) {
.sp__scroller { scroll-behavior: auto; }
}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);
}
.sp {
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;
}
.sp__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sp__scroller {
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.55) transparent;
cursor: ns-resize;
}
.sp__scroller:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sp__track {
height: 600%;
}
.sp__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
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;
}
@media (max-width: 340px) {
.sp__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__scroller {
scroll-behavior: auto;
}
}(function () {
const root = document.getElementById("sp");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const GUIDE = [86, 78, 116];
const CREAM = [255, 248, 234];
const SKY = [124, 196, 255];
const PINK = [255, 160, 190];
const MINT = [126, 226, 192];
const LILAC = [190, 164, 255];
const PEACH = [255, 208, 168];
const LEMON = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a) {
let v = a;
while (v > Math.PI) {
v -= TAU;
}
while (v < -Math.PI) {
v += TAU;
}
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts) {
const n = pts.length - 1;
return function (u) {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
function buildLengthTable(curve, samples) {
const us = [0];
const lens = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab, s) {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) {
lo = mid;
}
else {
hi = mid;
}
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab, s) { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — milestones·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab, u) {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab, s) {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab, s0, s1, res) {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const scrollerEl = root.querySelector('[data-role="scroller"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const scroller = scrollerEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p) {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// Progress 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p) { return 'Progress ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed, legs) {
const rng = makeRng(seed);
const ws = [];
const ps = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch, t) {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 꼬리 마디 수 — 머리 뒤에 이어지는 점 개수
const TAIL_SEGMENTS = 12;
// 마디 길이(px) — 이웃 점 사이 거리를 이 값으로 되맞춘다
const SEGMENT_LENGTH = 15;
// 되맞춤 반복 — 틱마다 모든 마디 거리를 이 횟수만큼 다시 맞춘다(많을수록 덜 늘어난다)
const CONSTRAINT_ITERATIONS = 6;
// 끌림 — 이전 위치와의 차(= 지난 틱에 움직인 만큼)에 틱마다 곱한다
const TAIL_DRAG = 0.9;
const DEMO_SEED = 4746;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — Progress 끝 상태
const STILL_PROGRESS = 1;
const ROUTE = [[40, 226], [110, 70], [210, 150], [290, 60], [400, 110], [440, 226], [320, 238], [230, 196]];
const TABLE = buildLengthTable(makeCurve(ROUTE), 200);
// 마디 위치(xs·ys)와 한 틱 전 위치(pxs·pys). 0 번이 머리
const xs = [];
const ys = [];
const pxs = [];
const pys = [];
function head() { return pointAtLength(TABLE, prog * TABLE.total); }
// 처음 자리 — 머리 뒤쪽 길 위에 마디 길이 간격으로 늘어놓는다. 길 시작보다 뒤면 시작점 접선 반대쪽으로 곧게
function onRestart() {
const s = prog * TABLE.total;
const a = pointAtLength(TABLE, 0);
const b = pointAtLength(TABLE, 4);
const d = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
for (let i = 0; i <= TAIL_SEGMENTS; i += 1) {
const back = s - i * SEGMENT_LENGTH;
const q = back >= 0 ? pointAtLength(TABLE, back) : [a[0] + (a[0] - b[0]) / d * -back, a[1] + (a[1] - b[1]) / d * -back];
xs[i] = q[0];
ys[i] = q[1];
pxs[i] = q[0];
pys[i] = q[1];
}
}
function step(dp) {
const h = head();
xs[0] = h[0];
ys[0] = h[1];
// 이전 위치와의 차로 움직이기 — 지난 틱에 간 만큼(끌림을 곱해) 이번 틱에도 간다
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const vx = (xs[i] - pxs[i]) * TAIL_DRAG;
const vy = (ys[i] - pys[i]) * TAIL_DRAG;
pxs[i] = xs[i];
pys[i] = ys[i];
xs[i] += vx;
ys[i] += vy;
}
// 마디 거리 되맞춤 — 머리(0)는 길 위에 고정, 1 번은 혼자 다 움직이고 나머지 이웃 쌍은 반씩 나눠 움직인다
for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const dx = xs[i] - xs[i - 1];
const dy = ys[i] - ys[i - 1];
const d = Math.hypot(dx, dy) || 1e-6;
const diff = (d - SEGMENT_LENGTH) / d;
if (i === 1) {
xs[i] -= dx * diff;
ys[i] -= dy * diff;
}
else {
xs[i] -= dx * diff * 0.5;
ys[i] -= dy * diff * 0.5;
xs[i - 1] += dx * diff * 0.5;
ys[i - 1] += dy * diff * 0.5;
}
}
}
}
function draw() {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
ctx.setLineDash([]);
// 몸통 — 꼬리 끝(가늘게)부터 머리(굵게) 쪽으로, 마디 사이를 굵은 선으로 이어 칠한다
for (let i = TAIL_SEGMENTS; i >= 1; i -= 1) {
const r = lerp(14, 4, i / TAIL_SEGMENTS);
ctx.strokeStyle = rgba(i % 2 ? PINK : PEACH, 1);
ctx.lineWidth = r * 2;
ctx.beginPath();
ctx.moveTo(xs[i], ys[i]);
ctx.lineTo(xs[i - 1], ys[i - 1]);
ctx.stroke();
}
for (let i = TAIL_SEGMENTS; i >= 1; i -= 1) {
const r = lerp(14, 4, i / TAIL_SEGMENTS);
ctx.fillStyle = rgba(BG, 0.35);
ctx.beginPath();
ctx.arc(xs[i], ys[i], r * 0.32, 0, TAU);
ctx.fill();
}
// 머리 — 1 번 마디에서 머리로 향하는 쪽을 본다
const ax = xs[0] - xs[1];
const ay = ys[0] - ys[1];
const ad = Math.hypot(ax, ay) || 1;
const fx = ax / ad;
const fy = ay / ad;
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(xs[0], ys[0], 16, 0, TAU);
ctx.fill();
for (let e = -1; e <= 1; e += 2) {
const ex = xs[0] + fx * 6 - fy * 7 * e;
const ey = ys[0] + fy * 6 + fx * 7 * e;
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(ex, ey, 4.5, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.beginPath();
ctx.arc(ex + fx * 1.8, ey + fy * 1.8, 2.2, 0, TAU);
ctx.fill();
}
}
function stateText() {
const moving = Math.hypot(xs[TAIL_SEGMENTS] - pxs[TAIL_SEGMENTS], ys[TAIL_SEGMENTS] - pys[TAIL_SEGMENTS]) > 0.05;
return pctText(prog) + ' — ' + (moving ? 'tail following' : 'tail at rest');
}
const SCHEDULE = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick() {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function restart(p) {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function () {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) {
return;
}
expectTop = -1;
wake();
inputP = readScroll();
if (still) {
after();
}
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function () { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function () { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function () { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev) {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') {
d = KEY_STEP;
}
else if (k === 'ArrowUp' || k === 'ArrowLeft') {
d = -KEY_STEP;
}
else if (k === 'PageDown') {
d = PAGE_STEP;
}
else if (k === 'PageUp') {
d = -PAGE_STEP;
}
else if (k === 'Home') {
to = 0;
}
else if (k === 'End') {
to = 1;
}
else {
return;
}
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize() {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
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;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) {
placeScroll(prog);
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 Progress 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 Progress 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
}
else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// ChainTailLengthConstraint - vanilla 판(06-spa-chain-tail-length-constraint) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ChainTailLengthConstraint.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ChainTailLengthConstraint() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 선·점을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const GUIDE: RGB = [86, 78, 116];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [124, 196, 255];
const PINK: RGB = [255, 160, 190];
const MINT: RGB = [126, 226, 192];
const LILAC: RGB = [190, 164, 255];
const PEACH: RGB = [255, 208, 168];
const LEMON: RGB = [246, 228, 140];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(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)]; }
// 시드 고정 난수 — 같은 시드면 같은 시간표·배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 각도 접기 — 차이를 -180°~180° 로 접어 가장 짧은 방향으로 돌게 한다
function wrapAngle(a: number): number {
let v = a;
while (v > Math.PI) { v -= TAU; }
while (v < -Math.PI) { v += TAU; }
return v;
}
// 곡선 — 지나는 점들을 잇는 3차 곡선(토막마다 이웃 네 점으로 정한다). u(0~1) 를 넣으면 점이 나온다.
// u 는 토막마다 같은 몫이라, 지나는 점 간격이 다르면 같은 u 차이에 움직이는 거리가 토막마다 달라진다
function makeCurve(pts: Pt[]): (u: number) => Pt {
const n = pts.length - 1;
return function (u: number): Pt {
const x = clamp(u, 0, 1) * n;
const i = Math.min(Math.floor(x), n - 1);
const t = x - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n)];
const t2 = t * t;
const t3 = t2 * t;
const out: Pt = [0, 0];
for (let k = 0; k < 2; k += 1) {
out[k] = 0.5 * (2 * p1[k] + (p2[k] - p0[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (3 * p1[k] - p0[k] - 3 * p2[k] + p3[k]) * t3);
}
return out;
};
}
// 누적 길이 표 — 곡선을 samples 토막으로 잘라, 토막 끝까지의 길이를 차례로 더해 둔다
type LengthTable = { curve: (u: number) => Pt; us: number[]; lens: number[]; total: number };
function buildLengthTable(curve: (u: number) => Pt, samples: number): LengthTable {
const us: number[] = [0];
const lens: number[] = [0];
let prev = curve(0);
let acc = 0;
for (let i = 1; i <= samples; i += 1) {
const u = i / samples;
const p = curve(u);
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
us.push(u);
lens.push(acc);
prev = p;
}
return { curve: curve, us: us, lens: lens, total: acc };
}
// 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
function uAtLength(tab: LengthTable, s: number): number {
const L = clamp(s, 0, tab.total);
let lo = 0;
let hi = tab.lens.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (tab.lens[mid] < L) { lo = mid; } else { hi = mid; }
}
const span = tab.lens[hi] - tab.lens[lo];
const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
return lerp(tab.us[lo], tab.us[hi], k);
}
function pointAtLength(tab: LengthTable, s: number): Pt { return tab.curve(uAtLength(tab, s)); }
// u → 길이 — 이정표·정거장처럼 곡선 위 자리를 먼저 정한 것의 누적 길이를 미리 잰다
function lengthAtU(tab: LengthTable, u: number): number {
const x = clamp(u, 0, 1) * (tab.us.length - 1);
const i = Math.min(Math.floor(x), tab.us.length - 2);
return lerp(tab.lens[i], tab.lens[i + 1], x - i);
}
// 법선 — s 앞뒤 1px 점의 차로 접선을 구해 90° 돌린다
function normalAt(tab: LengthTable, s: number): Pt {
const a = pointAtLength(tab, s - 1);
const b = pointAtLength(tab, s + 1);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const d = Math.hypot(dx, dy) || 1;
return [-dy / d, dx / d];
}
// 길 그리기 경로 — 길이 s0 부터 s1 까지를 u 로 잘게(전체 res 걸음 비율) 따라간다. 표의 토막 수와 상관없이 선은 매끈하다
function tracePath(tab: LengthTable, s0: number, s1: number, res: number): void {
const u0 = uAtLength(tab, s0);
const u1 = uAtLength(tab, s1);
const steps = Math.max(2, Math.ceil(res * Math.abs(u1 - u0)));
ctx.beginPath();
for (let k = 0; k <= steps; k += 1) {
const p = tab.curve(lerp(u0, u1, k / steps));
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const scrollerEl = root.querySelector('[data-role="scroller"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !scrollerEl || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const scroller: HTMLElement = scrollerEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 상자를 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 진행률 — prog 는 지금 장면이 쓰는 값, prevProg 는 한 틱 전 값, inputP 는 사람이 상자를 굴려 정한 값
let prog = 0;
let prevProg = 0;
let inputP = 0;
// 코드가 상자를 굴린 자리 — 뒤따라 오는 scroll 이벤트를 사람 입력으로 오인하지 않게 적어 둔다
let expectTop = -1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
// 시연이 멈춘 그 자리에서 이어 굴린다 — 상자도 시연 내내 같은 자리로 굴려 두었다
inputP = prog;
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
function maxScroll(): number { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
function readScroll(): number { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
function placeScroll(p: number): void {
scroller.scrollTop = p * maxScroll();
expectTop = scroller.scrollTop;
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 진행 문구 — 5% 단위로 끊는다(굴릴 때마다 화면 낭독기가 숫자를 쏟아내지 않게)
function pctText(p: number): string { return '진행 ' + Math.round(p * 20) * 5 + '%'; }
// 시연 시간표 — [시각 ms, 진행률] 를 잇는 왕복. 앞으로 가는 길은 토막마다 걸리는 시간과 나아가는 몫이 시드로 달라 빠르기가 바뀐다
const DEMO_LEAD_MS = 200;
const DEMO_FORWARD_MS = 2600;
const DEMO_HOLD_MS = 500;
const DEMO_BACK_MS = 1800;
const DEMO_REST_MS = 300;
const DEMO_LOOP_MS = DEMO_LEAD_MS + DEMO_FORWARD_MS + DEMO_HOLD_MS + DEMO_BACK_MS + DEMO_REST_MS;
function makeSchedule(seed: number, legs: number): Pt[] {
const rng = makeRng(seed);
const ws: number[] = [];
const ps: number[] = [];
let wSum = 0;
let pSum = 0;
for (let i = 0; i < legs; i += 1) {
const w = 0.45 + rng() * 1.1;
const v = 0.6 + rng() * 0.8;
ws.push(w);
ps.push(v);
wSum += w;
pSum += v;
}
const out: Pt[] = [[0, 0], [DEMO_LEAD_MS, 0]];
let t = DEMO_LEAD_MS;
let p = 0;
for (let i = 0; i < legs; i += 1) {
t += DEMO_FORWARD_MS * ws[i] / wSum;
p += ps[i] / pSum;
out.push([t, i === legs - 1 ? 1 : p]);
}
t += DEMO_HOLD_MS;
out.push([t, 1]);
t += DEMO_BACK_MS;
out.push([t, 0]);
out.push([DEMO_LOOP_MS, 0]);
return out;
}
// 시간표 읽기 — 토막 안은 부드럽게(처음·끝이 느린 곡선) 잇는다
function scheduleAt(sch: Pt[], t: number): number {
const tt = t % DEMO_LOOP_MS;
for (let i = 0; i < sch.length - 1; i += 1) {
if (tt <= sch[i + 1][0]) {
const span = sch[i + 1][0] - sch[i][0];
const k = span > 0 ? (tt - sch[i][0]) / span : 1;
return lerp(sch[i][1], sch[i + 1][1], smooth(k));
}
}
return 0;
}
// 꼬리 마디 수 — 머리 뒤에 이어지는 점 개수
const TAIL_SEGMENTS = 12;
// 마디 길이(px) — 이웃 점 사이 거리를 이 값으로 되맞춘다
const SEGMENT_LENGTH = 15;
// 되맞춤 반복 — 틱마다 모든 마디 거리를 이 횟수만큼 다시 맞춘다(많을수록 덜 늘어난다)
const CONSTRAINT_ITERATIONS = 6;
// 끌림 — 이전 위치와의 차(= 지난 틱에 움직인 만큼)에 틱마다 곱한다
const TAIL_DRAG = 0.9;
const DEMO_SEED = 4746;
const DEMO_LEGS = 4;
// 동작 줄이기에서 멈춰 보일 진행률 — 진행 끝 상태
const STILL_PROGRESS = 1;
const ROUTE: Pt[] = [[40, 226], [110, 70], [210, 150], [290, 60], [400, 110], [440, 226], [320, 238], [230, 196]];
const TABLE: LengthTable = buildLengthTable(makeCurve(ROUTE), 200);
// 마디 위치(xs·ys)와 한 틱 전 위치(pxs·pys). 0 번이 머리
const xs: number[] = [];
const ys: number[] = [];
const pxs: number[] = [];
const pys: number[] = [];
function head(): Pt { return pointAtLength(TABLE, prog * TABLE.total); }
// 처음 자리 — 머리 뒤쪽 길 위에 마디 길이 간격으로 늘어놓는다. 길 시작보다 뒤면 시작점 접선 반대쪽으로 곧게
function onRestart(): void {
const s = prog * TABLE.total;
const a = pointAtLength(TABLE, 0);
const b = pointAtLength(TABLE, 4);
const d = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
for (let i = 0; i <= TAIL_SEGMENTS; i += 1) {
const back = s - i * SEGMENT_LENGTH;
const q: Pt = back >= 0 ? pointAtLength(TABLE, back) : [a[0] + (a[0] - b[0]) / d * -back, a[1] + (a[1] - b[1]) / d * -back];
xs[i] = q[0];
ys[i] = q[1];
pxs[i] = q[0];
pys[i] = q[1];
}
}
function step(dp: number): void {
const h = head();
xs[0] = h[0];
ys[0] = h[1];
// 이전 위치와의 차로 움직이기 — 지난 틱에 간 만큼(끌림을 곱해) 이번 틱에도 간다
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const vx = (xs[i] - pxs[i]) * TAIL_DRAG;
const vy = (ys[i] - pys[i]) * TAIL_DRAG;
pxs[i] = xs[i];
pys[i] = ys[i];
xs[i] += vx;
ys[i] += vy;
}
// 마디 거리 되맞춤 — 머리(0)는 길 위에 고정, 1 번은 혼자 다 움직이고 나머지 이웃 쌍은 반씩 나눠 움직인다
for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
const dx = xs[i] - xs[i - 1];
const dy = ys[i] - ys[i - 1];
const d = Math.hypot(dx, dy) || 1e-6;
const diff = (d - SEGMENT_LENGTH) / d;
if (i === 1) {
xs[i] -= dx * diff;
ys[i] -= dy * diff;
} else {
xs[i] -= dx * diff * 0.5;
ys[i] -= dy * diff * 0.5;
xs[i - 1] += dx * diff * 0.5;
ys[i - 1] += dy * diff * 0.5;
}
}
}
}
function draw(): void {
clearStage();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([3, 8]);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(GUIDE, 1);
tracePath(TABLE, 0, TABLE.total, 600);
ctx.stroke();
ctx.setLineDash([]);
// 몸통 — 꼬리 끝(가늘게)부터 머리(굵게) 쪽으로, 마디 사이를 굵은 선으로 이어 칠한다
for (let i = TAIL_SEGMENTS; i >= 1; i -= 1) {
const r = lerp(14, 4, i / TAIL_SEGMENTS);
ctx.strokeStyle = rgba(i % 2 ? PINK : PEACH, 1);
ctx.lineWidth = r * 2;
ctx.beginPath();
ctx.moveTo(xs[i], ys[i]);
ctx.lineTo(xs[i - 1], ys[i - 1]);
ctx.stroke();
}
for (let i = TAIL_SEGMENTS; i >= 1; i -= 1) {
const r = lerp(14, 4, i / TAIL_SEGMENTS);
ctx.fillStyle = rgba(BG, 0.35);
ctx.beginPath();
ctx.arc(xs[i], ys[i], r * 0.32, 0, TAU);
ctx.fill();
}
// 머리 — 1 번 마디에서 머리로 향하는 쪽을 본다
const ax = xs[0] - xs[1];
const ay = ys[0] - ys[1];
const ad = Math.hypot(ax, ay) || 1;
const fx = ax / ad;
const fy = ay / ad;
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(xs[0], ys[0], 16, 0, TAU);
ctx.fill();
for (let e = -1; e <= 1; e += 2) {
const ex = xs[0] + fx * 6 - fy * 7 * e;
const ey = ys[0] + fy * 6 + fx * 7 * e;
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(ex, ey, 4.5, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.beginPath();
ctx.arc(ex + fx * 1.8, ey + fy * 1.8, 2.2, 0, TAU);
ctx.fill();
}
}
function stateText(): string {
const moving = Math.hypot(xs[TAIL_SEGMENTS] - pxs[TAIL_SEGMENTS], ys[TAIL_SEGMENTS] - pys[TAIL_SEGMENTS]) > 0.05;
return pctText(prog) + ' — ' + (moving ? '꼬리가 끌려오는 중' : '꼬리가 멈춤');
}
const SCHEDULE: Pt[] = makeSchedule(DEMO_SEED, DEMO_LEGS);
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
// 한 틱 — 시연이면 시간표에서, 아니면 사람이 굴린 상자에서 진행률을 받고, 한 틱 전과의 차를 항목 계산에 넘긴다
function tick(): void {
prevProg = prog;
prog = demo && !still ? scheduleAt(SCHEDULE, clock) : inputP;
step(prog - prevProg);
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 자리 잡은 결과만 그린다
const SETTLE_TICKS = 240;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function restart(p: number): void {
prog = p;
prevProg = p;
inputP = p;
onRestart();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 상자 스크롤 — 코드가 굴린 자리면 넘기고, 사람이 굴렸으면 시연을 멈추고 그 진행률을 쓴다
scroller.addEventListener('scroll', function (): void {
if (expectTop >= 0 && Math.abs(scroller.scrollTop - expectTop) <= 1.5) { return; }
expectTop = -1;
wake();
inputP = readScroll();
if (still) { after(); }
}, { signal: off.signal });
// 휠은 듣기만 한다(passive) — 기본 동작을 막지 않으니 굴림은 브라우저가 상자 안에서 한다
scroller.addEventListener('wheel', function (): void { wake(); }, { passive: true, signal: off.signal });
scroller.addEventListener('pointerdown', function (): void { wake(); }, { signal: off.signal });
// 키보드로 상자에 들어오면 시연을 멈춘다 — 손대기 전까지 저절로 움직이던 장면을 멈출 방법
scroller.addEventListener('focus', function (): void { wake(); }, { signal: off.signal });
// 키보드 — 방향키는 KEY_STEP, PageUp·PageDown 은 PAGE_STEP, Home·End 는 처음·끝. 상자 안 진행만 바꾸고 페이지는 그대로
const KEY_STEP = 0.04;
const PAGE_STEP = 0.25;
scroller.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
let d = 0;
let to = -1;
if (k === 'ArrowDown' || k === 'ArrowRight') { d = KEY_STEP; }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { d = -KEY_STEP; }
else if (k === 'PageDown') { d = PAGE_STEP; }
else if (k === 'PageUp') { d = -PAGE_STEP; }
else if (k === 'Home') { to = 0; }
else if (k === 'End') { to = 1; }
else { return; }
ev.preventDefault();
wake();
inputP = to >= 0 ? to : clamp(inputP + d, 0, 1);
placeScroll(inputP);
after();
}, { signal: off.signal });
function onResize(): void {
fitCanvas();
placeScroll(demo && !still ? prog : inputP);
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
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;
tick();
acc -= FIXED_DT;
n += 1;
}
// 시연 중에는 상자도 같은 진행률로 굴려 둔다 — 스크롤 막대가 함께 움직이고, 사람이 잡으면 그 자리에서 이어진다
if (demo) { placeScroll(prog); }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고(시연 중이었으면 진행 끝 화면), 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) {
inputP = STILL_PROGRESS;
placeScroll(STILL_PROGRESS);
}
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
if (still) {
// 동작 줄이기 — 가상 진행을 돌리지 않고 진행 끝 상태(STILL_PROGRESS)에서 멈춘 화면. 상자도 끝으로 굴려 둔다
restart(STILL_PROGRESS);
placeScroll(STILL_PROGRESS);
after();
} else {
restart(scheduleAt(SCHEDULE, 0));
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("sp") + " is-demo"} ref={rootRef}>
<canvas className={cx("sp__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("sp__scroller")} data-role="scroller" tabIndex={0} role="region" aria-label="꼬리가 마디 길이를 지키며 길을 따라 끌려오는 장면. 이 상자 안에서 휠이나 손가락으로 굴리거나, 방향키·PageUp·PageDown·Home·End 로 진행한다. 페이지 스크롤은 그대로다">
<div className={cx("sp__track")}></div>
</div>
<p className={cx("sp__status")} data-role="status" aria-live="off">진행 0%</p>
</div>
);
}06 A Tail That Drags Along the Path at a Fixed Link Length — Measured area 22.145% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Scroll Path Animation: 9 Scenes Along a Curve
The post this demo lives in
Scroll Path Animation: 9 Scenes Along a CurveScroll path animation lurches on bends. Fed straight into a curve, scroll progress makes 01's pen speed vary up to 6.2x; See all demos in this post
If you like this effect - similar demos
Scroll09 A Text Ribbon That Flows Sideways Along a Sine Wave, Each Letter Turned to the Slope, With Taller Waves as Speed BuildsHorizontal Scroll Animation CSS: 9 Inertia Scenes
Scroll09 CTA that rises at the end9 Scrollytelling CSS Section Patterns, Copy-Paste
Scroll01 Scroll hint that bounces only on the first screen9 Reading Progress Bar CSS Patterns, No Library