07줄마다 폭을 좁혀 한 점으로 빨려 드는 닫힘
창 그림을 가로줄로 잘라 줄마다 목표 점까지의 거리에 따라 폭과 위치를 다른 빠르기로 줄이고, 목표 점에 가까운 위쪽 줄이 먼저 좁아져 창이 깔때기처럼 휘며 한 점으로 빨려 들어간다.
쓰임장바구니로 담기며 닫히는 창
FUNNEL_ROWSFUNNEL_TARGET_XFUNNEL_DURATION_MS

<main class="stage">
<div class="md is-demo" id="md">
<canvas class="md__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<button class="md__open" data-role="open" type="button" aria-haspopup="dialog">옵션 고르기</button>
<div class="md__dialog" data-role="dialog" role="dialog" aria-modal="true" aria-labelledby="md-title-07" aria-describedby="md-body-07" hidden>
<h2 class="md__title" data-role="title" id="md-title-07">이 옵션으로 담을까요?</h2>
<p class="md__body" data-role="body" id="md-body-07">베이지, M 사이즈 한 개를 장바구니에 담아요.</p>
<button class="md__btn" data-role="close" type="button">닫기</button>
<button class="md__btn" data-role="confirm" type="button">담기</button>
</div>
<p class="md__status" data-role="status" aria-live="off">창이 닫혔어요 — 버튼으로 다시 열기</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.md {
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;
}
.md__canvas {
display: block;
width: 100%;
height: 100%;
// 손가락 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
touch-action: pan-y;
}
// 버튼·대화상자 층 — 글자는 캔버스가 그리고, 이 층은 같은 자리에 겹쳐 화면 낭독기·키보드·클릭을 받는다.
// 자리(left·top·width·height)는 스크립트가 장면 좌표(480x300)를 백분율로 바꿔 넣는다
.md__open,
.md__dialog,
.md__title,
.md__body,
.md__btn {
position: absolute;
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: transparent;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
overflow: hidden;
}
.md__open,
.md__btn {
border-radius: $r-control;
font-family: inherit;
cursor: pointer;
transition: outline-color $dur-instant ease-out;
}
.md__title,
.md__body {
pointer-events: none;
}
.md__open:focus-visible,
.md__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
// 창이 아직 움직이는 동안에는 초점 고리를 감춘다 — 고리는 다 열린 창의 버튼 자리에 맞춰져 있다
.md:not(.md--open) .md__btn:focus-visible {
outline-color: transparent;
}
.md__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.md__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 움직임은 스크립트가 matchMedia 로 끈다(열기·닫기가 그 자리에서 끝난다). CSS 쪽은 초점 고리 전환을 끈다
@media (prefers-reduced-motion: reduce) {
.md__open, .md__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);
}
.md {
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;
}
.md__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: pan-y;
}
.md__open,
.md__dialog,
.md__title,
.md__body,
.md__btn {
position: absolute;
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: transparent;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
overflow: hidden;
}
.md__open,
.md__btn {
border-radius: 12px;
font-family: inherit;
cursor: pointer;
transition: outline-color 140ms ease-out;
}
.md__title,
.md__body {
pointer-events: none;
}
.md__open:focus-visible,
.md__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.md:not(.md--open) .md__btn:focus-visible {
outline-color: transparent;
}
.md__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.md__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.md__open, .md__btn {
transition: none;
}
}(function () {
const root = document.getElementById("md");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 창과 여는 버튼의 자리 — 장면 좌표(480x300). 화면 위 버튼·대화상자 층도 이 값으로 같은 자리에 맞춘다
const PANEL = { x: 104, y: 56, w: 272, h: 172 };
const OPENER = { x: 332, y: 248, w: 128, h: 34 };
// 창 안의 자리 — 창 왼쪽 위가 원점
const TITLE_BOX = { x: 20, y: 18, w: 232, h: 26 };
const BODY_BOX = { x: 20, y: 52, w: 232, h: 56 };
const CLOSE_BOX = { x: 56, y: 120, w: 92, h: 34 };
const CONFIRM_BOX = { x: 156, y: 120, w: 96, h: 34 };
const PANEL_RADIUS = 14;
const BUTTON_RADIUS = 10;
// 뒤 페이지를 덮는 막의 가장 진한 값
const SCRIM_MAX = 0.46;
// 파스텔 팔레트 — 밝은 페이지 위에 어두운 막이 덮이며 창이 떠오른다. 글자는 진한 먹색으로 명도 차를 둔다
const PAGE_BG = [246, 243, 251];
const PAGE_CARD = [255, 255, 255];
const PAGE_HERO = [228, 221, 252];
const PAGE_LINE = [218, 212, 232];
const PANEL_BG = [255, 252, 246];
const GHOST = [236, 232, 245];
const INK = [33, 29, 46];
const MUTED = [92, 86, 112];
const SCRIM = [23, 20, 26];
const WHITE = [255, 255, 255];
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 easeOutCubic(x) { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 가속 곡선 — 처음 느리고 끝에 빠르다
function easeInCubic(x) { const c = clamp(x, 0, 1); return c * c * c; }
function easeInOutCubic(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (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;
};
}
// 진행률을 목표(0 또는 1) 쪽으로 dt/길이만큼 옮긴다 — 닫히는 중에 다시 열면 지금 값에서 그대로 되돌아간다
function approach(v, target, dt, ms) {
const s = dt / ms;
return target > v ? Math.min(target, v + s) : Math.max(target, v - s);
}
// 감쇠 스프링 한 스텝 — 가속도 = -강성 x (위치 - 목표) - 감쇠 x 속도. 속도를 먼저 고친 뒤 그 속도로 위치를 옮긴다. dt 는 초
function springStep(s, target, stiffness, damping, dt) {
const a = -stiffness * (s.x - target) - damping * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
function springRest(s, target) { return Math.abs(s.x - target) < 0.002 && Math.abs(s.v) < 0.02; }
function lerpRect(a, b, k) {
return { x: lerp(a.x, b.x, k), y: lerp(a.y, b.y, k), w: lerp(a.w, b.w, k), h: lerp(a.h, b.h, k) };
}
function inside(r, x, y) { return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; }
// 모서리 둥근 사각형 경로 — 가로 반경 rx 와 세로 반경 ry 를 따로 받는다(늘어난 좌표계에서는 배율로 나눈 반경을 넣어 모서리를 원으로 되돌린다)
function roundRectPath(c, x, y, w, h, rx, ry) {
const ax = Math.max(0, Math.min(rx, w / 2));
const ay = Math.max(0, Math.min(ry, h / 2));
c.beginPath();
c.moveTo(x + ax, y);
c.lineTo(x + w - ax, y);
c.ellipse(x + w - ax, y + ay, ax, ay, 0, -Math.PI / 2, 0);
c.lineTo(x + w, y + h - ay);
c.ellipse(x + w - ax, y + h - ay, ax, ay, 0, 0, Math.PI / 2);
c.lineTo(x + ax, y + h);
c.ellipse(x + ax, y + h - ay, ax, ay, 0, Math.PI / 2, Math.PI);
c.lineTo(x, y + ay);
c.ellipse(x + ax, y + ay, ax, ay, 0, Math.PI, Math.PI * 1.5);
c.closePath();
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const openEl = root.querySelector('[data-role="open"]');
const dialogEl = root.querySelector('[data-role="dialog"]');
const titleEl = root.querySelector('[data-role="title"]');
const bodyEl = root.querySelector('[data-role="body"]');
const closeEl = root.querySelector('[data-role="close"]');
const confirmEl = root.querySelector('[data-role="confirm"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !openEl || !dialogEl || !titleEl || !bodyEl || !closeEl || !confirmEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const openBtn = openEl;
const dialog = dialogEl;
const titleNode = titleEl;
const bodyNode = bodyEl;
const closeBtn = closeEl;
const confirmBtn = confirmEl;
function textOf(el) { return (el.textContent || '').trim(); }
// 화면에 보이는 글자는 DOM 글자를 캔버스에 그린 것 — 문구를 바꾸면 그림도 같이 바뀐다
const OPEN_LABEL = textOf(openBtn);
const TITLE = textOf(titleNode);
const BODY = textOf(bodyNode);
const CLOSE_LABEL = textOf(closeBtn);
const CONFIRM_LABEL = textOf(confirmBtn);
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 fontOf(weight, px) { return weight + ' ' + px.toFixed(1) + 'px ' + FONT_FAMILY; }
// 한 줄에 들어가도록 글자 크기를 줄인다 — 언어마다 글자 길이가 달라도 상자 안에 든다
function fitFont(c, text, weight, px, maxW) {
let size = px;
c.font = fontOf(weight, size);
while (size > 8 && c.measureText(text).width > maxW) {
size -= 0.5;
c.font = fontOf(weight, size);
}
return size;
}
// 줄 나누기 — 폭을 넘으면 마지막 띄어쓰기에서 끊고, 띄어쓰기가 없는 글(일본어)은 그 글자에서 끊는다
function wrapLines(c, text, maxW) {
const out = [];
let line = '';
const chars = Array.from(text);
for (let i = 0; i < chars.length; i += 1) {
const ch = chars[i];
const test = line + ch;
if (line !== '' && c.measureText(test).width > maxW) {
const cut = line.lastIndexOf(' ');
if (cut > 0 && ch !== ' ') {
out.push(line.slice(0, cut));
line = line.slice(cut + 1) + ch;
}
else {
out.push(line);
line = ch === ' ' ? '' : ch;
}
}
else {
line = test;
}
}
if (line !== '') {
out.push(line);
}
return out;
}
function drawButton(c, r, fill, label, alpha) {
if (alpha <= 0.001) {
return;
}
c.save();
c.globalAlpha *= alpha;
roundRectPath(c, r.x, r.y, r.w, r.h, BUTTON_RADIUS, BUTTON_RADIUS);
c.fillStyle = rgba(fill, 1);
c.fill();
fitFont(c, label, 600, 12.5, r.w - 14);
c.fillStyle = rgba(INK, 1);
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + 0.5);
c.restore();
}
// 창 그림 — 창 하나를 화면 밖 캔버스에 한 번 그려 두고, 효과마다 이 그림을 잘라·늘려·옮겨 그린다
let dpr = 1;
function makeSprite(withBg, withConfirm) {
const sc = document.createElement('canvas');
sc.width = Math.round(PANEL.w * dpr);
sc.height = Math.round(PANEL.h * dpr);
const c = sc.getContext('2d');
if (!c) {
return sc;
}
c.setTransform(dpr, 0, 0, dpr, 0, 0);
if (withBg) {
roundRectPath(c, 0, 0, PANEL.w, PANEL.h, PANEL_RADIUS, PANEL_RADIUS);
c.fillStyle = rgba(PANEL_BG, 1);
c.fill();
}
c.textAlign = 'left';
c.textBaseline = 'top';
fitFont(c, TITLE, 700, 16.5, TITLE_BOX.w);
c.fillStyle = rgba(INK, 1);
c.fillText(TITLE, TITLE_BOX.x, TITLE_BOX.y + 3);
// 본문 — 줄 수가 상자 높이를 넘으면 글자 크기를 반 px 씩 줄인다
let size = 12.5;
let lines = [];
for (; size >= 9; size -= 0.5) {
c.font = fontOf(500, size);
lines = wrapLines(c, BODY, BODY_BOX.w);
if (lines.length * size * 1.5 <= BODY_BOX.h) {
break;
}
}
c.fillStyle = rgba(MUTED, 1);
for (let i = 0; i < lines.length; i += 1) {
c.fillText(lines[i], BODY_BOX.x, BODY_BOX.y + i * size * 1.5);
}
drawButton(c, CLOSE_BOX, GHOST, CLOSE_LABEL, 1);
if (withConfirm) {
drawButton(c, CONFIRM_BOX, ACCENT, CONFIRM_LABEL, 1);
}
return sc;
}
// 배경·글자·두 버튼 / 글자·버튼만(배경 없음) / 배경·글자·닫기 버튼만(확인 버튼은 효과가 따로 그린다)
let spriteFull = document.createElement('canvas');
let spriteContent = document.createElement('canvas');
let spriteBare = document.createElement('canvas');
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
spriteFull = makeSprite(true, true);
spriteContent = makeSprite(false, true);
spriteBare = makeSprite(true, false);
}
// 창 그림의 한 조각을 장면에 옮겨 그린다 — 원본 좌표는 창 기준 px, 그림 배율을 곱해 화소로 바꾼다
function blit(sp, sx, sy, sw, sh, dx, dy, dw, dh) {
if (sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0) {
return;
}
ctx.drawImage(sp, sx * dpr, sy * dpr, sw * dpr, sh * dpr, dx, dy, dw, dh);
}
// 버튼·대화상자 층을 장면 좌표와 같은 자리에 백분율로 놓는다(한 번만 — 크기가 바뀌어도 백분율이라 그대로 맞는다)
function pct(v, of) { return ((v / of) * 100).toFixed(3) + '%'; }
function place(el, r, pw, ph) {
el.style.left = pct(r.x, pw);
el.style.top = pct(r.y, ph);
el.style.width = pct(r.w, pw);
el.style.height = pct(r.h, ph);
}
function placeOverlays() {
place(openBtn, OPENER, W, H);
place(dialog, PANEL, W, H);
place(titleNode, TITLE_BOX, PANEL.w, PANEL.h);
place(bodyNode, BODY_BOX, PANEL.w, PANEL.h);
place(closeBtn, CLOSE_BOX, PANEL.w, PANEL.h);
place(confirmBtn, CONFIRM_BOX, PANEL.w, PANEL.h);
}
function fillRound(x, y, w, h, r, col, a) {
roundRectPath(ctx, x, y, w, h, r, r);
ctx.fillStyle = rgba(col, a);
ctx.fill();
}
// 뒤 페이지 — 머리 띠·큰 카드·작은 카드 몇 장. 글자 대신 막대로 그려 언어와 상관없이 같은 그림이다
function drawPage() {
ctx.fillStyle = rgba(PAGE_BG, 1);
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = rgba(PAGE_CARD, 1);
ctx.fillRect(0, 0, W, 40);
ctx.fillStyle = rgba(PAGE_LINE, 1);
ctx.fillRect(0, 40, W, 1);
ctx.fillStyle = rgba(ACCENT, 1);
ctx.beginPath();
ctx.arc(26, 20, 9, 0, TAU);
ctx.fill();
fillRound(44, 15, 70, 10, 5, PAGE_LINE, 1);
fillRound(252, 15, 34, 10, 5, PAGE_LINE, 1);
fillRound(296, 15, 34, 10, 5, PAGE_LINE, 1);
fillRound(20, 56, 284, 132, 12, PAGE_HERO, 1);
fillRound(36, 78, 150, 14, 7, WHITE, 0.85);
fillRound(36, 102, 210, 8, 4, WHITE, 0.7);
fillRound(36, 116, 176, 8, 4, WHITE, 0.7);
fillRound(36, 150, 88, 22, 11, WHITE, 0.9);
fillRound(316, 56, 144, 60, 12, PAGE_CARD, 1);
fillRound(328, 70, 84, 8, 4, PAGE_LINE, 1);
fillRound(328, 86, 112, 6, 3, PAGE_LINE, 0.7);
fillRound(316, 128, 144, 60, 12, PAGE_CARD, 1);
fillRound(328, 142, 70, 8, 4, PAGE_LINE, 1);
fillRound(328, 158, 104, 6, 3, PAGE_LINE, 0.7);
fillRound(20, 200, 140, 36, 10, PAGE_CARD, 1);
fillRound(172, 200, 140, 36, 10, PAGE_CARD, 1);
fillRound(32, 214, 80, 8, 4, PAGE_LINE, 1);
fillRound(184, 214, 64, 8, 4, PAGE_LINE, 1);
itemPage();
}
function drawOpener(alpha) {
ctx.save();
ctx.shadowColor = rgba(SCRIM, 0.2);
ctx.shadowBlur = 8 * dpr;
ctx.shadowOffsetY = 3 * dpr;
drawButton(ctx, OPENER, ACCENT, OPEN_LABEL, alpha);
ctx.restore();
}
// 뒤를 덮는 막 — a 는 0(없음)에서 1(가장 진함)
function drawScrim(a) {
if (a <= 0.001) {
return;
}
ctx.fillStyle = rgba(SCRIM, SCRIM_MAX * clamp(a, 0, 1));
ctx.fillRect(0, 0, W, H);
}
// 창을 가운데 기준으로 s 배 키워 그린다. lift 는 떠오른 높이(px) — 그림자 번짐과 거리가 이 값 하나로 정해진다
function drawPanelScaled(sp, s, alpha, lift) {
if (alpha <= 0.001 || s <= 0.001) {
return;
}
const cx = PANEL.x + PANEL.w / 2;
const cy = PANEL.y + PANEL.h / 2;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(cx, cy);
ctx.scale(s, s);
// 그림자 값은 변환을 따라가지 않는 화소 단위라 배율(dpr)을 직접 곱한다
ctx.shadowColor = rgba(SCRIM, clamp(0.16 + 0.008 * lift, 0, 0.5));
ctx.shadowBlur = (4 + lift * 1.3) * dpr;
ctx.shadowOffsetY = (2 + lift * 0.55) * dpr;
ctx.drawImage(sp, -PANEL.w / 2, -PANEL.h / 2, PANEL.w, PANEL.h);
ctx.restore();
}
// 열고 닫는 흐름 — closed → opening → open → closing → closed. 효과마다 itemStep 이 「다 끝났다」 를 알려 준다
let phase = 'closed';
let phaseAge = 0;
function setPhase(p) {
phase = p;
phaseAge = 0;
root.classList.toggle('md--open', p === 'open');
}
function shown() { return phase === 'opening' || phase === 'open'; }
// 대화상자 층 보이기 — 창이 떠 있는 동안 여는 버튼은 inert 로 막아 Tab·클릭이 창 뒤로 새지 않게 한다
function showDialog(v) {
dialog.hidden = !v;
if (v) {
openBtn.setAttribute('inert', '');
}
else {
openBtn.removeAttribute('inert');
}
}
function snapTo(open) {
itemSnap(open);
showDialog(open);
setPhase(open ? 'open' : 'closed');
}
function requestOpen(user) {
if (shown()) {
return;
}
showDialog(true);
setPhase('opening');
itemOpen();
// 동작 줄이기 — 움직임 없이 그 자리에서 다 열린 창을 그린다
if (still) {
snapTo(true);
}
if (user) {
(FIRST_FOCUS === 'close' ? closeBtn : confirmBtn).focus({ preventScroll: true });
}
}
function requestClose(user, via) {
if (!shown()) {
return;
}
const hadFocus = dialog.contains(document.activeElement);
showDialog(false);
setPhase('closing');
itemClose(via);
if (still) {
snapTo(false);
}
// 닫으면 초점은 창을 열었던 버튼으로 돌아간다
if (user || hadFocus) {
openBtn.focus({ preventScroll: true });
}
}
const TXT_CLOSED = '창이 닫혔어요 — 버튼으로 다시 열기';
const TXT_OPENING = '창이 열리는 중';
const TXT_OPEN = '창이 열렸어요 — Esc 로 닫기';
const TXT_CLOSING = '창이 닫히는 중';
function stateText() {
const s = itemStatus();
if (s !== '') {
return s;
}
if (phase === 'open') {
return TXT_OPEN;
}
if (phase === 'opening') {
return TXT_OPENING;
}
if (phase === 'closing') {
return TXT_CLOSING;
}
return TXT_CLOSED;
}
function render() {
drawPage();
itemDraw();
say(stateText());
}
function tick() {
const done = itemStep(FIXED_DT);
phaseAge += FIXED_DT;
if (phase === 'opening' && done) {
setPhase('open');
}
else if (phase === 'closing' && done) {
setPhase('closed');
}
}
// 시연 — DEMO_LOOP_MS 마다 같은 시간표로 연다(DEMO_OPEN_AT) → 닫는다(DEMO_CLOSE_AT, DEMO_CLOSE_VIA 버튼으로)
let demoCycle = -1;
let demoOpened = false;
let demoClosed = false;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP_MS);
if (cyc !== demoCycle) {
demoCycle = cyc;
demoOpened = false;
demoClosed = false;
if (phase !== 'closed') {
snapTo(false);
}
}
const lt = t - cyc * DEMO_LOOP_MS;
if (!demoOpened && lt >= DEMO_OPEN_AT) {
demoOpened = true;
requestOpen(false);
}
if (!demoClosed && lt >= DEMO_CLOSE_AT) {
demoClosed = true;
if (DEMO_CLOSE_VIA !== 'confirm' || !itemConfirm()) {
requestClose(false, DEMO_CLOSE_VIA);
}
}
}
// 화면 좌표(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 FUNNEL_ROWS = 30;
// 담기로 닫을 때 빨려 드는 점 — 무대 폭에 대한 비율(머리 띠의 장바구니 알약 가운데)
const FUNNEL_TARGET_X = 0.86;
// 닫히는 시간(ms)
const FUNNEL_DURATION_MS = 900;
// 빨려 드는 점의 높이(px)
const FUNNEL_TARGET_Y = 21;
// 목표점에서 가장 먼 줄이 출발을 늦추는 몫 — 진행률 단위
const FUNNEL_LAG = 0.55;
// 열 때 여는 버튼에서 거꾸로 펼쳐지는 시간(ms)
const FUNNEL_OPEN_MS = 560;
const ACCENT = [246, 214, 150];
const CART_FILL = [255, 226, 170];
const FIRST_FOCUS = 'confirm';
const DEMO_LOOP_MS = 2400;
const DEMO_OPEN_AT = 200;
const DEMO_CLOSE_AT = 1150;
const DEMO_CLOSE_VIA = 'confirm';
const TXT_TO_CART = '장바구니로 담는 중';
const TXT_ADDED = '장바구니에 담았어요 — 버튼으로 다시 열기';
const CART = { x: FUNNEL_TARGET_X * W - 26, y: FUNNEL_TARGET_Y - 11, w: 52, h: 22 };
// suck — 0 이면 창이 다 펼쳐짐, 1 이면 점 하나로 다 빨려 듦
let suck = 1;
let suckTarget = 1;
let pointX = OPENER.x + OPENER.w / 2;
let pointY = OPENER.y + OPENER.h / 2;
let toCart = false;
let added = false;
let cartCount = 2;
const cartBump = { x: 0, v: 0 };
function setPoint(cart) {
toCart = cart;
pointX = cart ? FUNNEL_TARGET_X * W : OPENER.x + OPENER.w / 2;
pointY = cart ? FUNNEL_TARGET_Y : OPENER.y + OPENER.h / 2;
}
function itemOpen() {
// 다 닫혀 있었으면 여는 버튼에서 펼친다. 장바구니로 빨려 들던 중이면 그 자리에서 거꾸로 돌아 나온다
if (suck >= 1) {
setPoint(false);
}
suckTarget = 0;
added = false;
}
function itemClose(via) {
// 담기 버튼으로 닫으면 장바구니로, 그 밖의 닫기는 여는 버튼으로 빨려 든다
setPoint(via === 'confirm');
suckTarget = 1;
}
function itemSnap(open) {
suckTarget = open ? 0 : 1;
suck = suckTarget;
setPoint(false);
}
function itemStep(dt) {
suck = approach(suck, suckTarget, dt, suckTarget > suck ? FUNNEL_DURATION_MS : FUNNEL_OPEN_MS);
if (suck >= 1 && toCart && !added && phase === 'closing') {
added = true;
cartCount = cartCount >= 99 ? 1 : cartCount + 1;
// 장바구니 알약이 한 번 부풀었다 돌아온다 — 속도를 걷어차 스프링으로 되돌린다
cartBump.v = 9;
}
springStep(cartBump, 0, 420, 15, dt / 1000);
return suck === suckTarget;
}
// 줄 하나의 진행률 — 목표점에서 먼 줄일수록 늦게 출발한다(거리에 비례한 지연). 늦은 만큼 남은 시간에 더 빨리 따라잡는다
function rowProgress(p, dn) { return clamp((p - FUNNEL_LAG * dn) / (1 - FUNNEL_LAG), 0, 1); }
function itemPage() {
drawOpener(1);
const s = 1 + 0.4 * cartBump.x;
ctx.save();
ctx.translate(CART.x + CART.w / 2, CART.y + CART.h / 2);
ctx.scale(s, s);
fillRound(-CART.w / 2, -CART.h / 2, CART.w, CART.h, CART.h / 2, CART_FILL, 1);
ctx.fillStyle = rgba(INK, 1);
ctx.beginPath();
ctx.arc(-CART.w / 2 + 12, 0, 4, 0, TAU);
ctx.fill();
ctx.font = fontOf(700, 12);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(cartCount), 6, 0.5);
ctx.restore();
}
function itemDraw() {
if (suck >= 1) {
return;
}
const p = suck;
drawScrim(1 - smooth(p * 1.2));
const rh = PANEL.h / FUNNEL_ROWS;
const cx0 = PANEL.x + PANEL.w / 2;
const dist = [];
let dmin = 1e9;
let dmax = -1e9;
for (let i = 0; i < FUNNEL_ROWS; i += 1) {
const d = Math.hypot(cx0 - pointX, PANEL.y + (i + 0.5) * rh - pointY);
dist.push(d);
dmin = Math.min(dmin, d);
dmax = Math.max(dmax, d);
}
// 먼 줄부터 그려 목표점에 가까운 줄이 위에 온다
const order = [];
for (let i = 0; i < FUNNEL_ROWS; i += 1) {
order.push(i);
}
order.sort(function (a, b) { return dist[b] - dist[a]; });
for (let n = 0; n < order.length; n += 1) {
const i = order[n];
const dn = (dist[i] - dmin) / Math.max(dmax - dmin, 1);
const q = rowProgress(p, dn);
// 폭은 빨리 좁히고(1.6배 빠르게) 자리는 늦게 옮긴다(제곱) — 먼저 가늘어진 뒤 빨려 든다
const wq = smooth(q * 1.6);
const mq = q * q;
const w = lerp(PANEL.w, 3, wq);
const cx = lerp(cx0, pointX, mq);
const cy = lerp(PANEL.y + (i + 0.5) * rh, pointY, mq);
const h = lerp(rh, rh * 0.2, mq);
ctx.globalAlpha = 1 - smooth((q - 0.85) / 0.15);
blit(spriteFull, 0, i * rh, PANEL.w, rh, cx - w / 2, cy - h / 2, w, h + 0.4);
}
ctx.globalAlpha = 1;
}
function itemConfirm() { return false; }
function itemStatus() {
if (phase === 'closing' && toCart) {
return TXT_TO_CART;
}
if (phase === 'closed' && added) {
return TXT_ADDED;
}
return '';
}
function after() { render(); }
root.addEventListener('pointerdown', function () { wake(); });
openBtn.addEventListener('click', function () { wake(); requestOpen(true); after(); });
closeBtn.addEventListener('click', function () { wake(); requestClose(true, 'close'); after(); });
confirmBtn.addEventListener('click', function () {
wake();
if (still || !itemConfirm()) {
requestClose(true, 'confirm');
}
after();
});
// 창 바깥(막)을 누르면 닫는다
cv.addEventListener('pointerdown', function (ev) {
const p = toScene(ev);
if (shown() && !inside(PANEL, p[0], p[1])) {
requestClose(true, 'backdrop');
after();
}
});
root.addEventListener('keydown', function (ev) {
wake();
if (ev.key === 'Escape') {
if (!shown()) {
return;
}
ev.preventDefault();
requestClose(true, 'esc');
after();
return;
}
// 초점 가두기 — 창이 떠 있는 동안 Tab 은 창 안 두 버튼 사이만 돈다
if (ev.key === 'Tab' && shown()) {
const order = [closeBtn, confirmBtn];
const i = order.indexOf(document.activeElement);
const n = i < 0 ? 0 : (i + (ev.shiftKey ? order.length - 1 : 1)) % order.length;
ev.preventDefault();
order[n].focus({ preventScroll: true });
}
});
function onResize() {
fitCanvas();
render();
}
placeOverlays();
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 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;
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;
}
snapTo(shown());
render();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
// 동작 줄이기 — 시연 대신 다 열린 창을 한 장 그려 둔다. 버튼을 누르면 그 자리에서 열리고 닫힌다
snapTo(true);
render();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// FunnelRowShrinkClose - vanilla 판(07-modal-anim-funnel-row-shrink-close) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FunnelRowShrinkClose.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FunnelRowShrinkClose() {
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];
type Rect = { x: number; y: number; w: number; h: number };
type Spring = { x: number; v: number };
type Phase = 'closed' | 'opening' | 'open' | 'closing';
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 창과 여는 버튼의 자리 — 장면 좌표(480x300). 화면 위 버튼·대화상자 층도 이 값으로 같은 자리에 맞춘다
const PANEL: Rect = { x: 104, y: 56, w: 272, h: 172 };
const OPENER: Rect = { x: 332, y: 248, w: 128, h: 34 };
// 창 안의 자리 — 창 왼쪽 위가 원점
const TITLE_BOX: Rect = { x: 20, y: 18, w: 232, h: 26 };
const BODY_BOX: Rect = { x: 20, y: 52, w: 232, h: 56 };
const CLOSE_BOX: Rect = { x: 56, y: 120, w: 92, h: 34 };
const CONFIRM_BOX: Rect = { x: 156, y: 120, w: 96, h: 34 };
const PANEL_RADIUS = 14;
const BUTTON_RADIUS = 10;
// 뒤 페이지를 덮는 막의 가장 진한 값
const SCRIM_MAX = 0.46;
// 파스텔 팔레트 — 밝은 페이지 위에 어두운 막이 덮이며 창이 떠오른다. 글자는 진한 먹색으로 명도 차를 둔다
const PAGE_BG: RGB = [246, 243, 251];
const PAGE_CARD: RGB = [255, 255, 255];
const PAGE_HERO: RGB = [228, 221, 252];
const PAGE_LINE: RGB = [218, 212, 232];
const PANEL_BG: RGB = [255, 252, 246];
const GHOST: RGB = [236, 232, 245];
const INK: RGB = [33, 29, 46];
const MUTED: RGB = [92, 86, 112];
const SCRIM: RGB = [23, 20, 26];
const WHITE: RGB = [255, 255, 255];
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 easeOutCubic(x: number): number { const c = 1 - clamp(x, 0, 1); return 1 - c * c * c; }
// 가속 곡선 — 처음 느리고 끝에 빠르다
function easeInCubic(x: number): number { const c = clamp(x, 0, 1); return c * c * c; }
function easeInOutCubic(x: number): number {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (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;
};
}
// 진행률을 목표(0 또는 1) 쪽으로 dt/길이만큼 옮긴다 — 닫히는 중에 다시 열면 지금 값에서 그대로 되돌아간다
function approach(v: number, target: number, dt: number, ms: number): number {
const s = dt / ms;
return target > v ? Math.min(target, v + s) : Math.max(target, v - s);
}
// 감쇠 스프링 한 스텝 — 가속도 = -강성 x (위치 - 목표) - 감쇠 x 속도. 속도를 먼저 고친 뒤 그 속도로 위치를 옮긴다. dt 는 초
function springStep(s: Spring, target: number, stiffness: number, damping: number, dt: number): void {
const a = -stiffness * (s.x - target) - damping * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
function springRest(s: Spring, target: number): boolean { return Math.abs(s.x - target) < 0.002 && Math.abs(s.v) < 0.02; }
function lerpRect(a: Rect, b: Rect, k: number): Rect {
return { x: lerp(a.x, b.x, k), y: lerp(a.y, b.y, k), w: lerp(a.w, b.w, k), h: lerp(a.h, b.h, k) };
}
function inside(r: Rect, x: number, y: number): boolean { return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; }
// 모서리 둥근 사각형 경로 — 가로 반경 rx 와 세로 반경 ry 를 따로 받는다(늘어난 좌표계에서는 배율로 나눈 반경을 넣어 모서리를 원으로 되돌린다)
function roundRectPath(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rx: number, ry: number): void {
const ax = Math.max(0, Math.min(rx, w / 2));
const ay = Math.max(0, Math.min(ry, h / 2));
c.beginPath();
c.moveTo(x + ax, y);
c.lineTo(x + w - ax, y);
c.ellipse(x + w - ax, y + ay, ax, ay, 0, -Math.PI / 2, 0);
c.lineTo(x + w, y + h - ay);
c.ellipse(x + w - ax, y + h - ay, ax, ay, 0, 0, Math.PI / 2);
c.lineTo(x + ax, y + h);
c.ellipse(x + ax, y + h - ay, ax, ay, 0, Math.PI / 2, Math.PI);
c.lineTo(x, y + ay);
c.ellipse(x + ax, y + ay, ax, ay, 0, Math.PI, Math.PI * 1.5);
c.closePath();
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const openEl = root.querySelector('[data-role="open"]') as HTMLButtonElement | null;
const dialogEl = root.querySelector('[data-role="dialog"]') as HTMLElement | null;
const titleEl = root.querySelector('[data-role="title"]') as HTMLElement | null;
const bodyEl = root.querySelector('[data-role="body"]') as HTMLElement | null;
const closeEl = root.querySelector('[data-role="close"]') as HTMLButtonElement | null;
const confirmEl = root.querySelector('[data-role="confirm"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !openEl || !dialogEl || !titleEl || !bodyEl || !closeEl || !confirmEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const openBtn: HTMLButtonElement = openEl;
const dialog: HTMLElement = dialogEl;
const titleNode: HTMLElement = titleEl;
const bodyNode: HTMLElement = bodyEl;
const closeBtn: HTMLButtonElement = closeEl;
const confirmBtn: HTMLButtonElement = confirmEl;
function textOf(el: HTMLElement): string { return (el.textContent || '').trim(); }
// 화면에 보이는 글자는 DOM 글자를 캔버스에 그린 것 — 문구를 바꾸면 그림도 같이 바뀐다
const OPEN_LABEL = textOf(openBtn);
const TITLE = textOf(titleNode);
const BODY = textOf(bodyNode);
const CLOSE_LABEL = textOf(closeBtn);
const CONFIRM_LABEL = textOf(confirmBtn);
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 fontOf(weight: number, px: number): string { return weight + ' ' + px.toFixed(1) + 'px ' + FONT_FAMILY; }
// 한 줄에 들어가도록 글자 크기를 줄인다 — 언어마다 글자 길이가 달라도 상자 안에 든다
function fitFont(c: CanvasRenderingContext2D, text: string, weight: number, px: number, maxW: number): number {
let size = px;
c.font = fontOf(weight, size);
while (size > 8 && c.measureText(text).width > maxW) {
size -= 0.5;
c.font = fontOf(weight, size);
}
return size;
}
// 줄 나누기 — 폭을 넘으면 마지막 띄어쓰기에서 끊고, 띄어쓰기가 없는 글(일본어)은 그 글자에서 끊는다
function wrapLines(c: CanvasRenderingContext2D, text: string, maxW: number): string[] {
const out: string[] = [];
let line = '';
const chars = Array.from(text);
for (let i = 0; i < chars.length; i += 1) {
const ch = chars[i];
const test = line + ch;
if (line !== '' && c.measureText(test).width > maxW) {
const cut = line.lastIndexOf(' ');
if (cut > 0 && ch !== ' ') {
out.push(line.slice(0, cut));
line = line.slice(cut + 1) + ch;
} else {
out.push(line);
line = ch === ' ' ? '' : ch;
}
} else {
line = test;
}
}
if (line !== '') { out.push(line); }
return out;
}
function drawButton(c: CanvasRenderingContext2D, r: Rect, fill: RGB, label: string, alpha: number): void {
if (alpha <= 0.001) { return; }
c.save();
c.globalAlpha *= alpha;
roundRectPath(c, r.x, r.y, r.w, r.h, BUTTON_RADIUS, BUTTON_RADIUS);
c.fillStyle = rgba(fill, 1);
c.fill();
fitFont(c, label, 600, 12.5, r.w - 14);
c.fillStyle = rgba(INK, 1);
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + 0.5);
c.restore();
}
// 창 그림 — 창 하나를 화면 밖 캔버스에 한 번 그려 두고, 효과마다 이 그림을 잘라·늘려·옮겨 그린다
let dpr = 1;
function makeSprite(withBg: boolean, withConfirm: boolean): HTMLCanvasElement {
const sc = document.createElement('canvas');
sc.width = Math.round(PANEL.w * dpr);
sc.height = Math.round(PANEL.h * dpr);
const c = sc.getContext('2d');
if (!c) { return sc; }
c.setTransform(dpr, 0, 0, dpr, 0, 0);
if (withBg) {
roundRectPath(c, 0, 0, PANEL.w, PANEL.h, PANEL_RADIUS, PANEL_RADIUS);
c.fillStyle = rgba(PANEL_BG, 1);
c.fill();
}
c.textAlign = 'left';
c.textBaseline = 'top';
fitFont(c, TITLE, 700, 16.5, TITLE_BOX.w);
c.fillStyle = rgba(INK, 1);
c.fillText(TITLE, TITLE_BOX.x, TITLE_BOX.y + 3);
// 본문 — 줄 수가 상자 높이를 넘으면 글자 크기를 반 px 씩 줄인다
let size = 12.5;
let lines: string[] = [];
for (; size >= 9; size -= 0.5) {
c.font = fontOf(500, size);
lines = wrapLines(c, BODY, BODY_BOX.w);
if (lines.length * size * 1.5 <= BODY_BOX.h) { break; }
}
c.fillStyle = rgba(MUTED, 1);
for (let i = 0; i < lines.length; i += 1) { c.fillText(lines[i], BODY_BOX.x, BODY_BOX.y + i * size * 1.5); }
drawButton(c, CLOSE_BOX, GHOST, CLOSE_LABEL, 1);
if (withConfirm) { drawButton(c, CONFIRM_BOX, ACCENT, CONFIRM_LABEL, 1); }
return sc;
}
// 배경·글자·두 버튼 / 글자·버튼만(배경 없음) / 배경·글자·닫기 버튼만(확인 버튼은 효과가 따로 그린다)
let spriteFull: HTMLCanvasElement = document.createElement('canvas');
let spriteContent: HTMLCanvasElement = document.createElement('canvas');
let spriteBare: HTMLCanvasElement = document.createElement('canvas');
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
spriteFull = makeSprite(true, true);
spriteContent = makeSprite(false, true);
spriteBare = makeSprite(true, false);
}
// 창 그림의 한 조각을 장면에 옮겨 그린다 — 원본 좌표는 창 기준 px, 그림 배율을 곱해 화소로 바꾼다
function blit(sp: HTMLCanvasElement, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void {
if (sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0) { return; }
ctx.drawImage(sp, sx * dpr, sy * dpr, sw * dpr, sh * dpr, dx, dy, dw, dh);
}
// 버튼·대화상자 층을 장면 좌표와 같은 자리에 백분율로 놓는다(한 번만 — 크기가 바뀌어도 백분율이라 그대로 맞는다)
function pct(v: number, of: number): string { return ((v / of) * 100).toFixed(3) + '%'; }
function place(el: HTMLElement, r: Rect, pw: number, ph: number): void {
el.style.left = pct(r.x, pw);
el.style.top = pct(r.y, ph);
el.style.width = pct(r.w, pw);
el.style.height = pct(r.h, ph);
}
function placeOverlays(): void {
place(openBtn, OPENER, W, H);
place(dialog, PANEL, W, H);
place(titleNode, TITLE_BOX, PANEL.w, PANEL.h);
place(bodyNode, BODY_BOX, PANEL.w, PANEL.h);
place(closeBtn, CLOSE_BOX, PANEL.w, PANEL.h);
place(confirmBtn, CONFIRM_BOX, PANEL.w, PANEL.h);
}
function fillRound(x: number, y: number, w: number, h: number, r: number, col: RGB, a: number): void {
roundRectPath(ctx, x, y, w, h, r, r);
ctx.fillStyle = rgba(col, a);
ctx.fill();
}
// 뒤 페이지 — 머리 띠·큰 카드·작은 카드 몇 장. 글자 대신 막대로 그려 언어와 상관없이 같은 그림이다
function drawPage(): void {
ctx.fillStyle = rgba(PAGE_BG, 1);
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = rgba(PAGE_CARD, 1);
ctx.fillRect(0, 0, W, 40);
ctx.fillStyle = rgba(PAGE_LINE, 1);
ctx.fillRect(0, 40, W, 1);
ctx.fillStyle = rgba(ACCENT, 1);
ctx.beginPath();
ctx.arc(26, 20, 9, 0, TAU);
ctx.fill();
fillRound(44, 15, 70, 10, 5, PAGE_LINE, 1);
fillRound(252, 15, 34, 10, 5, PAGE_LINE, 1);
fillRound(296, 15, 34, 10, 5, PAGE_LINE, 1);
fillRound(20, 56, 284, 132, 12, PAGE_HERO, 1);
fillRound(36, 78, 150, 14, 7, WHITE, 0.85);
fillRound(36, 102, 210, 8, 4, WHITE, 0.7);
fillRound(36, 116, 176, 8, 4, WHITE, 0.7);
fillRound(36, 150, 88, 22, 11, WHITE, 0.9);
fillRound(316, 56, 144, 60, 12, PAGE_CARD, 1);
fillRound(328, 70, 84, 8, 4, PAGE_LINE, 1);
fillRound(328, 86, 112, 6, 3, PAGE_LINE, 0.7);
fillRound(316, 128, 144, 60, 12, PAGE_CARD, 1);
fillRound(328, 142, 70, 8, 4, PAGE_LINE, 1);
fillRound(328, 158, 104, 6, 3, PAGE_LINE, 0.7);
fillRound(20, 200, 140, 36, 10, PAGE_CARD, 1);
fillRound(172, 200, 140, 36, 10, PAGE_CARD, 1);
fillRound(32, 214, 80, 8, 4, PAGE_LINE, 1);
fillRound(184, 214, 64, 8, 4, PAGE_LINE, 1);
itemPage();
}
function drawOpener(alpha: number): void {
ctx.save();
ctx.shadowColor = rgba(SCRIM, 0.2);
ctx.shadowBlur = 8 * dpr;
ctx.shadowOffsetY = 3 * dpr;
drawButton(ctx, OPENER, ACCENT, OPEN_LABEL, alpha);
ctx.restore();
}
// 뒤를 덮는 막 — a 는 0(없음)에서 1(가장 진함)
function drawScrim(a: number): void {
if (a <= 0.001) { return; }
ctx.fillStyle = rgba(SCRIM, SCRIM_MAX * clamp(a, 0, 1));
ctx.fillRect(0, 0, W, H);
}
// 창을 가운데 기준으로 s 배 키워 그린다. lift 는 떠오른 높이(px) — 그림자 번짐과 거리가 이 값 하나로 정해진다
function drawPanelScaled(sp: HTMLCanvasElement, s: number, alpha: number, lift: number): void {
if (alpha <= 0.001 || s <= 0.001) { return; }
const cx = PANEL.x + PANEL.w / 2;
const cy = PANEL.y + PANEL.h / 2;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(cx, cy);
ctx.scale(s, s);
// 그림자 값은 변환을 따라가지 않는 화소 단위라 배율(dpr)을 직접 곱한다
ctx.shadowColor = rgba(SCRIM, clamp(0.16 + 0.008 * lift, 0, 0.5));
ctx.shadowBlur = (4 + lift * 1.3) * dpr;
ctx.shadowOffsetY = (2 + lift * 0.55) * dpr;
ctx.drawImage(sp, -PANEL.w / 2, -PANEL.h / 2, PANEL.w, PANEL.h);
ctx.restore();
}
// 열고 닫는 흐름 — closed → opening → open → closing → closed. 효과마다 itemStep 이 「다 끝났다」 를 알려 준다
let phase: Phase = 'closed';
let phaseAge = 0;
function setPhase(p: Phase): void {
phase = p;
phaseAge = 0;
root.classList.toggle('md--open', p === 'open');
}
function shown(): boolean { return phase === 'opening' || phase === 'open'; }
// 대화상자 층 보이기 — 창이 떠 있는 동안 여는 버튼은 inert 로 막아 Tab·클릭이 창 뒤로 새지 않게 한다
function showDialog(v: boolean): void {
dialog.hidden = !v;
if (v) { openBtn.setAttribute('inert', ''); } else { openBtn.removeAttribute('inert'); }
}
function snapTo(open: boolean): void {
itemSnap(open);
showDialog(open);
setPhase(open ? 'open' : 'closed');
}
function requestOpen(user: boolean): void {
if (shown()) { return; }
showDialog(true);
setPhase('opening');
itemOpen();
// 동작 줄이기 — 움직임 없이 그 자리에서 다 열린 창을 그린다
if (still) { snapTo(true); }
if (user) { (FIRST_FOCUS === 'close' ? closeBtn : confirmBtn).focus({ preventScroll: true }); }
}
function requestClose(user: boolean, via: string): void {
if (!shown()) { return; }
const hadFocus = dialog.contains(document.activeElement);
showDialog(false);
setPhase('closing');
itemClose(via);
if (still) { snapTo(false); }
// 닫으면 초점은 창을 열었던 버튼으로 돌아간다
if (user || hadFocus) { openBtn.focus({ preventScroll: true }); }
}
const TXT_CLOSED = '창이 닫혔어요 — 버튼으로 다시 열기';
const TXT_OPENING = '창이 열리는 중';
const TXT_OPEN = '창이 열렸어요 — Esc 로 닫기';
const TXT_CLOSING = '창이 닫히는 중';
function stateText(): string {
const s = itemStatus();
if (s !== '') { return s; }
if (phase === 'open') { return TXT_OPEN; }
if (phase === 'opening') { return TXT_OPENING; }
if (phase === 'closing') { return TXT_CLOSING; }
return TXT_CLOSED;
}
function render(): void {
drawPage();
itemDraw();
say(stateText());
}
function tick(): void {
const done = itemStep(FIXED_DT);
phaseAge += FIXED_DT;
if (phase === 'opening' && done) { setPhase('open'); }
else if (phase === 'closing' && done) { setPhase('closed'); }
}
// 시연 — DEMO_LOOP_MS 마다 같은 시간표로 연다(DEMO_OPEN_AT) → 닫는다(DEMO_CLOSE_AT, DEMO_CLOSE_VIA 버튼으로)
let demoCycle = -1;
let demoOpened = false;
let demoClosed = false;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP_MS);
if (cyc !== demoCycle) {
demoCycle = cyc;
demoOpened = false;
demoClosed = false;
if (phase !== 'closed') { snapTo(false); }
}
const lt = t - cyc * DEMO_LOOP_MS;
if (!demoOpened && lt >= DEMO_OPEN_AT) {
demoOpened = true;
requestOpen(false);
}
if (!demoClosed && lt >= DEMO_CLOSE_AT) {
demoClosed = true;
if (DEMO_CLOSE_VIA !== 'confirm' || !itemConfirm()) { requestClose(false, DEMO_CLOSE_VIA); }
}
}
// 화면 좌표(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];
}
// 창을 가로로 자른 줄 수 — 줄마다 다른 빠르기로 좁아진다
const FUNNEL_ROWS = 30;
// 담기로 닫을 때 빨려 드는 점 — 무대 폭에 대한 비율(머리 띠의 장바구니 알약 가운데)
const FUNNEL_TARGET_X = 0.86;
// 닫히는 시간(ms)
const FUNNEL_DURATION_MS = 900;
// 빨려 드는 점의 높이(px)
const FUNNEL_TARGET_Y = 21;
// 목표점에서 가장 먼 줄이 출발을 늦추는 몫 — 진행률 단위
const FUNNEL_LAG = 0.55;
// 열 때 여는 버튼에서 거꾸로 펼쳐지는 시간(ms)
const FUNNEL_OPEN_MS = 560;
const ACCENT: RGB = [246, 214, 150];
const CART_FILL: RGB = [255, 226, 170];
const FIRST_FOCUS: string = 'confirm';
const DEMO_LOOP_MS = 2400;
const DEMO_OPEN_AT = 200;
const DEMO_CLOSE_AT = 1150;
const DEMO_CLOSE_VIA: string = 'confirm';
const TXT_TO_CART = '장바구니로 담는 중';
const TXT_ADDED = '장바구니에 담았어요 — 버튼으로 다시 열기';
const CART: Rect = { x: FUNNEL_TARGET_X * W - 26, y: FUNNEL_TARGET_Y - 11, w: 52, h: 22 };
// suck — 0 이면 창이 다 펼쳐짐, 1 이면 점 하나로 다 빨려 듦
let suck = 1;
let suckTarget = 1;
let pointX = OPENER.x + OPENER.w / 2;
let pointY = OPENER.y + OPENER.h / 2;
let toCart = false;
let added = false;
let cartCount = 2;
const cartBump: Spring = { x: 0, v: 0 };
function setPoint(cart: boolean): void {
toCart = cart;
pointX = cart ? FUNNEL_TARGET_X * W : OPENER.x + OPENER.w / 2;
pointY = cart ? FUNNEL_TARGET_Y : OPENER.y + OPENER.h / 2;
}
function itemOpen(): void {
// 다 닫혀 있었으면 여는 버튼에서 펼친다. 장바구니로 빨려 들던 중이면 그 자리에서 거꾸로 돌아 나온다
if (suck >= 1) { setPoint(false); }
suckTarget = 0;
added = false;
}
function itemClose(via: string): void {
// 담기 버튼으로 닫으면 장바구니로, 그 밖의 닫기는 여는 버튼으로 빨려 든다
setPoint(via === 'confirm');
suckTarget = 1;
}
function itemSnap(open: boolean): void {
suckTarget = open ? 0 : 1;
suck = suckTarget;
setPoint(false);
}
function itemStep(dt: number): boolean {
suck = approach(suck, suckTarget, dt, suckTarget > suck ? FUNNEL_DURATION_MS : FUNNEL_OPEN_MS);
if (suck >= 1 && toCart && !added && phase === 'closing') {
added = true;
cartCount = cartCount >= 99 ? 1 : cartCount + 1;
// 장바구니 알약이 한 번 부풀었다 돌아온다 — 속도를 걷어차 스프링으로 되돌린다
cartBump.v = 9;
}
springStep(cartBump, 0, 420, 15, dt / 1000);
return suck === suckTarget;
}
// 줄 하나의 진행률 — 목표점에서 먼 줄일수록 늦게 출발한다(거리에 비례한 지연). 늦은 만큼 남은 시간에 더 빨리 따라잡는다
function rowProgress(p: number, dn: number): number { return clamp((p - FUNNEL_LAG * dn) / (1 - FUNNEL_LAG), 0, 1); }
function itemPage(): void {
drawOpener(1);
const s = 1 + 0.4 * cartBump.x;
ctx.save();
ctx.translate(CART.x + CART.w / 2, CART.y + CART.h / 2);
ctx.scale(s, s);
fillRound(-CART.w / 2, -CART.h / 2, CART.w, CART.h, CART.h / 2, CART_FILL, 1);
ctx.fillStyle = rgba(INK, 1);
ctx.beginPath();
ctx.arc(-CART.w / 2 + 12, 0, 4, 0, TAU);
ctx.fill();
ctx.font = fontOf(700, 12);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(cartCount), 6, 0.5);
ctx.restore();
}
function itemDraw(): void {
if (suck >= 1) { return; }
const p = suck;
drawScrim(1 - smooth(p * 1.2));
const rh = PANEL.h / FUNNEL_ROWS;
const cx0 = PANEL.x + PANEL.w / 2;
const dist: number[] = [];
let dmin = 1e9;
let dmax = -1e9;
for (let i = 0; i < FUNNEL_ROWS; i += 1) {
const d = Math.hypot(cx0 - pointX, PANEL.y + (i + 0.5) * rh - pointY);
dist.push(d);
dmin = Math.min(dmin, d);
dmax = Math.max(dmax, d);
}
// 먼 줄부터 그려 목표점에 가까운 줄이 위에 온다
const order: number[] = [];
for (let i = 0; i < FUNNEL_ROWS; i += 1) { order.push(i); }
order.sort(function (a: number, b: number): number { return dist[b] - dist[a]; });
for (let n = 0; n < order.length; n += 1) {
const i = order[n];
const dn = (dist[i] - dmin) / Math.max(dmax - dmin, 1);
const q = rowProgress(p, dn);
// 폭은 빨리 좁히고(1.6배 빠르게) 자리는 늦게 옮긴다(제곱) — 먼저 가늘어진 뒤 빨려 든다
const wq = smooth(q * 1.6);
const mq = q * q;
const w = lerp(PANEL.w, 3, wq);
const cx = lerp(cx0, pointX, mq);
const cy = lerp(PANEL.y + (i + 0.5) * rh, pointY, mq);
const h = lerp(rh, rh * 0.2, mq);
ctx.globalAlpha = 1 - smooth((q - 0.85) / 0.15);
blit(spriteFull, 0, i * rh, PANEL.w, rh, cx - w / 2, cy - h / 2, w, h + 0.4);
}
ctx.globalAlpha = 1;
}
function itemConfirm(): boolean { return false; }
function itemStatus(): string {
if (phase === 'closing' && toCart) { return TXT_TO_CART; }
if (phase === 'closed' && added) { return TXT_ADDED; }
return '';
}
function after(): void { render(); }
root.addEventListener('pointerdown', function (): void { wake(); });
openBtn.addEventListener('click', function (): void { wake(); requestOpen(true); after(); });
closeBtn.addEventListener('click', function (): void { wake(); requestClose(true, 'close'); after(); });
confirmBtn.addEventListener('click', function (): void {
wake();
if (still || !itemConfirm()) { requestClose(true, 'confirm'); }
after();
});
// 창 바깥(막)을 누르면 닫는다
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
const p = toScene(ev);
if (shown() && !inside(PANEL, p[0], p[1])) {
requestClose(true, 'backdrop');
after();
}
});
root.addEventListener('keydown', function (ev: KeyboardEvent): void {
wake();
if (ev.key === 'Escape') {
if (!shown()) { return; }
ev.preventDefault();
requestClose(true, 'esc');
after();
return;
}
// 초점 가두기 — 창이 떠 있는 동안 Tab 은 창 안 두 버튼 사이만 돈다
if (ev.key === 'Tab' && shown()) {
const order: HTMLButtonElement[] = [closeBtn, confirmBtn];
const i = order.indexOf(document.activeElement as HTMLButtonElement);
const n = i < 0 ? 0 : (i + (ev.shiftKey ? order.length - 1 : 1)) % order.length;
ev.preventDefault();
order[n].focus({ preventScroll: true });
}
});
function onResize(): void {
fitCanvas();
render();
}
placeOverlays();
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 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;
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; }
snapTo(shown());
render();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
// 동작 줄이기 — 시연 대신 다 열린 창을 한 장 그려 둔다. 버튼을 누르면 그 자리에서 열리고 닫힌다
snapTo(true);
render();
} 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("md") + " is-demo"} ref={rootRef}>
<canvas className={cx("md__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<button className={cx("md__open")} data-role="open" type="button" aria-haspopup="dialog">옵션 고르기</button>
<div className={cx("md__dialog")} data-role="dialog" role="dialog" aria-modal="true" aria-labelledby="md-title-07" aria-describedby="md-body-07" hidden>
<h2 className={cx("md__title")} data-role="title" id="md-title-07">이 옵션으로 담을까요?</h2>
<p className={cx("md__body")} data-role="body" id="md-body-07">베이지, M 사이즈 한 개를 장바구니에 담아요.</p>
<button className={cx("md__btn")} data-role="close" type="button">닫기</button>
<button className={cx("md__btn")} data-role="confirm" type="button">담기</button>
</div>
<p className={cx("md__status")} data-role="status" aria-live="off">창이 닫혔어요 — 버튼으로 다시 열기</p>
</div>
);
}07 줄마다 폭을 좁혀 한 점으로 빨려 드는 닫힘 — 측정 누적면적 75.871% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 모달 창 애니메이션 9종 — 스프링·원형 마스크·화소로 열고 닫기
이 데모가 속한 글
모달 창 애니메이션 9종 — 스프링·원형 마스크·화소로 열고 닫기열 때는 공들인 모달도 닫힐 때는 대개 그냥 사라진다. 여기서 03번은 가장 먼 모서리까지 잰 원으로 창을 열고, 05번은 칸마다 다른 노이즈 문턱을 넘는 순서로 화소가 맺히며, 07번은 줄마다 폭을 좁혀 한 점으로 이 글의 데모 전부 보기


