09A Hero Headline That Collapses, Then Lines Back Up
Headline letters that fell into a pile are pulled back to their reading-order spots after ASSEMBLE_DELAY seconds by a critically damped SETTLE_SPRING, and they drop again every LOOP_SECONDS.
Best forProduct landing hero
ASSEMBLE_DELAYSETTLE_SPRINGLOOP_SECONDS

<main class="stage">
<div class="pc is-demo" id="pc">
<p class="pc__sr" data-role="word">BACK IN LINE</p>
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="Headline letters collapse into a pile, then return to one line in reading order. Press or use the Down arrow to collapse it now, the Up arrow to bring it back now, Enter to stand it up again and Space to pause."></canvas>
<p class="pc__status" data-role="status" aria-live="off">The headline is collapsing</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">Stand it up</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
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;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 원문 한 줄 — 화면에는 숨기고 화면 낭독기에만 남긴다(낱글자는 캔버스 그림이라 따로 읽히지 않는다)
.pc__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pc__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;
}
// 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 무대를 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 46%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__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;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__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);
}
.pc {
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;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pc__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;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 46%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__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;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 바닥 높이 — 왼쪽 아래 상태 줄 위에서 글자가 멈추도록 바닥을 띄워 둔다
const FLOOR = 250;
// 다시 떨어뜨린 뒤 첫 200ms 는 그대로 둔다 — 첫 변화가 200ms 뒤에 오게
const LEAD_MS = 200;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 글자를 얹어 명도 차로 글자 경계가 읽힌다
const BG = [27, 24, 38];
const BG_LOW = [38, 33, 54];
const FLOOR_COL = [56, 50, 78];
const INK = [33, 29, 46];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
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 shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[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;
};
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const wordEl = root.querySelector('[data-role="word"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !wordEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
// 원문 한 줄 — 화면 낭독기는 이 문장만 한 번 읽는다. 캔버스에 그린 낱글자는 접근성 트리에 들어가지 않는다
const WORD = (wordEl.textContent || '').trim();
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let 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;
}
}
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);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 그때그때 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
// 글자 사이 간격 — 글자 크기에 대한 비율
const GLYPH_GAP = 0.1;
function fontAt(px) { return '800 ' + px.toFixed(1) + 'px ' + FONT_FAMILY; }
function layoutWord(text, sizeMax, maxW, cx, cy) {
const chars = Array.from(text);
ctx.font = fontAt(sizeMax);
const ws = [];
let total = 0;
for (let i = 0; i < chars.length; i += 1) {
const m = chars[i].trim() === '' ? sizeMax * 0.3 : ctx.measureText(chars[i]).width;
ws.push(m);
total += m;
}
total += GLYPH_GAP * sizeMax * Math.max(0, chars.length - 1);
// 줄이 maxW 보다 길면 글자 크기를 같은 비율로 줄인다 — 언어마다 글자 수가 달라도 무대 안에 든다
const k = total > maxW ? maxW / total : 1;
const size = sizeMax * k;
const out = [];
let x = cx - (total * k) / 2;
let n = 0;
for (let i = 0; i < chars.length; i += 1) {
const w = ws[i] * k;
if (chars[i].trim() !== '') {
out.push({ ch: chars[i], w: Math.max(w, size * 0.34), h: size * 0.9, hx: x + w / 2, hy: cy, size: size, col: PAL[n % PAL.length], i: n });
n += 1;
}
x += w + GLYPH_GAP * size;
}
return out;
}
// 글자 하나 그리기 — 같은 글자를 어둡게 한 번 아래로 비켜 찍어 두께를 만든 뒤 제 색으로 덮는다
function drawGlyph(g, x, y, a, sx, sy, fill) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.scale(sx, sy);
ctx.font = fontAt(g.size);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(shadeRGB(fill, 0.5), 1);
ctx.fillText(g.ch, 1.4, g.size * 0.04 + 2.8);
ctx.fillStyle = rgba(fill, 1);
ctx.fillText(g.ch, 0, g.size * 0.04);
ctx.restore();
}
// 모서리가 둥근 사각형 경로 — 가운데 (0,0) 기준 폭 w·높이 h
function roundRectPath(x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + h - rr);
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
ctx.lineTo(x + rr, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
}
// 바닥 그림자 — 글자 아래끝이 바닥에서 멀수록 옅고 넓게. 높이 값 하나로 크기와 진하기를 함께 정한다
function floorShadow(x, bottom, w) {
const lift = clamp((FLOOR - bottom) / 160, 0, 1);
ctx.fillStyle = rgba([0, 0, 0], 0.34 * (1 - lift) + 0.05);
ctx.beginPath();
ctx.ellipse(x, FLOOR + 3, w * (0.5 + 0.4 * lift), 3.5 + 2.5 * lift, 0, 0, TAU);
ctx.fill();
}
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);
}
function drawFloor() {
ctx.fillStyle = rgba(FLOOR_COL, 1);
ctx.fillRect(0, FLOOR, W, H - FLOOR);
ctx.fillStyle = rgba(PAPER, 0.22);
ctx.fillRect(0, FLOOR, W, 1.5);
}
const TXT_PAUSE = 'Paused — press Space to resume';
// 시계 — clock 은 시연 시계, age 는 다시 떨어뜨린 뒤 흐른 시간(ms). 둘 다 16ms 고정 스텝으로만 늘어난다
let paused = false;
let clock = 0;
let age = 0;
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
age = 0;
onRestart();
}
function tick() {
age += FIXED_DT;
step();
}
function report() { say(paused ? TXT_PAUSE : stateText()); }
function render() {
draw();
report();
}
// 시연 — DEMO_LOOP_MS 마다 처음부터 다시 떨어뜨린다
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP_MS);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
demoDrive(t - cyc * DEMO_LOOP_MS);
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 내려앉은 결과만 그린다
const SETTLE_TICKS = 360;
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
tick();
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
cv.addEventListener('pointerdown', function (ev) {
wake();
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
const p = toScene(ev);
onPointer('down', p[0], p[1]);
after();
});
cv.addEventListener('pointermove', function (ev) {
const p = toScene(ev);
if (onPointer('move', p[0], p[1])) {
wake();
after();
}
});
cv.addEventListener('pointerup', function (ev) {
const p = toScene(ev);
onPointer('up', p[0], p[1]);
});
cv.addEventListener('pointercancel', function () { onPointer('up', -1, -1); });
cv.addEventListener('pointerleave', function () {
if (onPointer('leave', -1, -1)) {
after();
}
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (!onKey(k)) {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 한 틱을 두 번으로 나눠 옮긴다 — 빠르게 떨어진 글자가 다른 글자를 뚫고 지나가지 않게
const SUBSTEPS = 2;
// 접점 충격량을 되풀이해 맞추는 횟수
const SOLVER_ITERATIONS = 6;
// 이보다 느리게 닿으면 되튀지 않는다 — 쌓인 글자가 제자리에서 떨지 않게(px/틱)
const BOUNCE_MIN = 1.2;
// 이만큼(px)까지 파고든 것은 그냥 둔다 — 밀어내기가 접점을 매번 끊었다 붙였다 하지 않게
const SLOP = 0.4;
const WALL_L = 8;
const WALL_R = W - 8;
function makeBody(g, x, y, a) {
// 질량은 상자 넓이에 비례, 회전 관성은 상자 공식 m(w²+h²)/12
const m = (g.w * g.h) / 900;
const inertia = (m * (g.w * g.w + g.h * g.h)) / 12;
return { g: g, x: x, y: y, vx: 0, vy: 0, a: a, w: 0, hw: g.w / 2, hh: g.h / 2, im: 1 / m, ii: 1 / inertia, free: true };
}
function corners(b) {
const c = Math.cos(b.a);
const s = Math.sin(b.a);
const out = [];
const sx = [-1, 1, 1, -1];
const sy = [-1, -1, 1, 1];
for (let k = 0; k < 4; k += 1) {
const lx = sx[k] * b.hw;
const ly = sy[k] * b.hh;
out.push([b.x + lx * c - ly * s, b.y + lx * s + ly * c]);
}
return out;
}
function addContact(out, a, b, px, py, nx, ny, pen) {
out.push({ a: a, b: b, px: px, py: py, nx: nx, ny: ny, pen: pen, target: 0, jn: 0, jt: 0 });
}
// 꼭짓점 하나가 다른 상자 안에 들어갔는가 — 들어갔으면 덜 파고든 축을 법선으로 삼는다(그쪽으로 빼는 게 가장 짧다)
function cornerInBox(out, a, p, b) {
const dx = p[0] - b.x;
const dy = p[1] - b.y;
const c = Math.cos(b.a);
const s = Math.sin(b.a);
const lx = dx * c + dy * s;
const ly = -dx * s + dy * c;
const ox = b.hw - Math.abs(lx);
const oy = b.hh - Math.abs(ly);
if (ox <= 0 || oy <= 0) {
return;
}
let nlx = 0;
let nly = 0;
let pen = 0;
if (ox < oy) {
nlx = lx < 0 ? -1 : 1;
pen = ox;
}
else {
nly = ly < 0 ? -1 : 1;
pen = oy;
}
addContact(out, a, b, p[0], p[1], nlx * c - nly * s, nlx * s + nly * c, pen);
}
function findContacts(bodies) {
const out = [];
for (let i = 0; i < bodies.length; i += 1) {
const a = bodies[i];
if (!a.free) {
continue;
}
const cs = corners(a);
for (let k = 0; k < 4; k += 1) {
const p = cs[k];
if (p[1] > FLOOR) {
addContact(out, a, null, p[0], p[1], 0, -1, p[1] - FLOOR);
}
if (p[0] < WALL_L) {
addContact(out, a, null, p[0], p[1], 1, 0, WALL_L - p[0]);
}
if (p[0] > WALL_R) {
addContact(out, a, null, p[0], p[1], -1, 0, p[0] - WALL_R);
}
}
for (let j = i + 1; j < bodies.length; j += 1) {
const b = bodies[j];
if (!b.free) {
continue;
}
const reach = Math.hypot(a.hw, a.hh) + Math.hypot(b.hw, b.hh);
if (Math.abs(a.x - b.x) > reach || Math.abs(a.y - b.y) > reach) {
continue;
}
for (let k = 0; k < 4; k += 1) {
cornerInBox(out, a, cs[k], b);
}
const cb = corners(b);
for (let k = 0; k < 4; k += 1) {
cornerInBox(out, b, cb[k], a);
}
}
}
return out;
}
// 접점 속도 — 무게중심 속도 + 각속도 x 무게중심에서 접점까지의 거리
function pointVel(b, px, py) {
return [b.vx - b.w * (py - b.y), b.vy + b.w * (px - b.x)];
}
function relVel(c) {
const va = pointVel(c.a, c.px, c.py);
if (!c.b) {
return va;
}
const vb = pointVel(c.b, c.px, c.py);
return [va[0] - vb[0], va[1] - vb[1]];
}
// 접점이 무게중심에서 벗어난 거리(r x n)가 클수록 같은 충격량이 회전으로 더 많이 간다
function effMass(c, dx, dy) {
const ra = (c.px - c.a.x) * dy - (c.py - c.a.y) * dx;
let k = c.a.im + ra * ra * c.a.ii;
if (c.b) {
const rb = (c.px - c.b.x) * dy - (c.py - c.b.y) * dx;
k += c.b.im + rb * rb * c.b.ii;
}
return k;
}
function applyImpulse(b, px, py, jx, jy) {
b.vx += jx * b.im;
b.vy += jy * b.im;
b.w += ((px - b.x) * jy - (py - b.y) * jx) * b.ii;
}
function pushPair(c, jx, jy) {
applyImpulse(c.a, c.px, c.py, jx, jy);
if (c.b) {
applyImpulse(c.b, c.px, c.py, -jx, -jy);
}
}
function solveContact(c, fric) {
const rel = relVel(c);
const vn = rel[0] * c.nx + rel[1] * c.ny;
const jn0 = c.jn;
c.jn = Math.max(jn0 + (c.target - vn) / effMass(c, c.nx, c.ny), 0);
const dj = c.jn - jn0;
pushPair(c, c.nx * dj, c.ny * dj);
// 마찰 — 미끄러지는 방향 충격량은 누르는 충격량 x fric 을 넘지 못한다
const tx = -c.ny;
const ty = c.nx;
const rel2 = relVel(c);
const vt = rel2[0] * tx + rel2[1] * ty;
const lim = fric * c.jn;
const jt0 = c.jt;
c.jt = clamp(jt0 - vt / effMass(c, tx, ty), -lim, lim);
const djt = c.jt - jt0;
pushPair(c, tx * djt, ty * djt);
}
function rigidStep(bodies, grav, rest, fric) {
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (!b.free) {
continue;
}
b.vy += grav / SUBSTEPS;
b.x += b.vx / SUBSTEPS;
b.y += b.vy / SUBSTEPS;
b.a += b.w / SUBSTEPS;
}
const cs = findContacts(bodies);
for (let k = 0; k < cs.length; k += 1) {
const c = cs[k];
const rel = relVel(c);
const vn = rel[0] * c.nx + rel[1] * c.ny;
// 되튐 목표 — 닿기 직전 법선 속도의 rest 배만큼 반대로. 느린 접촉은 0
c.target = vn < -BOUNCE_MIN ? -rest * vn : 0;
}
for (let it = 0; it < SOLVER_ITERATIONS; it += 1) {
for (let k = 0; k < cs.length; k += 1) {
solveContact(cs[k], fric);
}
}
// 파고든 깊이는 질량 역수 비율로 나눠 밖으로 뺀다
for (let k = 0; k < cs.length; k += 1) {
const c = cs[k];
const sum = c.a.im + (c.b ? c.b.im : 0);
const corr = (Math.max(c.pen - SLOP, 0) * 0.45) / sum;
c.a.x += c.nx * corr * c.a.im;
c.a.y += c.ny * corr * c.a.im;
if (c.b) {
c.b.x -= c.nx * corr * c.b.im;
c.b.y -= c.ny * corr * c.b.im;
}
}
}
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (!b.free) {
continue;
}
b.vx *= 0.998;
b.w *= 0.985;
}
}
function drawBody(b) { drawGlyph(b.g, b.x, b.y, b.a, 1, 1, b.g.col); }
function bodyBottom(b) {
return b.y + Math.abs(Math.sin(b.a)) * b.hw + Math.abs(Math.cos(b.a)) * b.hh;
}
// 무너진 뒤 이만큼(초) 지나면 읽는 순서대로 제자리로 돌아오기 시작한다
const ASSEMBLE_DELAY = 0.9;
// 복귀 스프링 세기(1/틱²) — 감쇠는 2 x 제곱근(세기)로 맞춰 지나치지 않고 가장 빨리 선다(임계 감쇠)
const SETTLE_SPRING = 0.03;
// 이만큼(초)마다 다시 떨어뜨린다
const LOOP_SECONDS = 3.4;
// 앞 글자가 출발한 뒤 다음 글자가 출발하기까지(ms)
const REALIGN_STAGGER_MS = 45;
const COLLAPSE_GRAVITY = 0.5;
const COLLAPSE_RESTITUTION = 0.3;
const COLLAPSE_FRICTION = 0.5;
const DEMO_LOOP_MS = LOOP_SECONDS * 1000;
const TXT_FALL = 'The headline is collapsing';
const TXT_BACK = 'Returning to one line';
const TXT_LINE = 'Aligned — collapsing again soon';
const TXT_LINE_STILL = 'Aligned — press Enter to replay';
const GLYPHS = layoutWord(WORD, 60, 400, 240, 112);
let bodies = [];
// 무너뜨리기 시작한 시각(age, ms). -1 이면 아직 줄에 서 있다
let fallAt = -1;
function onRestart() {
bodies = GLYPHS.map(function (g) {
const b = makeBody(g, g.hx, g.hy, 0);
b.free = false;
return b;
});
fallAt = -1;
}
function collapse() {
const rng = makeRng(4729);
fallAt = age;
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
b.free = true;
b.vx = (rng() - 0.5) * 3;
b.vy = -2 - rng() * 3;
b.w = (rng() - 0.5) * 0.24;
}
}
// 임계 감쇠 스프링 한 칸 — 가속도 = -세기 x 벗어난 거리 - 2 x 제곱근(세기) x 속도
function springTo(v, x, target) {
return v - SETTLE_SPRING * (x - target) - 2 * Math.sqrt(SETTLE_SPRING) * v;
}
function step() {
if (!demo && !still && age >= LOOP_SECONDS * 1000) {
restart();
return;
}
if (fallAt < 0) {
if (age >= LEAD_MS) {
collapse();
}
return;
}
const since = age - fallAt;
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (b.free && since >= ASSEMBLE_DELAY * 1000 + i * REALIGN_STAGGER_MS) {
b.free = false;
// 각도는 가장 가까운 한 바퀴 자리로 — 두 바퀴 돌아 있으면 두 바퀴를 되감지 않는다
b.a -= Math.round(b.a / TAU) * TAU;
}
}
rigidStep(bodies, COLLAPSE_GRAVITY, COLLAPSE_RESTITUTION, COLLAPSE_FRICTION);
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (b.free) {
continue;
}
b.vx = springTo(b.vx, b.x, b.g.hx);
b.vy = springTo(b.vy, b.y, b.g.hy);
b.w = springTo(b.w, b.a, 0);
b.x += b.vx;
b.y += b.vy;
b.a += b.w;
}
}
// 줄에서 벗어난 거리 — 글자마다 제자리까지 거리의 평균(px)
function lineError() {
let s = 0;
for (let i = 0; i < bodies.length; i += 1) {
s += Math.hypot(bodies[i].x - bodies[i].g.hx, bodies[i].y - bodies[i].g.hy);
}
return bodies.length ? s / bodies.length : 0;
}
function stateText() {
const err = lineError();
if (err < 0.5) {
return still ? TXT_LINE_STILL : TXT_LINE;
}
for (let i = 0; i < bodies.length; i += 1) {
if (!bodies[i].free) {
return TXT_BACK;
}
}
return TXT_FALL;
}
function draw() {
clearStage();
drawFloor();
// 제자리 밑줄 — 줄에서 벗어난 거리가 줄수록 길고 진해진다. 정렬 정도를 lineError 하나로
const first = GLYPHS[0];
const last = GLYPHS[GLYPHS.length - 1];
if (first && last) {
const x0 = first.hx - first.w / 2;
const x1 = last.hx + last.w / 2;
const k = clamp(1 - lineError() / 60, 0, 1);
const y = first.hy + first.h / 2 + 12;
ctx.fillStyle = rgba(PAPER, 0.1);
ctx.fillRect(x0, y, x1 - x0, 3);
ctx.fillStyle = rgba(PAL[2], 0.25 + 0.7 * k);
ctx.fillRect(x0, y, (x1 - x0) * k, 3);
}
for (let i = 0; i < bodies.length; i += 1) {
floorShadow(bodies[i].x, bodyBottom(bodies[i]), bodies[i].hw);
}
for (let i = 0; i < bodies.length; i += 1) {
drawBody(bodies[i]);
}
}
function demoDrive(t) { }
// 누르면 — 줄에 서 있으면 무너뜨리고, 무너져 있으면 가까운 글자를 튕긴다
function onPointer(type, x, y) {
if (type !== 'down') {
return false;
}
if (fallAt < 0) {
collapse();
return false;
}
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (!b.free || Math.hypot(b.x - x, b.y - y) > 80) {
continue;
}
b.vy -= 8;
b.vx += (b.x - x) * 0.06;
}
return false;
}
// 아래 방향키 — 지금 무너뜨리기, 위 방향키 — 지금 한 줄로 되돌리기
function onKey(k) {
if (k === 'ArrowDown') {
if (lineError() < 0.5) {
restart();
}
collapse();
return true;
}
if (k === 'ArrowUp') {
if (fallAt < 0) {
return true;
}
fallAt = age - ASSEMBLE_DELAY * 1000 - bodies.length * REALIGN_STAGGER_MS;
return true;
}
return false;
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
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) {
if (!paused) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
tick();
}
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 내려앉은 결과로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
restart();
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// HeroCollapseRealign - vanilla 판(09-tx-phys-hero-collapse-realign) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./HeroCollapseRealign.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroCollapseRealign() {
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 DEG = Math.PI / 180;
// 바닥 높이 — 왼쪽 아래 상태 줄 위에서 글자가 멈추도록 바닥을 띄워 둔다
const FLOOR = 250;
// 다시 떨어뜨린 뒤 첫 200ms 는 그대로 둔다 — 첫 변화가 200ms 뒤에 오게
const LEAD_MS = 200;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 글자를 얹어 명도 차로 글자 경계가 읽힌다
const BG: RGB = [27, 24, 38];
const BG_LOW: RGB = [38, 33, 54];
const FLOOR_COL: RGB = [56, 50, 78];
const INK: RGB = [33, 29, 46];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
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 shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[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;
};
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const wordEl = root.querySelector('[data-role="word"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !wordEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
// 원문 한 줄 — 화면 낭독기는 이 문장만 한 번 읽는다. 캔버스에 그린 낱글자는 접근성 트리에 들어가지 않는다
const WORD = (wordEl.textContent || '').trim();
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let 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; }
}
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);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 그때그때 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 글자 상자 — measureText 로 잰 폭과 글자 크기로 상자를 잡고, 원래 읽는 순서대로 자리(hx, hy)를 매긴다
type Glyph = { ch: string; w: number; h: number; hx: number; hy: number; size: number; col: RGB; i: number };
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
// 글자 사이 간격 — 글자 크기에 대한 비율
const GLYPH_GAP = 0.1;
function fontAt(px: number): string { return '800 ' + px.toFixed(1) + 'px ' + FONT_FAMILY; }
function layoutWord(text: string, sizeMax: number, maxW: number, cx: number, cy: number): Glyph[] {
const chars = Array.from(text);
ctx.font = fontAt(sizeMax);
const ws: number[] = [];
let total = 0;
for (let i = 0; i < chars.length; i += 1) {
const m = chars[i].trim() === '' ? sizeMax * 0.3 : ctx.measureText(chars[i]).width;
ws.push(m);
total += m;
}
total += GLYPH_GAP * sizeMax * Math.max(0, chars.length - 1);
// 줄이 maxW 보다 길면 글자 크기를 같은 비율로 줄인다 — 언어마다 글자 수가 달라도 무대 안에 든다
const k = total > maxW ? maxW / total : 1;
const size = sizeMax * k;
const out: Glyph[] = [];
let x = cx - (total * k) / 2;
let n = 0;
for (let i = 0; i < chars.length; i += 1) {
const w = ws[i] * k;
if (chars[i].trim() !== '') {
out.push({ ch: chars[i], w: Math.max(w, size * 0.34), h: size * 0.9, hx: x + w / 2, hy: cy, size: size, col: PAL[n % PAL.length], i: n });
n += 1;
}
x += w + GLYPH_GAP * size;
}
return out;
}
// 글자 하나 그리기 — 같은 글자를 어둡게 한 번 아래로 비켜 찍어 두께를 만든 뒤 제 색으로 덮는다
function drawGlyph(g: Glyph, x: number, y: number, a: number, sx: number, sy: number, fill: RGB): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.scale(sx, sy);
ctx.font = fontAt(g.size);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(shadeRGB(fill, 0.5), 1);
ctx.fillText(g.ch, 1.4, g.size * 0.04 + 2.8);
ctx.fillStyle = rgba(fill, 1);
ctx.fillText(g.ch, 0, g.size * 0.04);
ctx.restore();
}
// 모서리가 둥근 사각형 경로 — 가운데 (0,0) 기준 폭 w·높이 h
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + h - rr);
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
ctx.lineTo(x + rr, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
}
// 바닥 그림자 — 글자 아래끝이 바닥에서 멀수록 옅고 넓게. 높이 값 하나로 크기와 진하기를 함께 정한다
function floorShadow(x: number, bottom: number, w: number): void {
const lift = clamp((FLOOR - bottom) / 160, 0, 1);
ctx.fillStyle = rgba([0, 0, 0], 0.34 * (1 - lift) + 0.05);
ctx.beginPath();
ctx.ellipse(x, FLOOR + 3, w * (0.5 + 0.4 * lift), 3.5 + 2.5 * lift, 0, 0, TAU);
ctx.fill();
}
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);
}
function drawFloor(): void {
ctx.fillStyle = rgba(FLOOR_COL, 1);
ctx.fillRect(0, FLOOR, W, H - FLOOR);
ctx.fillStyle = rgba(PAPER, 0.22);
ctx.fillRect(0, FLOOR, W, 1.5);
}
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시계 — clock 은 시연 시계, age 는 다시 떨어뜨린 뒤 흐른 시간(ms). 둘 다 16ms 고정 스텝으로만 늘어난다
let paused = false;
let clock = 0;
let age = 0;
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
age = 0;
onRestart();
}
function tick(): void {
age += FIXED_DT;
step();
}
function report(): void { say(paused ? TXT_PAUSE : stateText()); }
function render(): void {
draw();
report();
}
// 시연 — DEMO_LOOP_MS 마다 처음부터 다시 떨어뜨린다
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP_MS);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
demoDrive(t - cyc * DEMO_LOOP_MS);
}
// 동작 줄이기 — 움직임을 그리지 않고 SETTLE_TICKS 스텝을 한 번에 계산해 내려앉은 결과만 그린다
const SETTLE_TICKS = 360;
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { tick(); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
const p = toScene(ev);
onPointer('down', p[0], p[1]);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toScene(ev);
if (onPointer('move', p[0], p[1])) {
wake();
after();
}
});
cv.addEventListener('pointerup', function (ev: PointerEvent): void {
const p = toScene(ev);
onPointer('up', p[0], p[1]);
});
cv.addEventListener('pointercancel', function (): void { onPointer('up', -1, -1); });
cv.addEventListener('pointerleave', function (): void {
if (onPointer('leave', -1, -1)) { after(); }
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (!onKey(k)) { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 단단한 상자 — 글자 하나를 measureText 폭 x 글자 높이 상자로 보고 위치·속도·각도·각속도를 들고 다닌다 (01·09 가 쓴다)
type Body = { g: Glyph; x: number; y: number; vx: number; vy: number; a: number; w: number; hw: number; hh: number; im: number; ii: number; free: boolean };
// 한 틱을 두 번으로 나눠 옮긴다 — 빠르게 떨어진 글자가 다른 글자를 뚫고 지나가지 않게
const SUBSTEPS = 2;
// 접점 충격량을 되풀이해 맞추는 횟수
const SOLVER_ITERATIONS = 6;
// 이보다 느리게 닿으면 되튀지 않는다 — 쌓인 글자가 제자리에서 떨지 않게(px/틱)
const BOUNCE_MIN = 1.2;
// 이만큼(px)까지 파고든 것은 그냥 둔다 — 밀어내기가 접점을 매번 끊었다 붙였다 하지 않게
const SLOP = 0.4;
const WALL_L = 8;
const WALL_R = W - 8;
function makeBody(g: Glyph, x: number, y: number, a: number): Body {
// 질량은 상자 넓이에 비례, 회전 관성은 상자 공식 m(w²+h²)/12
const m = (g.w * g.h) / 900;
const inertia = (m * (g.w * g.w + g.h * g.h)) / 12;
return { g: g, x: x, y: y, vx: 0, vy: 0, a: a, w: 0, hw: g.w / 2, hh: g.h / 2, im: 1 / m, ii: 1 / inertia, free: true };
}
function corners(b: Body): Pt[] {
const c = Math.cos(b.a);
const s = Math.sin(b.a);
const out: Pt[] = [];
const sx = [-1, 1, 1, -1];
const sy = [-1, -1, 1, 1];
for (let k = 0; k < 4; k += 1) {
const lx = sx[k] * b.hw;
const ly = sy[k] * b.hh;
out.push([b.x + lx * c - ly * s, b.y + lx * s + ly * c]);
}
return out;
}
type Contact = { a: Body; b: Body | null; px: number; py: number; nx: number; ny: number; pen: number; target: number; jn: number; jt: number };
function addContact(out: Contact[], a: Body, b: Body | null, px: number, py: number, nx: number, ny: number, pen: number): void {
out.push({ a: a, b: b, px: px, py: py, nx: nx, ny: ny, pen: pen, target: 0, jn: 0, jt: 0 });
}
// 꼭짓점 하나가 다른 상자 안에 들어갔는가 — 들어갔으면 덜 파고든 축을 법선으로 삼는다(그쪽으로 빼는 게 가장 짧다)
function cornerInBox(out: Contact[], a: Body, p: Pt, b: Body): void {
const dx = p[0] - b.x;
const dy = p[1] - b.y;
const c = Math.cos(b.a);
const s = Math.sin(b.a);
const lx = dx * c + dy * s;
const ly = -dx * s + dy * c;
const ox = b.hw - Math.abs(lx);
const oy = b.hh - Math.abs(ly);
if (ox <= 0 || oy <= 0) { return; }
let nlx = 0;
let nly = 0;
let pen = 0;
if (ox < oy) { nlx = lx < 0 ? -1 : 1; pen = ox; } else { nly = ly < 0 ? -1 : 1; pen = oy; }
addContact(out, a, b, p[0], p[1], nlx * c - nly * s, nlx * s + nly * c, pen);
}
function findContacts(bodies: Body[]): Contact[] {
const out: Contact[] = [];
for (let i = 0; i < bodies.length; i += 1) {
const a = bodies[i];
if (!a.free) { continue; }
const cs = corners(a);
for (let k = 0; k < 4; k += 1) {
const p = cs[k];
if (p[1] > FLOOR) { addContact(out, a, null, p[0], p[1], 0, -1, p[1] - FLOOR); }
if (p[0] < WALL_L) { addContact(out, a, null, p[0], p[1], 1, 0, WALL_L - p[0]); }
if (p[0] > WALL_R) { addContact(out, a, null, p[0], p[1], -1, 0, p[0] - WALL_R); }
}
for (let j = i + 1; j < bodies.length; j += 1) {
const b = bodies[j];
if (!b.free) { continue; }
const reach = Math.hypot(a.hw, a.hh) + Math.hypot(b.hw, b.hh);
if (Math.abs(a.x - b.x) > reach || Math.abs(a.y - b.y) > reach) { continue; }
for (let k = 0; k < 4; k += 1) { cornerInBox(out, a, cs[k], b); }
const cb = corners(b);
for (let k = 0; k < 4; k += 1) { cornerInBox(out, b, cb[k], a); }
}
}
return out;
}
// 접점 속도 — 무게중심 속도 + 각속도 x 무게중심에서 접점까지의 거리
function pointVel(b: Body, px: number, py: number): Pt {
return [b.vx - b.w * (py - b.y), b.vy + b.w * (px - b.x)];
}
function relVel(c: Contact): Pt {
const va = pointVel(c.a, c.px, c.py);
if (!c.b) { return va; }
const vb = pointVel(c.b, c.px, c.py);
return [va[0] - vb[0], va[1] - vb[1]];
}
// 접점이 무게중심에서 벗어난 거리(r x n)가 클수록 같은 충격량이 회전으로 더 많이 간다
function effMass(c: Contact, dx: number, dy: number): number {
const ra = (c.px - c.a.x) * dy - (c.py - c.a.y) * dx;
let k = c.a.im + ra * ra * c.a.ii;
if (c.b) {
const rb = (c.px - c.b.x) * dy - (c.py - c.b.y) * dx;
k += c.b.im + rb * rb * c.b.ii;
}
return k;
}
function applyImpulse(b: Body, px: number, py: number, jx: number, jy: number): void {
b.vx += jx * b.im;
b.vy += jy * b.im;
b.w += ((px - b.x) * jy - (py - b.y) * jx) * b.ii;
}
function pushPair(c: Contact, jx: number, jy: number): void {
applyImpulse(c.a, c.px, c.py, jx, jy);
if (c.b) { applyImpulse(c.b, c.px, c.py, -jx, -jy); }
}
function solveContact(c: Contact, fric: number): void {
const rel = relVel(c);
const vn = rel[0] * c.nx + rel[1] * c.ny;
const jn0 = c.jn;
c.jn = Math.max(jn0 + (c.target - vn) / effMass(c, c.nx, c.ny), 0);
const dj = c.jn - jn0;
pushPair(c, c.nx * dj, c.ny * dj);
// 마찰 — 미끄러지는 방향 충격량은 누르는 충격량 x fric 을 넘지 못한다
const tx = -c.ny;
const ty = c.nx;
const rel2 = relVel(c);
const vt = rel2[0] * tx + rel2[1] * ty;
const lim = fric * c.jn;
const jt0 = c.jt;
c.jt = clamp(jt0 - vt / effMass(c, tx, ty), -lim, lim);
const djt = c.jt - jt0;
pushPair(c, tx * djt, ty * djt);
}
function rigidStep(bodies: Body[], grav: number, rest: number, fric: number): void {
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (!b.free) { continue; }
b.vy += grav / SUBSTEPS;
b.x += b.vx / SUBSTEPS;
b.y += b.vy / SUBSTEPS;
b.a += b.w / SUBSTEPS;
}
const cs = findContacts(bodies);
for (let k = 0; k < cs.length; k += 1) {
const c = cs[k];
const rel = relVel(c);
const vn = rel[0] * c.nx + rel[1] * c.ny;
// 되튐 목표 — 닿기 직전 법선 속도의 rest 배만큼 반대로. 느린 접촉은 0
c.target = vn < -BOUNCE_MIN ? -rest * vn : 0;
}
for (let it = 0; it < SOLVER_ITERATIONS; it += 1) {
for (let k = 0; k < cs.length; k += 1) { solveContact(cs[k], fric); }
}
// 파고든 깊이는 질량 역수 비율로 나눠 밖으로 뺀다
for (let k = 0; k < cs.length; k += 1) {
const c = cs[k];
const sum = c.a.im + (c.b ? c.b.im : 0);
const corr = (Math.max(c.pen - SLOP, 0) * 0.45) / sum;
c.a.x += c.nx * corr * c.a.im;
c.a.y += c.ny * corr * c.a.im;
if (c.b) {
c.b.x -= c.nx * corr * c.b.im;
c.b.y -= c.ny * corr * c.b.im;
}
}
}
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (!b.free) { continue; }
b.vx *= 0.998;
b.w *= 0.985;
}
}
function drawBody(b: Body): void { drawGlyph(b.g, b.x, b.y, b.a, 1, 1, b.g.col); }
function bodyBottom(b: Body): number {
return b.y + Math.abs(Math.sin(b.a)) * b.hw + Math.abs(Math.cos(b.a)) * b.hh;
}
// 무너진 뒤 이만큼(초) 지나면 읽는 순서대로 제자리로 돌아오기 시작한다
const ASSEMBLE_DELAY = 0.9;
// 복귀 스프링 세기(1/틱²) — 감쇠는 2 x 제곱근(세기)로 맞춰 지나치지 않고 가장 빨리 선다(임계 감쇠)
const SETTLE_SPRING = 0.03;
// 이만큼(초)마다 다시 떨어뜨린다
const LOOP_SECONDS = 3.4;
// 앞 글자가 출발한 뒤 다음 글자가 출발하기까지(ms)
const REALIGN_STAGGER_MS = 45;
const COLLAPSE_GRAVITY = 0.5;
const COLLAPSE_RESTITUTION = 0.3;
const COLLAPSE_FRICTION = 0.5;
const DEMO_LOOP_MS = LOOP_SECONDS * 1000;
const TXT_FALL = '헤드라인이 무너지는 중';
const TXT_BACK = '한 줄로 돌아오는 중';
const TXT_LINE = '정렬됨 — 곧 다시 무너짐';
const TXT_LINE_STILL = '정렬됨 — Enter 로 다시';
const GLYPHS: Glyph[] = layoutWord(WORD, 60, 400, 240, 112);
let bodies: Body[] = [];
// 무너뜨리기 시작한 시각(age, ms). -1 이면 아직 줄에 서 있다
let fallAt = -1;
function onRestart(): void {
bodies = GLYPHS.map(function (g: Glyph): Body {
const b = makeBody(g, g.hx, g.hy, 0);
b.free = false;
return b;
});
fallAt = -1;
}
function collapse(): void {
const rng = makeRng(4729);
fallAt = age;
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
b.free = true;
b.vx = (rng() - 0.5) * 3;
b.vy = -2 - rng() * 3;
b.w = (rng() - 0.5) * 0.24;
}
}
// 임계 감쇠 스프링 한 칸 — 가속도 = -세기 x 벗어난 거리 - 2 x 제곱근(세기) x 속도
function springTo(v: number, x: number, target: number): number {
return v - SETTLE_SPRING * (x - target) - 2 * Math.sqrt(SETTLE_SPRING) * v;
}
function step(): void {
if (!demo && !still && age >= LOOP_SECONDS * 1000) { restart(); return; }
if (fallAt < 0) {
if (age >= LEAD_MS) { collapse(); }
return;
}
const since = age - fallAt;
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (b.free && since >= ASSEMBLE_DELAY * 1000 + i * REALIGN_STAGGER_MS) {
b.free = false;
// 각도는 가장 가까운 한 바퀴 자리로 — 두 바퀴 돌아 있으면 두 바퀴를 되감지 않는다
b.a -= Math.round(b.a / TAU) * TAU;
}
}
rigidStep(bodies, COLLAPSE_GRAVITY, COLLAPSE_RESTITUTION, COLLAPSE_FRICTION);
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (b.free) { continue; }
b.vx = springTo(b.vx, b.x, b.g.hx);
b.vy = springTo(b.vy, b.y, b.g.hy);
b.w = springTo(b.w, b.a, 0);
b.x += b.vx;
b.y += b.vy;
b.a += b.w;
}
}
// 줄에서 벗어난 거리 — 글자마다 제자리까지 거리의 평균(px)
function lineError(): number {
let s = 0;
for (let i = 0; i < bodies.length; i += 1) { s += Math.hypot(bodies[i].x - bodies[i].g.hx, bodies[i].y - bodies[i].g.hy); }
return bodies.length ? s / bodies.length : 0;
}
function stateText(): string {
const err = lineError();
if (err < 0.5) { return still ? TXT_LINE_STILL : TXT_LINE; }
for (let i = 0; i < bodies.length; i += 1) {
if (!bodies[i].free) { return TXT_BACK; }
}
return TXT_FALL;
}
function draw(): void {
clearStage();
drawFloor();
// 제자리 밑줄 — 줄에서 벗어난 거리가 줄수록 길고 진해진다. 정렬 정도를 lineError 하나로
const first = GLYPHS[0];
const last = GLYPHS[GLYPHS.length - 1];
if (first && last) {
const x0 = first.hx - first.w / 2;
const x1 = last.hx + last.w / 2;
const k = clamp(1 - lineError() / 60, 0, 1);
const y = first.hy + first.h / 2 + 12;
ctx.fillStyle = rgba(PAPER, 0.1);
ctx.fillRect(x0, y, x1 - x0, 3);
ctx.fillStyle = rgba(PAL[2], 0.25 + 0.7 * k);
ctx.fillRect(x0, y, (x1 - x0) * k, 3);
}
for (let i = 0; i < bodies.length; i += 1) { floorShadow(bodies[i].x, bodyBottom(bodies[i]), bodies[i].hw); }
for (let i = 0; i < bodies.length; i += 1) { drawBody(bodies[i]); }
}
function demoDrive(t: number): void { /* 시연은 LOOP_SECONDS 주기로 demoTick 이 다시 세운다 */ }
// 누르면 — 줄에 서 있으면 무너뜨리고, 무너져 있으면 가까운 글자를 튕긴다
function onPointer(type: string, x: number, y: number): boolean {
if (type !== 'down') { return false; }
if (fallAt < 0) { collapse(); return false; }
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
if (!b.free || Math.hypot(b.x - x, b.y - y) > 80) { continue; }
b.vy -= 8;
b.vx += (b.x - x) * 0.06;
}
return false;
}
// 아래 방향키 — 지금 무너뜨리기, 위 방향키 — 지금 한 줄로 되돌리기
function onKey(k: string): boolean {
if (k === 'ArrowDown') {
if (lineError() < 0.5) { restart(); }
collapse();
return true;
}
if (k === 'ArrowUp') {
if (fallAt < 0) { return true; }
fallAt = age - ASSEMBLE_DELAY * 1000 - bodies.length * REALIGN_STAGGER_MS;
return true;
}
return false;
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
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) {
if (!paused) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
tick();
}
acc -= FIXED_DT;
n += 1;
}
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; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
restart();
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<p className={cx("pc__sr")} data-role="word">다시 한 줄로</p>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="헤드라인 글자가 무너져 쌓인 뒤 읽는 순서대로 다시 한 줄로 돌아오는 장면. 누르거나 아래 방향키로 지금 무너뜨리고, 위 방향키로 지금 되돌리고, Enter 로 다시 세우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">헤드라인이 무너지는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 세우기</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}09 A Hero Headline That Collapses, Then Lines Back Up — Measured area 26.865% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · Falling Text Animation: 9 Letters With Physics
The post this demo lives in
Falling Text Animation: 9 Letters With PhysicsDrop a headline and every letter bounces alike. Here 01 spins corner landings into a pile, 03 squashes letters like jell See all demos in this post
If you like this effect - similar demos
Feedback07 Group that folds past threeToast Notification UI: 9 Stacking Patterns
Feedback02 Saved-flag flash — the completion value flips to one the instant a save finishes, then falls back to zero and disappearsAutosave Indicator: 9 Save-Status Patterns
Feedback02 A Number That Overshoots and Springs Back to SettleNumber Rolling Animation JS: 9 Odometer Scenes