버튼 애니메이션 효과 9종 — 전송 버튼이 접혀 날아가는 계산
전송 버튼은 눌린 뒤에도 '보냈다'를 보여 줘야 한다 — 01번은 버튼이 종이비행기로 접혀 곡선을 따라 기울며 날고, 02번은 스프링으로 눌렸다 튕기며 그림자 높이가 따라 바뀌고, 08번은 실패하면 점점 잦아드는 흔들림으로 알린다.
보내기 버튼 아홉 개를 눌러 보고 일부러 실패시켜 보는 판
01 send-btn-paper-plane-fold-flight
02 send-btn-spring-press-shadow
03 send-btn-ring-progress-check-draw
04 send-btn-trail-drag-noise
05 send-btn-envelope-flap-seal
06 send-btn-gloss-sweep-pointer
07 send-btn-label-letter-scatter
08 send-btn-fail-damped-shake
09 send-btn-bubble-arc-to-thread
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 누르면 종이비행기로 접혀 곡선을 따라 기울며 날아가는 전송 버튼
- 02 눌린 깊이를 스프링으로 풀며 그림자 높이가 함께 바뀌는 제출 버튼
- 03 원으로 줄어든 둘레를 진행 링이 돌고 체크 표시로 끝나는 버튼
- 04 날아간 자리에 공기 저항과 노이즈로 흩어지는 꼬리 입자
- 05 편지봉투로 바뀌어 덮개가 접히고 봉해지는 메일 전송 버튼
- 06 보내는 중 광택 띠가 지나가고 포인터 기울기를 따라 반사광이 옮겨 가는 버튼
- 07 글자가 하나씩 흩어졌다가 '보냄'으로 다시 모이는 버튼 글자
- 08 전송에 실패하면 점점 잦아드는 흔들림으로 알리고 다시 보내기로 바뀌는 버튼
- 09 채팅 전송 버튼에서 말풍선이 포물선을 그리며 대화창에 내려앉는 장면
판은 사람이 만지기 전까지 정해진 간격으로 스스로 눌렸다가 처음으로 돌아가기를 되풀이한다(02 는 1.3초, 01·05·06 은 3초 간격). 판을 한 번 누르거나 판에서 키를 치면(06 은 판 위에서 포인터를 움직이기만 해도) 시연이 멈추고 버튼이 처음 상태에서 기다리므로, 그때부터는 내가 누른 대로만 움직인다. 08 은 첫 시도를 일부러 실패로 처리해 두었으니 한 번 눌러 흔들림을 보고, 한 번 더 눌러 다시 보내기가 성공하는 흐름까지 따라가면 된다.
버튼 애니메이션 효과 9종 — 접힘·스프링·진행 링·꼬리·봉투·광택·글자·흔들림·말풍선
순서는 움직임이 버튼에서 얼마나 멀리 번지는가를 따랐다. 01~03 은 버튼 몸이 접히고 눌리고 원으로 줄어드는 방식이고, 04·05 는 버튼에서 비행기와 봉투가 떠나는 방식이며, 06·07 은 겉면의 빛과 글자만 다듬고, 08·09 는 입력줄과 대화창까지 함께 움직인다.
키프레임 대신 16ms 걸음으로 적분하는 이유
아홉 판의 스타일 파일에는 @keyframes 가 하나도 없고, transition 은 움직임 줄이기 블록의 transition: none !important 한 줄뿐이다. 움직임은 스크립트가 맡는다. requestAnimationFrame 이 넘겨주는 시각을 쌓아 두었다가(한 프레임에 더하는 양은 100ms 에서 자른다) 16ms 짜리 고정 걸음 FIXED_DT 를 프레임마다 최대 여섯 번 돌리고, 그 뒤에 한 번 그린다. 스프링·저항·관성 같은 식은 걸음 크기가 바뀌면 결과도 바뀌기 때문에, 주사율이 다른 화면에서도 같은 숫자가 나오도록 걸음을 고정했다.
장면은 480×300 좌표로 짜 두고 통째로 축소한다
버튼과 그 주변 장면은 480×300 px 판 위에 배치되고, fitScene() 이 상자 너비 ÷ 480 을 k 로 잡아 판 전체를 scale(k) 한다. 480×300 창에서 k 는 0.8729 로 쟀다. 포인터 좌표나 요소 위치를 잴 때는 반대로 k 로 나눠 장면 좌표로 되돌리고, 캔버스를 쓰는 01·02·04·05 는 화소 배율을 2 에서 멈춘다.
렌더러가 2초 동안 본 아홉 버튼의 움직임
480×300 창에서 2초 동안 24장을 찍어 이웃한 두 장끼리 비교했다. 비교 횟수가 23번이라 움직인 프레임은 23 을 분모로 적는다.
| 항목 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 종이비행기 접기·비행 | 12.5% | 21/23 | 38.3 |
| 02 스프링 눌림·그림자 | 9.2% | 13/23 | 50.7 |
| 03 원·진행 링·체크 | 10.9% | 20/23 | 50.4 |
| 04 저항·노이즈 꼬리 | 14.1% | 22/23 | 68.3 |
| 05 봉투 덮개·봉인 | 24.4% | 18/23 | 43.6 |
| 06 광택 띠·기울기 반사광 | 17.1% | 23/23 | 43.7 |
| 07 글자 흩기·재집결 | 4.2% | 13/23 | 87.7 |
| 08 실패 흔들림 | 6.1% | 15/23 | 55.6 |
| 09 말풍선 포물선 | 22.2% | 18/23 | 72.2 |
면적이 10% 에 못 미치는 것은 02·07·08 세 개다. 셋은 버튼 크기 안에서만 움직이는 부품이라 면적이 작게 나오는 것이 맞다. 이 사이트가 기준으로 삼는 캔버스 물방울·블랙홀 장면 이 화면 전체를 계산으로 채운다면, 버튼은 작은 면 안의 정교함으로 잰다 — 놓는 순간의 스프링 넘침, 높이에 따라 멀어지고 흐려지는 그림자, 한 박자 늦게 따라오는 관성 기울기, 버튼을 가로지르는 광택 띠, 글자마다 다르게 읽히는 노이즈 방향이 그 정교함이다.
전송 버튼은 왜 눌린 뒤에도 움직여야 하는가?
누른 버튼이 그대로 있으면 사람은 눌렸는지부터 의심하고 한 번 더 누른다. 문의 폼의 보내기, 회원가입이나 결제 확인, 댓글 등록, 채팅 입력창의 보내기는 모두 누른 뒤 결과가 오기까지 짧은 빈틈이 있고, 그 빈틈에 보여 줄 수 있는 상태는 셋이다 — 보내는 중, 보냈음, 보내지 못했음.
이 편의 아홉 개는 그 셋을 나눠 맡는다. 03 의 진행 링과 06 의 광택 띠는 보내는 중을, 01·04·05 의 날아가는 종이와 봉투, 07 의 바뀌는 글자, 09 의 대화창에 내려앉는 말풍선은 보냈음을 보여 주고, 08 은 보내지 못했음을 흔들림으로 알린 뒤 다시 보내기로 이어 준다. 02 는 결과보다 앞의 순간, 손가락이 눌렀다는 감각 자체를 맡는다.
붙일 자리를 고를 때는 화면의 무게를 본다. 결제처럼 한 번만 눌러야 하는 자리에는 02 의 짧은 반동이나 03 의 진행 표시처럼 버튼 크기를 벗어나지 않는 쪽이, 문의 접수나 초대장처럼 끝나는 순간이 곧 보상인 자리에는 01·04·05 처럼 장면이 큰 쪽이 어울린다.
01종이비행기로 접혀 곡선을 따라 기울며 날아가는 버튼
누르면 168×52 버튼 면이 네 장으로 나뉘어 240·240·300ms 세 단계로 접히는데, 두 모서리를 접는 선 둘레로 180° 넘긴 뒤 위아래 절반을 가운데 선 둘레로 ±64° 세워서 폭 52px 이 22.8px 로 좁아진 V 자 종이비행기가 된다. 그다음 1,100ms 동안 3차 베지어 곡선을 따라 깊이 320 까지 날아가며 기수를 곡선 미분의 각도에 맞춰 돌리고, 바닥 그림자는 깊이에 비례해 10px 에서 35.6px 까지 오른쪽 아래로 떨어지면서 진하기가 0.16 에서 0.064 로 옅어진다. 문의 폼처럼 '보냈다'를 한 장면으로 보여 주고 싶은 곳에 맞고, 비행하는 동안 버튼 자리에는 민트색 자리표시가 반투명으로 깔려 빈 구멍이 남지 않는다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__scene" data-role="scene">
<div class="pp__card" aria-hidden="true">
<span class="pp__line pp__line--title"></span>
<span class="pp__line"></span>
<span class="pp__line"></span>
<span class="pp__line pp__line--short"></span>
</div>
<canvas class="sb__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<button class="pp__send" data-role="send" type="button"><svg xmlns="http://www.w3.org/2000/svg" class="pp__icon" aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg><span class="pp__label" data-role="label">보내기</span></button>
<p class="sb__status" data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sb {
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;
}
// 장면 판 — 480×300 좌표로 짜 두고 스크립트가 상자 폭에 맞춰 scale 한다.
// 기준점이 왼쪽 위 모서리인 이유: 판이 상자 왼쪽 위에 붙어 있어 모서리 기준으로 줄여야 줄인 판이 상자와 겹친다
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .9);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: $sp-3;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: $r-control;
}
.sb { background: #fbf3e6; }
// 문의 카드 — 글자 대신 회색 줄로 내용 자리만 보인다
.pp__card {
position: absolute;
left: 72px;
top: 40px;
width: 336px;
height: 124px;
box-sizing: border-box;
padding: $sp-5 $sp-6;
border-radius: $r-card;
background: #fffdf8;
box-shadow: 0 8px 20px rgba(23, 20, 26, .08);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.pp__line {
display: block;
height: 8px;
border-radius: $r-pill;
background: #ece4d6;
}
.pp__line--title {
width: 40%;
height: 12px;
background: #ddd2f7;
}
.pp__line--short { width: 60%; }
// 버튼 자리 = 스크립트의 BTN_CX 156 · BTN_CY 210 (왼쪽 72 + 너비 절반 84, 위 184 + 높이 절반 26)
.pp__send {
position: absolute;
left: 72px;
top: 184px;
width: 168px;
height: 52px;
margin: 0;
padding: 0 $sp-4;
border: 0;
border-radius: $r-sm;
background: #9ed0ff;
color: $subject-ink;
font: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
cursor: pointer;
}
.pp__send.is-sent { background: #b4ecd9; }
.pp__send:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.pp__icon {
width: 20px;
height: 20px;
flex: none;
}
@media (max-width: 340px) {
// 이 폭에서는 장면이 0.6 배 남짓으로 줄어든다 — 상태 줄 글자를 키워 줄인 뒤에도 읽히게 한다
.sb__status { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임은 전부 스크립트가 그리고, 스크립트는 reduce 에서 루프를 돌리지 않고 결과 상태만 둔다. CSS 쪽 전환도 막아 둔다
.sb__scene * {
transition: none !important;
animation: none !important;
}
}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);
}
.sb {
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);
}
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: 16px;
bottom: 12px;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: 12px;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: 12px;
}
.sb {
background: #fbf3e6;
}
.pp__card {
position: absolute;
left: 72px;
top: 40px;
width: 336px;
height: 124px;
box-sizing: border-box;
padding: 20px 24px;
border-radius: 16px;
background: #fffdf8;
box-shadow: 0 8px 20px rgba(23, 20, 26, 0.08);
display: flex;
flex-direction: column;
gap: 12px;
}
.pp__line {
display: block;
height: 8px;
border-radius: 999px;
background: #ece4d6;
}
.pp__line--title {
width: 40%;
height: 12px;
background: #ddd2f7;
}
.pp__line--short {
width: 60%;
}
.pp__send {
position: absolute;
left: 72px;
top: 184px;
width: 168px;
height: 52px;
margin: 0;
padding: 0 16px;
border: 0;
border-radius: 8px;
background: #9ed0ff;
color: #17141a;
font: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
}
.pp__send.is-sent {
background: #b4ecd9;
}
.pp__send:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.pp__icon {
width: 20px;
height: 20px;
flex: none;
}
@media (max-width: 340px) {
.sb__status {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__scene * {
transition: none !important;
animation: none !important;
}
}(function () {
const root = document.getElementById("sb");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK = [38, 33, 52];
const PAPER = [255, 252, 246];
const CREAM = [255, 243, 224];
const SKY = [158, 208, 255];
const LILAC = [200, 184, 255];
const MINT = [156, 228, 204];
const PINK = [255, 182, 204];
const PEACH = [255, 214, 184];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOut(x) { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]');
const statusEl = root.querySelector('[data-role="status"]');
if (!sceneEl || !statusEl) {
return;
}
const scene = sceneEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
resetIdle();
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene() {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX, clientY) {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
const canvasEl = root.querySelector('[data-role="canvas"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
let dprNow = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
dprNow = dpr;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fitCanvas();
resizeHooks.push(fitCanvas);
// 버튼 자리(장면 px) — scss 의 .pp__send 위치·크기와 같은 값이어야 접기 시작 순간 종이가 버튼과 겹친다
const BTN_CX = 156;
const BTN_CY = 210;
// 종이 한 장 = 버튼 면 168×52. 가운데가 원점이고 u 는 기수 쪽(오른쪽), v 는 아래쪽, w 는 화면 안쪽(멀어지는 쪽)
// 면 네 장 — 0 위 모서리 삼각형 · 1 아래 모서리 삼각형 · 2 위 몸통 · 3 아래 몸통
const FACETS = [
[[84, 0], [84, -26], [58, -26]],
[[84, 0], [84, 26], [58, 26]],
[[-84, -26], [58, -26], [84, 0], [-84, 0]],
[[-84, 0], [84, 0], [58, 26], [-84, 26]],
];
// 접기 순서 — 접는 선(a 에서 b 로)을 축으로 그 선에 붙은 면의 꼭짓점을 deg 만큼 돌린다. 앞 단계가 끝난 자리 위에서 다음 단계가 돈다
const FOLD_STEPS = [
{ ms: 240, hinges: [{ a: [84, 0], b: [58, -26], deg: -180, facets: [0] }] },
{ ms: 240, hinges: [{ a: [84, 0], b: [58, 26], deg: 180, facets: [1] }] },
{ ms: 300, hinges: [{ a: [-84, 0], b: [84, 0], deg: 64, facets: [0, 2] }, { a: [-84, 0], b: [84, 0], deg: -64, facets: [1, 3] }] },
];
const FOLD_TOTAL = FOLD_STEPS.reduce(function (s, f) { return s + f.ms; }, 0);
// 3차 베지어 조종점 네 개 [x, y, 깊이] — 버튼 자리에서 출발해 오른쪽 아래로 처졌다가 위로 감아 올라 멀어진다
const FLIGHT_CTRL = [[156, 210, 0], [318, 268, 30], [352, 64, 150], [560, 26, 320]];
// 선회율(초당 기수가 도는 라디안)에 곱해 날개 기울기(라디안)를 만드는 계수
const BANK_GAIN = 0.3;
const BANK_MAX = 0.95;
const FLIGHT_MS = 1100;
const PRESS_MS = 110;
const BACK_MS = 320;
const FOCAL = 620;
const CAM_X = 240;
const CAM_Y = 150;
const DEMO_CYCLE = 3000;
const TXT_IDLE = '보내기';
const TXT_SENT = '보냈어요';
const TXT_READY = '보낼 준비 됨';
const TXT_FOLD = '종이비행기로 접는 중';
const TXT_FLY = '곡선을 따라 날아가는 중';
const TXT_DONE = '보냄 — 문의가 접수됐어요';
const sendEl = root.querySelector('[data-role="send"]');
const labelEl = root.querySelector('[data-role="label"]');
if (!sendEl || !labelEl) {
return;
}
const send = sendEl;
const label = labelEl;
let phase = 'idle';
let pt = 0;
let bank = 0;
let demoNext = 200;
// 꼭짓점 p 를 점 a 를 지나는 단위 축 d 둘레로 ang 라디안 돌린다 (로드리게스 회전)
function rotateAround(p, a, d, ang) {
const vx = p[0] - a[0];
const vy = p[1] - a[1];
const vz = p[2] - a[2];
const c = Math.cos(ang);
const s = Math.sin(ang);
const dot = d[0] * vx + d[1] * vy + d[2] * vz;
const cx = d[1] * vz - d[2] * vy;
const cy = d[2] * vx - d[0] * vz;
const cz = d[0] * vy - d[1] * vx;
return [
a[0] + vx * c + cx * s + d[0] * dot * (1 - c),
a[1] + vy * c + cy * s + d[1] * dot * (1 - c),
a[2] + vz * c + cz * s + d[2] * dot * (1 - c),
];
}
// 접기 시각 t(ms) 의 면 네 장 꼭짓점 — 단계마다 진행률을 easeInOut 으로 깎아 접는 선 둘레로 돌린다
function foldedFacets(t) {
const out = FACETS.map(function (f) {
return f.map(function (q) { return [q[0], q[1], 0]; });
});
let start = 0;
for (const st of FOLD_STEPS) {
const p = easeInOut((t - start) / st.ms);
start += st.ms;
if (p <= 0) {
continue;
}
for (const hg of st.hinges) {
const a = [hg.a[0], hg.a[1], 0];
const len = Math.hypot(hg.b[0] - hg.a[0], hg.b[1] - hg.a[1]);
const d = [(hg.b[0] - hg.a[0]) / len, (hg.b[1] - hg.a[1]) / len, 0];
const ang = hg.deg * p * Math.PI / 180;
for (const fi of hg.facets) {
out[fi] = out[fi].map(function (q) { return rotateAround(q, a, d, ang); });
}
}
}
return out;
}
function bezier(t) {
const u = 1 - t;
const b0 = u * u * u;
const b1 = 3 * u * u * t;
const b2 = 3 * u * t * t;
const b3 = t * t * t;
const c = FLIGHT_CTRL;
return [
b0 * c[0][0] + b1 * c[1][0] + b2 * c[2][0] + b3 * c[3][0],
b0 * c[0][1] + b1 * c[1][1] + b2 * c[2][1] + b3 * c[3][1],
b0 * c[0][2] + b1 * c[1][2] + b2 * c[2][2] + b3 * c[3][2],
];
}
// 접선 = 베지어를 t 로 미분한 것. 화면 평면 성분의 각도가 기수 방향이다
function headingAt(t) {
const u = 1 - t;
const c = FLIGHT_CTRL;
const dx = 3 * u * u * (c[1][0] - c[0][0]) + 6 * u * t * (c[2][0] - c[1][0]) + 3 * t * t * (c[3][0] - c[2][0]);
const dy = 3 * u * u * (c[1][1] - c[0][1]) + 6 * u * t * (c[2][1] - c[1][1]) + 3 * t * t * (c[3][1] - c[2][1]);
return Math.atan2(dy, dx);
}
// 비행 진행률 → 곡선 매개변수. 출발은 느리고 갈수록 빨라진다
function flightParam(p) { return Math.pow(clamp(p, 0, 1), 1.45); }
// 목표 기울기 = 선회율 × BANK_GAIN. 선회율은 앞뒤 1% 지점의 기수 각도 차를 그 사이 시간으로 나눈 값
function bankTarget(p) {
const p0 = clamp(p - 0.01, 0, 1);
const p1 = clamp(p + 0.01, 0, 1);
let dh = headingAt(flightParam(p1)) - headingAt(flightParam(p0));
if (dh > Math.PI) {
dh -= Math.PI * 2;
}
if (dh < -Math.PI) {
dh += Math.PI * 2;
}
const rate = dh / ((p1 - p0) * FLIGHT_MS / 1000);
return clamp(rate * BANK_GAIN, -BANK_MAX, BANK_MAX);
}
// 원근 투영 — 깊이 z 만큼 멀어지면 화면 가운데 쪽으로 FOCAL/(FOCAL+z) 배 당겨진다
function project(p) {
const s = FOCAL / (FOCAL + p[2]);
return [CAM_X + (p[0] - CAM_X) * s, CAM_Y + (p[1] - CAM_Y) * s];
}
// 접힌 면을 날개 기울기(u 축 둘레) → 기수 회전(화면 평면) → 자리 이동 순서로 옮긴다
function place(q, pos, heading, roll, scale) {
const u = q[0] * scale;
const v = q[1] * scale;
const w = q[2] * scale;
const v2 = v * Math.cos(roll) - w * Math.sin(roll);
const w2 = v * Math.sin(roll) + w * Math.cos(roll);
const ch = Math.cos(heading);
const sh = Math.sin(heading);
return [pos[0] + u * ch - v2 * sh, pos[1] + u * sh + v2 * ch, pos[2] + w2];
}
const LIGHT = [-0.35, -0.55, -0.76];
// 바닥 그림자 — 같은 면을 깊이 z 에 비례해 오른쪽 아래로 밀어(10 + 0.08z) 옅게 칠한다. 멀어질수록 그림자가 떨어지고 옅어진다
function drawShadow(facets, pos, heading, roll, scale) {
const off = 10 + pos[2] * 0.08;
ctx.fillStyle = rgba(INK, clamp(0.16 - pos[2] * 0.0003, 0.05, 0.16));
for (const f of facets) {
ctx.beginPath();
f.forEach(function (q, j) {
const s = project(place(q, pos, heading, roll, scale));
if (j === 0) {
ctx.moveTo(s[0] + off, s[1] + off * 1.4);
}
else {
ctx.lineTo(s[0] + off, s[1] + off * 1.4);
}
});
ctx.closePath();
ctx.fill();
}
}
function drawPlane(facets, pos, heading, roll, scale) {
drawShadow(facets, pos, heading, roll, scale);
const items = facets.map(function (f, i) {
const pts = f.map(function (q) { return place(q, pos, heading, roll, scale); });
let z = 0;
for (const p of pts) {
z += p[2];
}
// 접혀 올라온 모서리 두 장은 몸통과 같은 평면에 눕는다 — 0.8 만큼 앞으로 쳐서 늘 몸통 위에 그린다
return { pts: pts, key: z / pts.length - (i < 2 ? 0.8 : 0), i: i };
});
// 화가 알고리즘 — 먼 면부터 칠해 가까운 면이 덮게 한다
items.sort(function (a, b) { return b.key - a.key; });
for (const it of items) {
const p = it.pts;
const ax = p[1][0] - p[0][0], ay = p[1][1] - p[0][1], az = p[1][2] - p[0][2];
const bx = p[2][0] - p[0][0], by = p[2][1] - p[0][1], bz = p[2][2] - p[0][2];
let nx = ay * bz - az * by;
let ny = az * bx - ax * bz;
let nz = ax * by - ay * bx;
const nl = Math.hypot(nx, ny, nz) || 1;
nx /= nl;
ny /= nl;
nz /= nl;
const ll = Math.hypot(LIGHT[0], LIGHT[1], LIGHT[2]);
const lit = Math.abs(nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2]) / ll;
const back = nz > 0;
const base = back ? mix(SKY, PAPER, 0.55) : SKY;
const shade = mix(mix(base, INK, 0.28), base, 0.35 + 0.65 * lit);
ctx.beginPath();
const s0 = project(p[0]);
ctx.moveTo(s0[0], s0[1]);
for (let j = 1; j < p.length; j += 1) {
const s = project(p[j]);
ctx.lineTo(s[0], s[1]);
}
ctx.closePath();
ctx.fillStyle = rgba(shade, 1);
ctx.fill();
ctx.strokeStyle = rgba(INK, 0.22);
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
ctx.stroke();
}
}
function drawTrail(tNow) {
for (let t = 0; t < tNow; t += 0.018) {
const p = project(bezier(t));
const fade = 1 - (tNow - t) / Math.max(tNow, 0.001);
ctx.fillStyle = rgba(INK, 0.08 + 0.22 * fade);
ctx.beginPath();
ctx.arc(p[0], p[1], 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
// 자리표시 — 버튼 자리(72, 184, 168×52)를 보냄 색(scss 의 #b4ecd9)으로 옅게
const SENT = [180, 236, 217];
const GHOST_ALPHA = 0.5;
function drawGhost(a) {
if (a <= 0.01) {
return;
}
ctx.fillStyle = rgba(SENT, a);
ctx.beginPath();
ctx.roundRect(BTN_CX - 84, BTN_CY - 26, 168, 52, 8);
ctx.fill();
}
function setButton(scale, alpha, labelAlpha) {
send.style.transform = scale === 1 ? '' : 'scale(' + scale.toFixed(3) + ')';
send.style.opacity = alpha === 1 ? '' : alpha.toFixed(3);
label.style.opacity = labelAlpha === 1 ? '' : labelAlpha.toFixed(3);
}
function resetIdle() {
phase = 'idle';
pt = 0;
bank = 0;
label.textContent = TXT_IDLE;
send.classList.remove('is-sent');
setButton(1, 1, 1);
say(TXT_READY);
}
function finish() {
phase = 'done';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
setButton(1, 1, 1);
say(TXT_DONE);
}
function settle() { finish(); }
function press() {
if (phase !== 'idle' && phase !== 'done') {
return;
}
if (still) {
finish();
return;
}
if (phase === 'done') {
resetIdle();
}
phase = 'press';
pt = 0;
say(TXT_FOLD);
}
function step() {
if (phase === 'idle' || phase === 'done') {
return;
}
pt += FIXED_DT;
if (phase === 'press' && pt >= PRESS_MS) {
phase = 'fold';
pt = 0;
}
else if (phase === 'fold' && pt >= FOLD_TOTAL) {
phase = 'fly';
pt = 0;
say(TXT_FLY);
}
else if (phase === 'fly') {
const p = pt / FLIGHT_MS;
// 기울기는 목표로 곧장 꺾지 않고 프레임마다 25% 씩 다가간다 — 날개가 바람을 타듯 넘어간다
bank += (bankTarget(p) - bank) * 0.25;
if (pt >= FLIGHT_MS) {
phase = 'back';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
}
}
else if (phase === 'back' && pt >= BACK_MS) {
finish();
}
}
function demoTick(t) {
if (t < demoNext) {
return;
}
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw() {
ctx.clearRect(0, 0, W, H);
if (phase === 'press') {
const p = pt / PRESS_MS;
setButton(1 - 0.06 * Math.sin(p * Math.PI), 1, 1 - p);
}
else if (phase === 'fold') {
setButton(1, 0, 0);
const h0 = headingAt(0) * easeInOut(pt / FOLD_TOTAL);
drawPlane(foldedFacets(pt), [BTN_CX, BTN_CY, 0], h0, 0, 1);
}
else if (phase === 'fly') {
setButton(1, 0, 0);
const p = clamp(pt / FLIGHT_MS, 0, 1);
const t = flightParam(p);
// 비운 자리는 날기 시작과 함께 보냄 색 자리표시로 채운다 — 어느 프레임에서도 버튼 자리가 빈 구멍이 아니다
drawGhost(GHOST_ALPHA * easeOut(p / 0.3));
drawTrail(t);
drawPlane(foldedFacets(FOLD_TOTAL), bezier(t), headingAt(t), bank, lerp(1, 0.62, easeOut(p / 0.4)));
}
else if (phase === 'back') {
const p = clamp(pt / BACK_MS, 0, 1);
drawGhost(GHOST_ALPHA * (1 - p));
// 되돌아오는 버튼은 0.9 에서 시작해 1 을 살짝 넘었다 돌아온다 (뒤로 당기는 이징)
const c = 1.7;
const e = 1 + (c + 1) * Math.pow(p - 1, 3) + c * Math.pow(p - 1, 2);
setButton(lerp(0.9, 1, e), lerp(GHOST_ALPHA, 1, p), 1);
}
}
send.addEventListener('click', function () { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize() {
fitScene();
for (const f of resizeHooks) {
f();
}
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// SendPaperPlaneFoldFlight - vanilla 판(01-send-btn-paper-plane-fold-flight) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SendPaperPlaneFoldFlight.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SendPaperPlaneFoldFlight() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK: RGB = [38, 33, 52];
const PAPER: RGB = [255, 252, 246];
const CREAM: RGB = [255, 243, 224];
const SKY: RGB = [158, 208, 255];
const LILAC: RGB = [200, 184, 255];
const MINT: RGB = [156, 228, 204];
const PINK: RGB = [255, 182, 204];
const PEACH: RGB = [255, 214, 184];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function easeOut(x: number): number { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
if (!sceneEl || !statusEl) { return; }
const scene: HTMLElement = sceneEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks: Array<() => void> = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
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');
resetIdle();
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene(): void {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX: number, clientY: number): [number, number] {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
let dprNow = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
dprNow = dpr;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fitCanvas();
resizeHooks.push(fitCanvas);
type V3 = [number, number, number];
type Hinge = { a: [number, number]; b: [number, number]; deg: number; facets: number[] };
type FoldStep = { ms: number; hinges: Hinge[] };
type Phase = 'idle' | 'press' | 'fold' | 'fly' | 'back' | 'done';
// 버튼 자리(장면 px) — scss 의 .pp__send 위치·크기와 같은 값이어야 접기 시작 순간 종이가 버튼과 겹친다
const BTN_CX = 156;
const BTN_CY = 210;
// 종이 한 장 = 버튼 면 168×52. 가운데가 원점이고 u 는 기수 쪽(오른쪽), v 는 아래쪽, w 는 화면 안쪽(멀어지는 쪽)
// 면 네 장 — 0 위 모서리 삼각형 · 1 아래 모서리 삼각형 · 2 위 몸통 · 3 아래 몸통
const FACETS: Array<Array<[number, number]>> = [
[[84, 0], [84, -26], [58, -26]],
[[84, 0], [84, 26], [58, 26]],
[[-84, -26], [58, -26], [84, 0], [-84, 0]],
[[-84, 0], [84, 0], [58, 26], [-84, 26]],
];
// 접기 순서 — 접는 선(a 에서 b 로)을 축으로 그 선에 붙은 면의 꼭짓점을 deg 만큼 돌린다. 앞 단계가 끝난 자리 위에서 다음 단계가 돈다
const FOLD_STEPS: FoldStep[] = [
{ ms: 240, hinges: [{ a: [84, 0], b: [58, -26], deg: -180, facets: [0] }] },
{ ms: 240, hinges: [{ a: [84, 0], b: [58, 26], deg: 180, facets: [1] }] },
{ ms: 300, hinges: [{ a: [-84, 0], b: [84, 0], deg: 64, facets: [0, 2] }, { a: [-84, 0], b: [84, 0], deg: -64, facets: [1, 3] }] },
];
const FOLD_TOTAL = FOLD_STEPS.reduce(function (s: number, f: FoldStep): number { return s + f.ms; }, 0);
// 3차 베지어 조종점 네 개 [x, y, 깊이] — 버튼 자리에서 출발해 오른쪽 아래로 처졌다가 위로 감아 올라 멀어진다
const FLIGHT_CTRL: V3[] = [[156, 210, 0], [318, 268, 30], [352, 64, 150], [560, 26, 320]];
// 선회율(초당 기수가 도는 라디안)에 곱해 날개 기울기(라디안)를 만드는 계수
const BANK_GAIN = 0.3;
const BANK_MAX = 0.95;
const FLIGHT_MS = 1100;
const PRESS_MS = 110;
const BACK_MS = 320;
const FOCAL = 620;
const CAM_X = 240;
const CAM_Y = 150;
const DEMO_CYCLE = 3000;
const TXT_IDLE = '보내기';
const TXT_SENT = '보냈어요';
const TXT_READY = '보낼 준비 됨';
const TXT_FOLD = '종이비행기로 접는 중';
const TXT_FLY = '곡선을 따라 날아가는 중';
const TXT_DONE = '보냄 — 문의가 접수됐어요';
const sendEl = root.querySelector('[data-role="send"]') as HTMLButtonElement | null;
const labelEl = root.querySelector('[data-role="label"]') as HTMLElement | null;
if (!sendEl || !labelEl) { return; }
const send: HTMLButtonElement = sendEl;
const label: HTMLElement = labelEl;
let phase: Phase = 'idle';
let pt = 0;
let bank = 0;
let demoNext = 200;
// 꼭짓점 p 를 점 a 를 지나는 단위 축 d 둘레로 ang 라디안 돌린다 (로드리게스 회전)
function rotateAround(p: V3, a: V3, d: V3, ang: number): V3 {
const vx = p[0] - a[0];
const vy = p[1] - a[1];
const vz = p[2] - a[2];
const c = Math.cos(ang);
const s = Math.sin(ang);
const dot = d[0] * vx + d[1] * vy + d[2] * vz;
const cx = d[1] * vz - d[2] * vy;
const cy = d[2] * vx - d[0] * vz;
const cz = d[0] * vy - d[1] * vx;
return [
a[0] + vx * c + cx * s + d[0] * dot * (1 - c),
a[1] + vy * c + cy * s + d[1] * dot * (1 - c),
a[2] + vz * c + cz * s + d[2] * dot * (1 - c),
];
}
// 접기 시각 t(ms) 의 면 네 장 꼭짓점 — 단계마다 진행률을 easeInOut 으로 깎아 접는 선 둘레로 돌린다
function foldedFacets(t: number): V3[][] {
const out: V3[][] = FACETS.map(function (f: Array<[number, number]>): V3[] {
return f.map(function (q: [number, number]): V3 { return [q[0], q[1], 0]; });
});
let start = 0;
for (const st of FOLD_STEPS) {
const p = easeInOut((t - start) / st.ms);
start += st.ms;
if (p <= 0) { continue; }
for (const hg of st.hinges) {
const a: V3 = [hg.a[0], hg.a[1], 0];
const len = Math.hypot(hg.b[0] - hg.a[0], hg.b[1] - hg.a[1]);
const d: V3 = [(hg.b[0] - hg.a[0]) / len, (hg.b[1] - hg.a[1]) / len, 0];
const ang = hg.deg * p * Math.PI / 180;
for (const fi of hg.facets) {
out[fi] = out[fi].map(function (q: V3): V3 { return rotateAround(q, a, d, ang); });
}
}
}
return out;
}
function bezier(t: number): V3 {
const u = 1 - t;
const b0 = u * u * u;
const b1 = 3 * u * u * t;
const b2 = 3 * u * t * t;
const b3 = t * t * t;
const c = FLIGHT_CTRL;
return [
b0 * c[0][0] + b1 * c[1][0] + b2 * c[2][0] + b3 * c[3][0],
b0 * c[0][1] + b1 * c[1][1] + b2 * c[2][1] + b3 * c[3][1],
b0 * c[0][2] + b1 * c[1][2] + b2 * c[2][2] + b3 * c[3][2],
];
}
// 접선 = 베지어를 t 로 미분한 것. 화면 평면 성분의 각도가 기수 방향이다
function headingAt(t: number): number {
const u = 1 - t;
const c = FLIGHT_CTRL;
const dx = 3 * u * u * (c[1][0] - c[0][0]) + 6 * u * t * (c[2][0] - c[1][0]) + 3 * t * t * (c[3][0] - c[2][0]);
const dy = 3 * u * u * (c[1][1] - c[0][1]) + 6 * u * t * (c[2][1] - c[1][1]) + 3 * t * t * (c[3][1] - c[2][1]);
return Math.atan2(dy, dx);
}
// 비행 진행률 → 곡선 매개변수. 출발은 느리고 갈수록 빨라진다
function flightParam(p: number): number { return Math.pow(clamp(p, 0, 1), 1.45); }
// 목표 기울기 = 선회율 × BANK_GAIN. 선회율은 앞뒤 1% 지점의 기수 각도 차를 그 사이 시간으로 나눈 값
function bankTarget(p: number): number {
const p0 = clamp(p - 0.01, 0, 1);
const p1 = clamp(p + 0.01, 0, 1);
let dh = headingAt(flightParam(p1)) - headingAt(flightParam(p0));
if (dh > Math.PI) { dh -= Math.PI * 2; }
if (dh < -Math.PI) { dh += Math.PI * 2; }
const rate = dh / ((p1 - p0) * FLIGHT_MS / 1000);
return clamp(rate * BANK_GAIN, -BANK_MAX, BANK_MAX);
}
// 원근 투영 — 깊이 z 만큼 멀어지면 화면 가운데 쪽으로 FOCAL/(FOCAL+z) 배 당겨진다
function project(p: V3): [number, number] {
const s = FOCAL / (FOCAL + p[2]);
return [CAM_X + (p[0] - CAM_X) * s, CAM_Y + (p[1] - CAM_Y) * s];
}
// 접힌 면을 날개 기울기(u 축 둘레) → 기수 회전(화면 평면) → 자리 이동 순서로 옮긴다
function place(q: V3, pos: V3, heading: number, roll: number, scale: number): V3 {
const u = q[0] * scale;
const v = q[1] * scale;
const w = q[2] * scale;
const v2 = v * Math.cos(roll) - w * Math.sin(roll);
const w2 = v * Math.sin(roll) + w * Math.cos(roll);
const ch = Math.cos(heading);
const sh = Math.sin(heading);
return [pos[0] + u * ch - v2 * sh, pos[1] + u * sh + v2 * ch, pos[2] + w2];
}
const LIGHT: V3 = [-0.35, -0.55, -0.76];
// 바닥 그림자 — 같은 면을 깊이 z 에 비례해 오른쪽 아래로 밀어(10 + 0.08z) 옅게 칠한다. 멀어질수록 그림자가 떨어지고 옅어진다
function drawShadow(facets: V3[][], pos: V3, heading: number, roll: number, scale: number): void {
const off = 10 + pos[2] * 0.08;
ctx.fillStyle = rgba(INK, clamp(0.16 - pos[2] * 0.0003, 0.05, 0.16));
for (const f of facets) {
ctx.beginPath();
f.forEach(function (q: V3, j: number): void {
const s = project(place(q, pos, heading, roll, scale));
if (j === 0) { ctx.moveTo(s[0] + off, s[1] + off * 1.4); } else { ctx.lineTo(s[0] + off, s[1] + off * 1.4); }
});
ctx.closePath();
ctx.fill();
}
}
function drawPlane(facets: V3[][], pos: V3, heading: number, roll: number, scale: number): void {
drawShadow(facets, pos, heading, roll, scale);
const items = facets.map(function (f: V3[], i: number): { pts: V3[]; key: number; i: number } {
const pts = f.map(function (q: V3): V3 { return place(q, pos, heading, roll, scale); });
let z = 0;
for (const p of pts) { z += p[2]; }
// 접혀 올라온 모서리 두 장은 몸통과 같은 평면에 눕는다 — 0.8 만큼 앞으로 쳐서 늘 몸통 위에 그린다
return { pts: pts, key: z / pts.length - (i < 2 ? 0.8 : 0), i: i };
});
// 화가 알고리즘 — 먼 면부터 칠해 가까운 면이 덮게 한다
items.sort(function (a, b): number { return b.key - a.key; });
for (const it of items) {
const p = it.pts;
const ax = p[1][0] - p[0][0], ay = p[1][1] - p[0][1], az = p[1][2] - p[0][2];
const bx = p[2][0] - p[0][0], by = p[2][1] - p[0][1], bz = p[2][2] - p[0][2];
let nx = ay * bz - az * by;
let ny = az * bx - ax * bz;
let nz = ax * by - ay * bx;
const nl = Math.hypot(nx, ny, nz) || 1;
nx /= nl; ny /= nl; nz /= nl;
const ll = Math.hypot(LIGHT[0], LIGHT[1], LIGHT[2]);
const lit = Math.abs(nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2]) / ll;
const back = nz > 0;
const base = back ? mix(SKY, PAPER, 0.55) : SKY;
const shade = mix(mix(base, INK, 0.28), base, 0.35 + 0.65 * lit);
ctx.beginPath();
const s0 = project(p[0]);
ctx.moveTo(s0[0], s0[1]);
for (let j = 1; j < p.length; j += 1) {
const s = project(p[j]);
ctx.lineTo(s[0], s[1]);
}
ctx.closePath();
ctx.fillStyle = rgba(shade, 1);
ctx.fill();
ctx.strokeStyle = rgba(INK, 0.22);
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
ctx.stroke();
}
}
function drawTrail(tNow: number): void {
for (let t = 0; t < tNow; t += 0.018) {
const p = project(bezier(t));
const fade = 1 - (tNow - t) / Math.max(tNow, 0.001);
ctx.fillStyle = rgba(INK, 0.08 + 0.22 * fade);
ctx.beginPath();
ctx.arc(p[0], p[1], 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
// 자리표시 — 버튼 자리(72, 184, 168×52)를 보냄 색(scss 의 #b4ecd9)으로 옅게
const SENT: RGB = [180, 236, 217];
const GHOST_ALPHA = 0.5;
function drawGhost(a: number): void {
if (a <= 0.01) { return; }
ctx.fillStyle = rgba(SENT, a);
ctx.beginPath();
ctx.roundRect(BTN_CX - 84, BTN_CY - 26, 168, 52, 8);
ctx.fill();
}
function setButton(scale: number, alpha: number, labelAlpha: number): void {
send.style.transform = scale === 1 ? '' : 'scale(' + scale.toFixed(3) + ')';
send.style.opacity = alpha === 1 ? '' : alpha.toFixed(3);
label.style.opacity = labelAlpha === 1 ? '' : labelAlpha.toFixed(3);
}
function resetIdle(): void {
phase = 'idle';
pt = 0;
bank = 0;
label.textContent = TXT_IDLE;
send.classList.remove('is-sent');
setButton(1, 1, 1);
say(TXT_READY);
}
function finish(): void {
phase = 'done';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
setButton(1, 1, 1);
say(TXT_DONE);
}
function settle(): void { finish(); }
function press(): void {
if (phase !== 'idle' && phase !== 'done') { return; }
if (still) { finish(); return; }
if (phase === 'done') { resetIdle(); }
phase = 'press';
pt = 0;
say(TXT_FOLD);
}
function step(): void {
if (phase === 'idle' || phase === 'done') { return; }
pt += FIXED_DT;
if (phase === 'press' && pt >= PRESS_MS) { phase = 'fold'; pt = 0; }
else if (phase === 'fold' && pt >= FOLD_TOTAL) { phase = 'fly'; pt = 0; say(TXT_FLY); }
else if (phase === 'fly') {
const p = pt / FLIGHT_MS;
// 기울기는 목표로 곧장 꺾지 않고 프레임마다 25% 씩 다가간다 — 날개가 바람을 타듯 넘어간다
bank += (bankTarget(p) - bank) * 0.25;
if (pt >= FLIGHT_MS) {
phase = 'back';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
}
} else if (phase === 'back' && pt >= BACK_MS) { finish(); }
}
function demoTick(t: number): void {
if (t < demoNext) { return; }
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw(): void {
ctx.clearRect(0, 0, W, H);
if (phase === 'press') {
const p = pt / PRESS_MS;
setButton(1 - 0.06 * Math.sin(p * Math.PI), 1, 1 - p);
} else if (phase === 'fold') {
setButton(1, 0, 0);
const h0 = headingAt(0) * easeInOut(pt / FOLD_TOTAL);
drawPlane(foldedFacets(pt), [BTN_CX, BTN_CY, 0], h0, 0, 1);
} else if (phase === 'fly') {
setButton(1, 0, 0);
const p = clamp(pt / FLIGHT_MS, 0, 1);
const t = flightParam(p);
// 비운 자리는 날기 시작과 함께 보냄 색 자리표시로 채운다 — 어느 프레임에서도 버튼 자리가 빈 구멍이 아니다
drawGhost(GHOST_ALPHA * easeOut(p / 0.3));
drawTrail(t);
drawPlane(foldedFacets(FOLD_TOTAL), bezier(t), headingAt(t), bank, lerp(1, 0.62, easeOut(p / 0.4)));
} else if (phase === 'back') {
const p = clamp(pt / BACK_MS, 0, 1);
drawGhost(GHOST_ALPHA * (1 - p));
// 되돌아오는 버튼은 0.9 에서 시작해 1 을 살짝 넘었다 돌아온다 (뒤로 당기는 이징)
const c = 1.7;
const e = 1 + (c + 1) * Math.pow(p - 1, 3) + c * Math.pow(p - 1, 2);
setButton(lerp(0.9, 1, e), lerp(GHOST_ALPHA, 1, p), 1);
}
}
send.addEventListener('click', function (): void { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize(): void {
fitScene();
for (const f of resizeHooks) { f(); }
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("sb") + " is-demo"} ref={rootRef}>
<div className={cx("sb__scene")} data-role="scene">
<div className={cx("pp__card")} aria-hidden="true">
<span className={cx("pp__line pp__line--title")}></span>
<span className={cx("pp__line")}></span>
<span className={cx("pp__line")}></span>
<span className={cx("pp__line pp__line--short")}></span>
</div>
<canvas className={cx("sb__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<button className={cx("pp__send")} data-role="send" type="button"><svg xmlns="http://www.w3.org/2000/svg" className={cx("pp__icon")} aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg><span className={cx("pp__label")} data-role="label">보내기</span></button>
<p className={cx("sb__status")} data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
);
}01 · 누르면 종이비행기로 접혀 곡선을 따라 기울며 날아가는 전송 버튼 — 측정 누적면적 12.511% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function foldedFacets(t) {
const out = FACETS.map(function (f) {
return f.map(function (q) { return [q[0], q[1], 0]; });
});
let start = 0;
for (const st of FOLD_STEPS) {
const p = easeInOut((t - start) / st.ms);
start += st.ms;
if (p <= 0) {
continue;
}
for (const hg of st.hinges) {
const a = [hg.a[0], hg.a[1], 0];
const len = Math.hypot(hg.b[0] - hg.a[0], hg.b[1] - hg.a[1]);
const d = [(hg.b[0] - hg.a[0]) / len, (hg.b[1] - hg.a[1]) / len, 0];
const ang = hg.deg * p * Math.PI / 180;
for (const fi of hg.facets) {
out[fi] = out[fi].map(function (q) { return rotateAround(q, a, d, ang); });
}
}
}
return out;
}
// 바닥 그림자 — 같은 면을 깊이 z 에 비례해 오른쪽 아래로 밀어(10 + 0.08z) 옅게 칠한다. 멀어질수록 그림자가 떨어지고 옅어진다
function drawShadow(facets, pos, heading, roll, scale) {
const off = 10 + pos[2] * 0.08;
ctx.fillStyle = rgba(INK, clamp(0.16 - pos[2] * 0.0003, 0.05, 0.16));
for (const f of facets) {
ctx.beginPath();
f.forEach(function (q, j) {
const s = project(place(q, pos, heading, roll, scale));
if (j === 0) {
ctx.moveTo(s[0] + off, s[1] + off * 1.4);
}
else {
ctx.lineTo(s[0] + off, s[1] + off * 1.4);
}
});
ctx.closePath();
ctx.fill();
}
}
02눌린 깊이를 스프링으로 풀며 그림자 높이가 함께 바뀌는 확인 버튼
누르는 동안 목표 깊이를 12px 로 옮기고 강성 520·감쇠비 0.45 스프링을 16ms 마다 적분하므로, 손을 떼면 쉬는 높이를 2.04px(17.0%) 지나쳤다가 208ms 만에 ±0.5px 안으로 들어와 머문다. 그림자는 면의 높이 h 하나로 거리 2 + 0.85h·흐림 4 + 1.6h·진하기를 다시 계산해 쉴 때 15.6px·29.6px 이던 것이 끝까지 눌리면 5.4px·10.4px 로 바짝 붙고, 버튼은 속도에 비례해 세로로 눌린 만큼 가로로 넓어져 넓이를 지킨다. 회원가입·결제 확인처럼 '눌렸다'는 손맛이 중요한 자리에 맞고, 클릭이 끝나면 색은 그대로 두고 글자만 「신청 완료」 로 바꾼다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__scene" data-role="scene">
<span class="sp__field sp__field--a" aria-hidden="true"></span>
<span class="sp__field sp__field--b" aria-hidden="true"></span>
<canvas class="sb__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<button class="sp__send" data-role="send" type="button"><span class="sp__label" data-role="label">가입하기</span></button>
<p class="sb__status" data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sb {
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;
}
// 장면 판 — 480×300 좌표로 짜 두고 스크립트가 상자 폭에 맞춰 scale 한다.
// 기준점이 왼쪽 위 모서리인 이유: 판이 상자 왼쪽 위에 붙어 있어 모서리 기준으로 줄여야 줄인 판이 상자와 겹친다
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .9);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: $sp-3;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: $r-control;
}
.sb { background: #efe9ff; }
.sp__field {
position: absolute;
left: 136px;
width: 208px;
height: 28px;
border-radius: $r-sm;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, .08);
}
.sp__field--a { top: 28px; }
.sp__field--b { top: 64px; }
// 버튼 자리 = 스크립트의 BTN_CX 240 · BTN_CY 142 · 208×60. 그림자는 그 아래 캔버스가 그린다
.sp__send {
position: absolute;
left: 136px;
top: 112px;
width: 208px;
height: 60px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-control;
background: linear-gradient(180deg, #ddd2ff, #c8b8ff);
color: $subject-ink;
font: inherit;
font-weight: 700;
font-size: 17px;
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.sp__send:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
@media (max-width: 340px) {
// 이 폭에서는 장면이 0.6 배 남짓으로 줄어든다 — 상태 줄 글자를 키워 줄인 뒤에도 읽히게 한다
.sb__status { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임은 전부 스크립트가 그리고, 스크립트는 reduce 에서 루프를 돌리지 않고 결과 상태만 둔다. CSS 쪽 전환도 막아 둔다
.sb__scene * {
transition: none !important;
animation: none !important;
}
}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);
}
.sb {
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);
}
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: 16px;
bottom: 12px;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: 12px;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: 12px;
}
.sb {
background: #efe9ff;
}
.sp__field {
position: absolute;
left: 136px;
width: 208px;
height: 28px;
border-radius: 8px;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.08);
}
.sp__field--a {
top: 28px;
}
.sp__field--b {
top: 64px;
}
.sp__send {
position: absolute;
left: 136px;
top: 112px;
width: 208px;
height: 60px;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
background: linear-gradient(180deg, #ddd2ff, #c8b8ff);
color: #17141a;
font: inherit;
font-weight: 700;
font-size: 17px;
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.sp__send:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
@media (max-width: 340px) {
.sb__status {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__scene * {
transition: none !important;
animation: none !important;
}
}(function () {
const root = document.getElementById("sb");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK = [38, 33, 52];
const PAPER = [255, 252, 246];
const CREAM = [255, 243, 224];
const SKY = [158, 208, 255];
const LILAC = [200, 184, 255];
const MINT = [156, 228, 204];
const PINK = [255, 182, 204];
const PEACH = [255, 214, 184];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOut(x) { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]');
const statusEl = root.querySelector('[data-role="status"]');
if (!sceneEl || !statusEl) {
return;
}
const scene = sceneEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
resetIdle();
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene() {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX, clientY) {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
const canvasEl = root.querySelector('[data-role="canvas"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
let dprNow = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
dprNow = dpr;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fitCanvas();
resizeHooks.push(fitCanvas);
// 감쇠 스프링 한 걸음 — 질량 1, 강성 stiff, 감쇠비 zeta. 속도를 먼저 바꾸고 그 속도로 위치를 옮긴다(반암시적 오일러)
function springStep(s, stiff, zeta, dtMs) {
const dt = dtMs / 1000;
const c = 2 * zeta * Math.sqrt(stiff);
const a = -stiff * (s.x - s.target) - c * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
// 버튼 자리(장면 px) — scss 의 .sp__send 위치·크기와 같은 값
const BTN_CX = 240;
const BTN_CY = 142;
const BTN_W = 208;
const BTN_H = 60;
// 감쇠 스프링 강성(1/s²) — 고유 각진동수 √520 ≈ 22.8 rad/s
const SPRING_K = 520;
// 감쇠비 — 1 이면 넘치지 않고 멈추고, 낮출수록 더 넘치고 더 오래 출렁인다
const SPRING_DAMP = 0.45;
// 쉴 때 버튼 면이 바닥에서 떠 있는 높이(px). 그림자 거리·흐림·진하기가 이 높이에서 다시 계산된다
const SHADOW_LIFT_PX = 16;
// 누르면 목표 깊이가 이만큼(px) 내려간다
const PRESS_DEPTH = 12;
const HOLD_MS = 180;
const DEMO_CYCLE = 1300;
const TXT_IDLE = '가입하기';
const TXT_SENT = '신청 완료';
const TXT_READY = '보낼 준비 됨';
const TXT_DOWN = '눌림 — 스프링이 깊이를 따라가는 중';
const TXT_DONE = '보냄 — 가입 신청을 받았어요';
const sendEl = root.querySelector('[data-role="send"]');
const labelEl = root.querySelector('[data-role="label"]');
if (!sendEl || !labelEl) {
return;
}
const send = sendEl;
const label = labelEl;
// x = 눌린 깊이(px, 아래가 +). 목표만 바꾸고 위치는 스프링이 프레임마다 따라간다
const depth = { x: 0, v: 0, target: 0 };
let down = false;
let demoNext = 200;
let releaseAt = -1;
// 그림자 세 값 — 면의 높이 h(px)에 비례한다. 높을수록 멀리·흐리게·옅게
function shadowOffset(h) { return 2 + h * 0.85; }
function shadowBlur(h) { return 4 + h * 1.6; }
function shadowAlpha(h) { return clamp(0.42 - h * 0.012, 0.12, 0.42); }
function pressDown() {
if (down) {
return;
}
down = true;
depth.target = PRESS_DEPTH;
if (still) {
depth.x = PRESS_DEPTH;
depth.v = 0;
}
say(TXT_DOWN);
}
function release() {
if (!down) {
return;
}
down = false;
depth.target = 0;
if (still) {
depth.x = 0;
depth.v = 0;
}
}
function submit() {
label.textContent = TXT_SENT;
say(TXT_DONE);
}
function resetIdle() {
down = false;
releaseAt = -1;
depth.x = 0;
depth.v = 0;
depth.target = 0;
label.textContent = TXT_IDLE;
say(TXT_READY);
}
function settle() {
resetIdle();
submit();
}
function step() {
if (still) {
return;
}
springStep(depth, SPRING_K, SPRING_DAMP, FIXED_DT);
}
function demoTick(t) {
if (releaseAt >= 0 && t >= releaseAt) {
releaseAt = -1;
release();
submit();
}
if (t < demoNext) {
return;
}
demoNext += DEMO_CYCLE;
resetIdle();
pressDown();
releaseAt = t + HOLD_MS;
}
function draw() {
const h = SHADOW_LIFT_PX - depth.x;
// 빠르게 움직이는 동안만 세로로 살짝 늘고 줄어든다 — 가로는 넓이를 지키게 역수
const sy = 1 - clamp(depth.v * 0.00018, -0.05, 0.05);
send.style.transform = 'translateY(' + depth.x.toFixed(2) + 'px) scale(' + (1 / sy).toFixed(4) + ',' + sy.toFixed(4) + ')';
ctx.clearRect(0, 0, W, H);
const off = 1000;
ctx.save();
// 그림자만 남기는 꼼수 — 판 밖(왼쪽 1000px)에 그린 사각형의 그림자를 제자리로 옮겨 온다. shadow 값은 기기 화소 단위라 배율을 곱한다
ctx.shadowColor = rgba(INK, shadowAlpha(h));
ctx.shadowBlur = shadowBlur(h) * dprNow;
ctx.shadowOffsetX = off * dprNow;
ctx.shadowOffsetY = shadowOffset(h) * dprNow;
ctx.fillStyle = rgba(INK, 1);
const w = BTN_W - 12 + h * 0.4;
const y = BTN_CY - BTN_H / 2 + 6;
ctx.beginPath();
ctx.roundRect(BTN_CX - w / 2 - off, y, w, BTN_H - 8, 14);
ctx.fill();
// 둘째 겹 = 넓게 퍼지는 주변 그림자. 같은 높이 h 에서 거리 4 + 1.4h · 흐림 8 + 3h 로 — 높이가 바뀌면 바닥 넓은 면의 톤이 함께 바뀐다
ctx.shadowColor = rgba(INK, 0.16);
ctx.shadowBlur = (8 + h * 3) * dprNow;
ctx.shadowOffsetY = (4 + h * 1.4) * dprNow;
ctx.beginPath();
ctx.roundRect(BTN_CX - (w + 30) / 2 - off, y + 4, w + 30, BTN_H - 12, 18);
ctx.fill();
ctx.restore();
}
send.addEventListener('pointerdown', function () { pressDown(); });
send.addEventListener('pointerup', function () { release(); });
send.addEventListener('pointerleave', function () { release(); });
send.addEventListener('pointercancel', function () { release(); });
send.addEventListener('keydown', function (ev) {
if ((ev.key === ' ' || ev.key === 'Enter') && !ev.repeat) {
pressDown();
}
});
send.addEventListener('keyup', function (ev) {
if (ev.key === ' ' || ev.key === 'Enter') {
release();
}
});
send.addEventListener('click', function () { submit(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize() {
fitScene();
for (const f of resizeHooks) {
f();
}
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// SendSpringPressShadow - vanilla 판(02-send-btn-spring-press-shadow) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SendSpringPressShadow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SendSpringPressShadow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK: RGB = [38, 33, 52];
const PAPER: RGB = [255, 252, 246];
const CREAM: RGB = [255, 243, 224];
const SKY: RGB = [158, 208, 255];
const LILAC: RGB = [200, 184, 255];
const MINT: RGB = [156, 228, 204];
const PINK: RGB = [255, 182, 204];
const PEACH: RGB = [255, 214, 184];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function easeOut(x: number): number { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
if (!sceneEl || !statusEl) { return; }
const scene: HTMLElement = sceneEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks: Array<() => void> = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
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');
resetIdle();
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene(): void {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX: number, clientY: number): [number, number] {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
let dprNow = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
dprNow = dpr;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fitCanvas();
resizeHooks.push(fitCanvas);
type Spring = { x: number; v: number; target: number };
// 감쇠 스프링 한 걸음 — 질량 1, 강성 stiff, 감쇠비 zeta. 속도를 먼저 바꾸고 그 속도로 위치를 옮긴다(반암시적 오일러)
function springStep(s: Spring, stiff: number, zeta: number, dtMs: number): void {
const dt = dtMs / 1000;
const c = 2 * zeta * Math.sqrt(stiff);
const a = -stiff * (s.x - s.target) - c * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
// 버튼 자리(장면 px) — scss 의 .sp__send 위치·크기와 같은 값
const BTN_CX = 240;
const BTN_CY = 142;
const BTN_W = 208;
const BTN_H = 60;
// 감쇠 스프링 강성(1/s²) — 고유 각진동수 √520 ≈ 22.8 rad/s
const SPRING_K = 520;
// 감쇠비 — 1 이면 넘치지 않고 멈추고, 낮출수록 더 넘치고 더 오래 출렁인다
const SPRING_DAMP = 0.45;
// 쉴 때 버튼 면이 바닥에서 떠 있는 높이(px). 그림자 거리·흐림·진하기가 이 높이에서 다시 계산된다
const SHADOW_LIFT_PX = 16;
// 누르면 목표 깊이가 이만큼(px) 내려간다
const PRESS_DEPTH = 12;
const HOLD_MS = 180;
const DEMO_CYCLE = 1300;
const TXT_IDLE = '가입하기';
const TXT_SENT = '신청 완료';
const TXT_READY = '보낼 준비 됨';
const TXT_DOWN = '눌림 — 스프링이 깊이를 따라가는 중';
const TXT_DONE = '보냄 — 가입 신청을 받았어요';
const sendEl = root.querySelector('[data-role="send"]') as HTMLButtonElement | null;
const labelEl = root.querySelector('[data-role="label"]') as HTMLElement | null;
if (!sendEl || !labelEl) { return; }
const send: HTMLButtonElement = sendEl;
const label: HTMLElement = labelEl;
// x = 눌린 깊이(px, 아래가 +). 목표만 바꾸고 위치는 스프링이 프레임마다 따라간다
const depth: Spring = { x: 0, v: 0, target: 0 };
let down = false;
let demoNext = 200;
let releaseAt = -1;
// 그림자 세 값 — 면의 높이 h(px)에 비례한다. 높을수록 멀리·흐리게·옅게
function shadowOffset(h: number): number { return 2 + h * 0.85; }
function shadowBlur(h: number): number { return 4 + h * 1.6; }
function shadowAlpha(h: number): number { return clamp(0.42 - h * 0.012, 0.12, 0.42); }
function pressDown(): void {
if (down) { return; }
down = true;
depth.target = PRESS_DEPTH;
if (still) { depth.x = PRESS_DEPTH; depth.v = 0; }
say(TXT_DOWN);
}
function release(): void {
if (!down) { return; }
down = false;
depth.target = 0;
if (still) { depth.x = 0; depth.v = 0; }
}
function submit(): void {
label.textContent = TXT_SENT;
say(TXT_DONE);
}
function resetIdle(): void {
down = false;
releaseAt = -1;
depth.x = 0;
depth.v = 0;
depth.target = 0;
label.textContent = TXT_IDLE;
say(TXT_READY);
}
function settle(): void {
resetIdle();
submit();
}
function step(): void {
if (still) { return; }
springStep(depth, SPRING_K, SPRING_DAMP, FIXED_DT);
}
function demoTick(t: number): void {
if (releaseAt >= 0 && t >= releaseAt) {
releaseAt = -1;
release();
submit();
}
if (t < demoNext) { return; }
demoNext += DEMO_CYCLE;
resetIdle();
pressDown();
releaseAt = t + HOLD_MS;
}
function draw(): void {
const h = SHADOW_LIFT_PX - depth.x;
// 빠르게 움직이는 동안만 세로로 살짝 늘고 줄어든다 — 가로는 넓이를 지키게 역수
const sy = 1 - clamp(depth.v * 0.00018, -0.05, 0.05);
send.style.transform = 'translateY(' + depth.x.toFixed(2) + 'px) scale(' + (1 / sy).toFixed(4) + ',' + sy.toFixed(4) + ')';
ctx.clearRect(0, 0, W, H);
const off = 1000;
ctx.save();
// 그림자만 남기는 꼼수 — 판 밖(왼쪽 1000px)에 그린 사각형의 그림자를 제자리로 옮겨 온다. shadow 값은 기기 화소 단위라 배율을 곱한다
ctx.shadowColor = rgba(INK, shadowAlpha(h));
ctx.shadowBlur = shadowBlur(h) * dprNow;
ctx.shadowOffsetX = off * dprNow;
ctx.shadowOffsetY = shadowOffset(h) * dprNow;
ctx.fillStyle = rgba(INK, 1);
const w = BTN_W - 12 + h * 0.4;
const y = BTN_CY - BTN_H / 2 + 6;
ctx.beginPath();
ctx.roundRect(BTN_CX - w / 2 - off, y, w, BTN_H - 8, 14);
ctx.fill();
// 둘째 겹 = 넓게 퍼지는 주변 그림자. 같은 높이 h 에서 거리 4 + 1.4h · 흐림 8 + 3h 로 — 높이가 바뀌면 바닥 넓은 면의 톤이 함께 바뀐다
ctx.shadowColor = rgba(INK, 0.16);
ctx.shadowBlur = (8 + h * 3) * dprNow;
ctx.shadowOffsetY = (4 + h * 1.4) * dprNow;
ctx.beginPath();
ctx.roundRect(BTN_CX - (w + 30) / 2 - off, y + 4, w + 30, BTN_H - 12, 18);
ctx.fill();
ctx.restore();
}
send.addEventListener('pointerdown', function (): void { pressDown(); });
send.addEventListener('pointerup', function (): void { release(); });
send.addEventListener('pointerleave', function (): void { release(); });
send.addEventListener('pointercancel', function (): void { release(); });
send.addEventListener('keydown', function (ev: KeyboardEvent): void {
if ((ev.key === ' ' || ev.key === 'Enter') && !ev.repeat) { pressDown(); }
});
send.addEventListener('keyup', function (ev: KeyboardEvent): void {
if (ev.key === ' ' || ev.key === 'Enter') { release(); }
});
send.addEventListener('click', function (): void { submit(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize(): void {
fitScene();
for (const f of resizeHooks) { f(); }
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("sb") + " is-demo"} ref={rootRef}>
<div className={cx("sb__scene")} data-role="scene">
<span className={cx("sp__field sp__field--a")} aria-hidden="true"></span>
<span className={cx("sp__field sp__field--b")} aria-hidden="true"></span>
<canvas className={cx("sb__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<button className={cx("sp__send")} data-role="send" type="button"><span className={cx("sp__label")} data-role="label">가입하기</span></button>
<p className={cx("sb__status")} data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
);
}02 · 눌린 깊이를 스프링으로 풀며 그림자 높이가 함께 바뀌는 제출 버튼 — 측정 누적면적 9.168% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 감쇠 스프링 한 걸음 — 질량 1, 강성 stiff, 감쇠비 zeta. 속도를 먼저 바꾸고 그 속도로 위치를 옮긴다(반암시적 오일러)
function springStep(s, stiff, zeta, dtMs) {
const dt = dtMs / 1000;
const c = 2 * zeta * Math.sqrt(stiff);
const a = -stiff * (s.x - s.target) - c * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
// 그림자 세 값 — 면의 높이 h(px)에 비례한다. 높을수록 멀리·흐리게·옅게
function shadowOffset(h) { return 2 + h * 0.85; }
function shadowBlur(h) { return 4 + h * 1.6; }
function shadowAlpha(h) { return clamp(0.42 - h * 0.012, 0.12, 0.42); }
03원으로 줄어든 둘레를 진행 링이 돌고 체크 표시로 끝나는 버튼
누르면 192×56 알약이 360ms 동안 가운데를 지키며 56×56 원으로 줄고, 그 둘레에 반지름 36·두께 4 인 링을 두어 stroke-dashoffset 을 둘레 226.19 × (1 − 진행률) 로 줄여 가며 선을 드러낸다. 진행률은 시드 466 난수로 덩어리지게 차고 링은 남은 차이의 30% 씩 따라가며, 다 차면 160ms 동안 색을 채운 뒤 길이 42.46 인 체크를 300ms 정속으로 그어 꺾이는 점에는 99.9ms 에 닿는다. 저장이나 폼 전송을 기다리는 자리에 맞고, 첫 누름은 링 채우기 1,120ms 를 포함해 1,952ms 에 완료 상태가 된다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__scene" data-role="scene">
<span class="rg__area" aria-hidden="true"></span>
<button class="rg__send" data-role="send" type="button">
<svg class="rg__svg" viewBox="0 0 240 100" aria-hidden="true" focusable="false">
<circle class="rg__halo" data-role="halo" cx="120" cy="50" r="40" fill="#e6dfff"/>
<rect class="rg__body" data-role="body" x="24" y="22" width="192" height="56" rx="28" fill="#c8b8ff"/>
<circle class="rg__track" data-role="track" cx="120" cy="50" r="36" fill="none" stroke-width="4"/>
<circle class="rg__ring" data-role="ring" cx="120" cy="50" r="36" fill="none" stroke-width="4" stroke-linecap="round" transform="rotate(-90 120 50)"/>
<polyline class="rg__check" data-role="check" points="106,51 116,61 135,40" fill="none" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="rg__label" data-role="label">제출하기</span>
</button>
<p class="sb__status" data-role="status" aria-live="off">제출할 준비 됨</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sb {
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;
}
// 장면 판 — 480×300 좌표로 짜 두고 스크립트가 상자 폭에 맞춰 scale 한다.
// 기준점이 왼쪽 위 모서리인 이유: 판이 상자 왼쪽 위에 붙어 있어 모서리 기준으로 줄여야 줄인 판이 상자와 겹친다
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .9);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: $sp-3;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: $r-control;
}
.sb { background: #f3f6fb; }
.rg__area {
position: absolute;
left: 120px;
top: 28px;
width: 240px;
height: 64px;
border-radius: $r-sm;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, .08);
}
// 버튼 = svg 판 240×100 (링이 알약보다 크게 돌아도 판 안에 든다)
.rg__send {
position: absolute;
left: 120px;
top: 100px;
width: 240px;
height: 100px;
margin: 0;
padding: 0;
border: 0;
background: none;
color: $subject-ink;
font: inherit;
cursor: pointer;
}
.rg__svg {
position: absolute;
left: 0;
top: 0;
width: 240px;
height: 100px;
overflow: visible;
}
.rg__track { stroke: rgba(23, 20, 26, .12); }
.rg__ring { stroke: #6f63b8; }
.rg__check { stroke: $subject-ink; }
.rg__send:focus-visible { outline: none; }
.rg__send:focus-visible .rg__body {
stroke: $subject-ink;
stroke-width: 3;
}
.rg__label {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 17px;
line-height: 1;
pointer-events: none;
}
@media (max-width: 340px) {
// 이 폭에서는 장면이 0.6 배 남짓으로 줄어든다 — 상태 줄 글자를 키워 줄인 뒤에도 읽히게 한다
.sb__status { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임은 전부 스크립트가 그리고, 스크립트는 reduce 에서 루프를 돌리지 않고 결과 상태만 둔다. CSS 쪽 전환도 막아 둔다
.sb__scene * {
transition: none !important;
animation: none !important;
}
}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);
}
.sb {
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);
}
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: 16px;
bottom: 12px;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: 12px;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: 12px;
}
.sb {
background: #f3f6fb;
}
.rg__area {
position: absolute;
left: 120px;
top: 28px;
width: 240px;
height: 64px;
border-radius: 8px;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.08);
}
.rg__send {
position: absolute;
left: 120px;
top: 100px;
width: 240px;
height: 100px;
margin: 0;
padding: 0;
border: 0;
background: none;
color: #17141a;
font: inherit;
cursor: pointer;
}
.rg__svg {
position: absolute;
left: 0;
top: 0;
width: 240px;
height: 100px;
overflow: visible;
}
.rg__track {
stroke: rgba(23, 20, 26, 0.12);
}
.rg__ring {
stroke: #6f63b8;
}
.rg__check {
stroke: #17141a;
}
.rg__send:focus-visible {
outline: none;
}
.rg__send:focus-visible .rg__body {
stroke: #17141a;
stroke-width: 3;
}
.rg__label {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 17px;
line-height: 1;
pointer-events: none;
}
@media (max-width: 340px) {
.sb__status {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__scene * {
transition: none !important;
animation: none !important;
}
}(function () {
const root = document.getElementById("sb");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK = [38, 33, 52];
const PAPER = [255, 252, 246];
const CREAM = [255, 243, 224];
const SKY = [158, 208, 255];
const LILAC = [200, 184, 255];
const MINT = [156, 228, 204];
const PINK = [255, 182, 204];
const PEACH = [255, 214, 184];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOut(x) { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]');
const statusEl = root.querySelector('[data-role="status"]');
if (!sceneEl || !statusEl) {
return;
}
const scene = sceneEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
resetIdle();
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene() {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX, clientY) {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
// 알약 버튼 크기(px) — svg 안 좌표. 원으로 줄면 너비가 높이와 같아진다
const PILL_W = 192;
const PILL_H = 56;
const SVG_CX = 120;
const SVG_CY = 50;
// 알약 너비가 높이(원)까지 줄어드는 시간
const SHRINK_MS = 360;
// 진행 링 선 두께(px). 링 반지름 = 원 반지름 28 + 틈 6 + 두께의 절반
const RING_STROKE = 4;
const RING_R = PILL_H / 2 + 6 + RING_STROKE / 2;
// 둘레 길이 = 2πr. 진행률만큼만 선을 드러내려면 dasharray 를 둘레로, dashoffset 을 둘레 × (1 − 진행률)로 둔다
const RING_C = 2 * Math.PI * RING_R;
// 체크 표시를 다 긋는 시간 — 경로 길이에 정비례로 펜이 움직여 두 획의 길이 비율대로 시간이 나뉜다
const CHECK_DRAW_MS = 300;
const PROGRESS_MS = 1000;
const FILL_MS = 160;
const DEMO_CYCLE = 2400;
const TXT_IDLE = '제출하기';
const TXT_DONE = '제출 완료';
const TXT_READY = '제출할 준비 됨';
const TXT_LOAD = '원으로 줄어 진행 링을 채우는 중';
const TXT_FINISH = '제출 완료 — 접수 번호를 메일로 보냈어요';
const sendEl = root.querySelector('[data-role="send"]');
const labelEl = root.querySelector('[data-role="label"]');
const bodyEl = root.querySelector('[data-role="body"]');
const trackEl = root.querySelector('[data-role="track"]');
const ringEl = root.querySelector('[data-role="ring"]');
const checkEl = root.querySelector('[data-role="check"]');
const haloEl = root.querySelector('[data-role="halo"]');
if (!sendEl || !labelEl || !bodyEl || !trackEl || !ringEl || !checkEl || !haloEl) {
return;
}
const halo = haloEl;
const send = sendEl;
const label = labelEl;
const body = bodyEl;
const track = trackEl;
const ring = ringEl;
const check = checkEl;
// 체크 경로 길이 — 브라우저가 재 준 값(getTotalLength)을 쓰고, 못 재면 두 획 길이의 합으로
const CHECK_PTS = [[106, 51], [116, 61], [135, 40]];
function pathLength(pts) {
let s = 0;
for (let i = 1; i < pts.length; i += 1) {
s += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
}
return s;
}
const checkLen = typeof check.getTotalLength === 'function' ? check.getTotalLength() : pathLength(CHECK_PTS);
const rng = makeRng(466);
let phase = 'idle';
let pt = 0;
let progress = 0;
let shown = 0;
let demoNext = 200;
function setNum(el, name, v) { el.setAttribute(name, v.toFixed(2)); }
// 알약 너비 w 로 몸통을 다시 그린다 — 가운데를 지키려면 x 도 함께 옮긴다
function setPill(w) {
setNum(body, 'x', SVG_CX - w / 2);
setNum(body, 'width', w);
}
function setRing(p) { setNum(ring, 'stroke-dashoffset', RING_C * (1 - clamp(p, 0, 1))); }
function setCheck(p) {
setNum(check, 'stroke-dashoffset', checkLen * (1 - clamp(p, 0, 1)));
// 길이 0 인 대시에도 둥근 끝(round cap)이 점 하나로 찍힌다 — 긋기 전에는 아예 숨긴다
check.style.opacity = p <= 0 ? '0' : '';
}
function setFill(t) { body.setAttribute('fill', rgba(mix(LILAC, MINT, clamp(t, 0, 1)), 1)); }
function setRingAlpha(a) {
ring.style.opacity = a.toFixed(3);
track.style.opacity = a.toFixed(3);
}
// 후광 — 링이 보여 주는 진행률 하나로 반지름(RING_R + 4 → + 38)과 진하기를 같이 정한다. 진행이 원 밖 넓은 면까지 번진다
function setHalo(p, a) {
setNum(halo, 'r', RING_R + 4 + 34 * clamp(p, 0, 1));
halo.style.opacity = clamp(a, 0, 1).toFixed(3);
}
function resetIdle() {
phase = 'idle';
pt = 0;
progress = 0;
shown = 0;
setPill(PILL_W);
setFill(0);
setRing(0);
setRingAlpha(0);
setHalo(0, 0);
setCheck(0);
label.textContent = TXT_IDLE;
label.style.opacity = '';
say(TXT_READY);
}
function finish() {
phase = 'done';
pt = 0;
setPill(PILL_H);
setFill(1);
setRingAlpha(0);
setHalo(1, 0);
setCheck(1);
label.textContent = TXT_DONE;
label.style.opacity = '0';
say(TXT_FINISH);
}
function settle() { finish(); }
function press() {
if (phase !== 'idle' && phase !== 'done') {
return;
}
if (still) {
finish();
return;
}
resetIdle();
phase = 'shrink';
say(TXT_LOAD);
}
function step() {
if (phase === 'idle' || phase === 'done') {
return;
}
pt += FIXED_DT;
if (phase === 'shrink' && pt >= SHRINK_MS) {
phase = 'load';
pt = 0;
}
else if (phase === 'load') {
// 업로드는 고르게 차지 않는다 — 걸음마다 평균 속도의 0.35~1.65 배로 더해 덩어리지게 찬다
progress = Math.min(1, progress + (FIXED_DT / PROGRESS_MS) * (0.35 + 1.3 * rng()));
// 링은 진행률로 바로 튀지 않고 남은 차이의 30% 씩 다가간다
shown += (progress - shown) * 0.3;
if (progress >= 1 && shown > 0.995) {
shown = 1;
phase = 'fill';
pt = 0;
}
}
else if (phase === 'fill' && pt >= FILL_MS) {
phase = 'check';
pt = 0;
label.textContent = TXT_DONE;
}
else if (phase === 'check' && pt >= CHECK_DRAW_MS) {
finish();
}
}
function demoTick(t) {
if (t < demoNext) {
return;
}
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw() {
if (phase === 'shrink') {
const p = easeInOut(pt / SHRINK_MS);
setPill(lerp(PILL_W, PILL_H, p));
label.style.opacity = clamp(1 - p * 2, 0, 1).toFixed(3);
setRingAlpha(p);
setHalo(0, p * 0.9);
}
else if (phase === 'load') {
setRing(shown);
setHalo(shown, 0.9 - 0.3 * shown);
}
else if (phase === 'fill') {
const p = pt / FILL_MS;
setRing(1);
setFill(p);
setRingAlpha(1 - p);
setHalo(1, 0.6 * (1 - p));
}
else if (phase === 'check') {
setRingAlpha(0);
setHalo(1, 0);
setFill(1);
setCheck(pt / CHECK_DRAW_MS);
}
}
setNum(ring, 'r', RING_R);
setNum(track, 'r', RING_R);
setNum(ring, 'stroke-width', RING_STROKE);
setNum(track, 'stroke-width', RING_STROKE);
ring.setAttribute('stroke-dasharray', RING_C.toFixed(2));
check.setAttribute('stroke-dasharray', checkLen.toFixed(2));
send.addEventListener('click', function () { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize() {
fitScene();
for (const f of resizeHooks) {
f();
}
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// SendRingProgressCheckDraw - vanilla 판(03-send-btn-ring-progress-check-draw) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SendRingProgressCheckDraw.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SendRingProgressCheckDraw() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK: RGB = [38, 33, 52];
const PAPER: RGB = [255, 252, 246];
const CREAM: RGB = [255, 243, 224];
const SKY: RGB = [158, 208, 255];
const LILAC: RGB = [200, 184, 255];
const MINT: RGB = [156, 228, 204];
const PINK: RGB = [255, 182, 204];
const PEACH: RGB = [255, 214, 184];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function easeOut(x: number): number { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
if (!sceneEl || !statusEl) { return; }
const scene: HTMLElement = sceneEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks: Array<() => void> = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
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');
resetIdle();
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene(): void {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX: number, clientY: number): [number, number] {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
type Phase = 'idle' | 'shrink' | 'load' | 'fill' | 'check' | 'done';
// 알약 버튼 크기(px) — svg 안 좌표. 원으로 줄면 너비가 높이와 같아진다
const PILL_W = 192;
const PILL_H = 56;
const SVG_CX = 120;
const SVG_CY = 50;
// 알약 너비가 높이(원)까지 줄어드는 시간
const SHRINK_MS = 360;
// 진행 링 선 두께(px). 링 반지름 = 원 반지름 28 + 틈 6 + 두께의 절반
const RING_STROKE = 4;
const RING_R = PILL_H / 2 + 6 + RING_STROKE / 2;
// 둘레 길이 = 2πr. 진행률만큼만 선을 드러내려면 dasharray 를 둘레로, dashoffset 을 둘레 × (1 − 진행률)로 둔다
const RING_C = 2 * Math.PI * RING_R;
// 체크 표시를 다 긋는 시간 — 경로 길이에 정비례로 펜이 움직여 두 획의 길이 비율대로 시간이 나뉜다
const CHECK_DRAW_MS = 300;
const PROGRESS_MS = 1000;
const FILL_MS = 160;
const DEMO_CYCLE = 2400;
const TXT_IDLE = '제출하기';
const TXT_DONE = '제출 완료';
const TXT_READY = '제출할 준비 됨';
const TXT_LOAD = '원으로 줄어 진행 링을 채우는 중';
const TXT_FINISH = '제출 완료 — 접수 번호를 메일로 보냈어요';
const sendEl = root.querySelector('[data-role="send"]') as HTMLButtonElement | null;
const labelEl = root.querySelector('[data-role="label"]') as HTMLElement | null;
const bodyEl = root.querySelector('[data-role="body"]') as SVGRectElement | null;
const trackEl = root.querySelector('[data-role="track"]') as SVGCircleElement | null;
const ringEl = root.querySelector('[data-role="ring"]') as SVGCircleElement | null;
const checkEl = root.querySelector('[data-role="check"]') as SVGPolylineElement | null;
const haloEl = root.querySelector('[data-role="halo"]') as SVGCircleElement | null;
if (!sendEl || !labelEl || !bodyEl || !trackEl || !ringEl || !checkEl || !haloEl) { return; }
const halo: SVGCircleElement = haloEl;
const send: HTMLButtonElement = sendEl;
const label: HTMLElement = labelEl;
const body: SVGRectElement = bodyEl;
const track: SVGCircleElement = trackEl;
const ring: SVGCircleElement = ringEl;
const check: SVGPolylineElement = checkEl;
// 체크 경로 길이 — 브라우저가 재 준 값(getTotalLength)을 쓰고, 못 재면 두 획 길이의 합으로
const CHECK_PTS: Array<[number, number]> = [[106, 51], [116, 61], [135, 40]];
function pathLength(pts: Array<[number, number]>): number {
let s = 0;
for (let i = 1; i < pts.length; i += 1) { s += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); }
return s;
}
const checkLen = typeof check.getTotalLength === 'function' ? check.getTotalLength() : pathLength(CHECK_PTS);
const rng = makeRng(466);
let phase: Phase = 'idle';
let pt = 0;
let progress = 0;
let shown = 0;
let demoNext = 200;
function setNum(el: Element, name: string, v: number): void { el.setAttribute(name, v.toFixed(2)); }
// 알약 너비 w 로 몸통을 다시 그린다 — 가운데를 지키려면 x 도 함께 옮긴다
function setPill(w: number): void {
setNum(body, 'x', SVG_CX - w / 2);
setNum(body, 'width', w);
}
function setRing(p: number): void { setNum(ring, 'stroke-dashoffset', RING_C * (1 - clamp(p, 0, 1))); }
function setCheck(p: number): void {
setNum(check, 'stroke-dashoffset', checkLen * (1 - clamp(p, 0, 1)));
// 길이 0 인 대시에도 둥근 끝(round cap)이 점 하나로 찍힌다 — 긋기 전에는 아예 숨긴다
check.style.opacity = p <= 0 ? '0' : '';
}
function setFill(t: number): void { body.setAttribute('fill', rgba(mix(LILAC, MINT, clamp(t, 0, 1)), 1)); }
function setRingAlpha(a: number): void {
ring.style.opacity = a.toFixed(3);
track.style.opacity = a.toFixed(3);
}
// 후광 — 링이 보여 주는 진행률 하나로 반지름(RING_R + 4 → + 38)과 진하기를 같이 정한다. 진행이 원 밖 넓은 면까지 번진다
function setHalo(p: number, a: number): void {
setNum(halo, 'r', RING_R + 4 + 34 * clamp(p, 0, 1));
halo.style.opacity = clamp(a, 0, 1).toFixed(3);
}
function resetIdle(): void {
phase = 'idle';
pt = 0;
progress = 0;
shown = 0;
setPill(PILL_W);
setFill(0);
setRing(0);
setRingAlpha(0);
setHalo(0, 0);
setCheck(0);
label.textContent = TXT_IDLE;
label.style.opacity = '';
say(TXT_READY);
}
function finish(): void {
phase = 'done';
pt = 0;
setPill(PILL_H);
setFill(1);
setRingAlpha(0);
setHalo(1, 0);
setCheck(1);
label.textContent = TXT_DONE;
label.style.opacity = '0';
say(TXT_FINISH);
}
function settle(): void { finish(); }
function press(): void {
if (phase !== 'idle' && phase !== 'done') { return; }
if (still) { finish(); return; }
resetIdle();
phase = 'shrink';
say(TXT_LOAD);
}
function step(): void {
if (phase === 'idle' || phase === 'done') { return; }
pt += FIXED_DT;
if (phase === 'shrink' && pt >= SHRINK_MS) { phase = 'load'; pt = 0; }
else if (phase === 'load') {
// 업로드는 고르게 차지 않는다 — 걸음마다 평균 속도의 0.35~1.65 배로 더해 덩어리지게 찬다
progress = Math.min(1, progress + (FIXED_DT / PROGRESS_MS) * (0.35 + 1.3 * rng()));
// 링은 진행률로 바로 튀지 않고 남은 차이의 30% 씩 다가간다
shown += (progress - shown) * 0.3;
if (progress >= 1 && shown > 0.995) { shown = 1; phase = 'fill'; pt = 0; }
} else if (phase === 'fill' && pt >= FILL_MS) { phase = 'check'; pt = 0; label.textContent = TXT_DONE; }
else if (phase === 'check' && pt >= CHECK_DRAW_MS) { finish(); }
}
function demoTick(t: number): void {
if (t < demoNext) { return; }
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw(): void {
if (phase === 'shrink') {
const p = easeInOut(pt / SHRINK_MS);
setPill(lerp(PILL_W, PILL_H, p));
label.style.opacity = clamp(1 - p * 2, 0, 1).toFixed(3);
setRingAlpha(p);
setHalo(0, p * 0.9);
} else if (phase === 'load') {
setRing(shown);
setHalo(shown, 0.9 - 0.3 * shown);
} else if (phase === 'fill') {
const p = pt / FILL_MS;
setRing(1);
setFill(p);
setRingAlpha(1 - p);
setHalo(1, 0.6 * (1 - p));
} else if (phase === 'check') {
setRingAlpha(0);
setHalo(1, 0);
setFill(1);
setCheck(pt / CHECK_DRAW_MS);
}
}
setNum(ring, 'r', RING_R);
setNum(track, 'r', RING_R);
setNum(ring, 'stroke-width', RING_STROKE);
setNum(track, 'stroke-width', RING_STROKE);
ring.setAttribute('stroke-dasharray', RING_C.toFixed(2));
check.setAttribute('stroke-dasharray', checkLen.toFixed(2));
send.addEventListener('click', function (): void { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize(): void {
fitScene();
for (const f of resizeHooks) { f(); }
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("sb") + " is-demo"} ref={rootRef}>
<div className={cx("sb__scene")} data-role="scene">
<span className={cx("rg__area")} aria-hidden="true"></span>
<button className={cx("rg__send")} data-role="send" type="button">
<svg className={cx("rg__svg")} viewBox="0 0 240 100" aria-hidden="true" focusable="false">
<circle className={cx("rg__halo")} data-role="halo" cx="120" cy="50" r="40" fill="#e6dfff"/>
<rect className={cx("rg__body")} data-role="body" x="24" y="22" width="192" height="56" rx="28" fill="#c8b8ff"/>
<circle className={cx("rg__track")} data-role="track" cx="120" cy="50" r="36" fill="none" strokeWidth="4"/>
<circle className={cx("rg__ring")} data-role="ring" cx="120" cy="50" r="36" fill="none" strokeWidth="4" strokeLinecap="round" transform="rotate(-90 120 50)"/>
<polyline className={cx("rg__check")} data-role="check" points="106,51 116,61 135,40" fill="none" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span className={cx("rg__label")} data-role="label">제출하기</span>
</button>
<p className={cx("sb__status")} data-role="status" aria-live="off">제출할 준비 됨</p>
</div>
</div>
);
}03 · 원으로 줄어든 둘레를 진행 링이 돌고 체크 표시로 끝나는 버튼 — 측정 누적면적 10.944% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 알약 너비 w 로 몸통을 다시 그린다 — 가운데를 지키려면 x 도 함께 옮긴다
function setPill(w) {
setNum(body, 'x', SVG_CX - w / 2);
setNum(body, 'width', w);
}
function setRing(p) { setNum(ring, 'stroke-dashoffset', RING_C * (1 - clamp(p, 0, 1))); }
function setCheck(p) {
setNum(check, 'stroke-dashoffset', checkLen * (1 - clamp(p, 0, 1)));
// 길이 0 인 대시에도 둥근 끝(round cap)이 점 하나로 찍힌다 — 긋기 전에는 아예 숨긴다
check.style.opacity = p <= 0 ? '0' : '';
}
function setFill(t) { body.setAttribute('fill', rgba(mix(LILAC, MINT, clamp(t, 0, 1)), 1)); }
function setRingAlpha(a) {
ring.style.opacity = a.toFixed(3);
track.style.opacity = a.toFixed(3);
}
// 후광 — 링이 보여 주는 진행률 하나로 반지름(RING_R + 4 → + 38)과 진하기를 같이 정한다. 진행이 원 밖 넓은 면까지 번진다
function setHalo(p, a) {
setNum(halo, 'r', RING_R + 4 + 34 * clamp(p, 0, 1));
halo.style.opacity = clamp(a, 0, 1).toFixed(3);
}
04날아간 자리에 공기 저항과 노이즈로 흩어지는 꼬리 입자
버튼에서 떠난 비행기가 3차 베지어 경로를 1,150ms 동안 지나며 걸음마다 입자 세 개를 진행 반대쪽으로 떨어뜨리고, 입자 속도는 걸음마다 0.955 를 곱해 241ms 에 절반이 된다. 미는 방향은 그 자리의 2D 값 노이즈가 정하고 세기는 0.06 으로 고정해서, 한 점에서 뿌린 입자 100개가 수명 69걸음 동안 평균 62.6px 떠밀리며 연기처럼 번진다. 뉴스레터 구독이나 초대장 보내기에 맞고, 비행 동안 207개를 뿌리지만 풀이 150칸이라 날아가는 도중에 이미 꽉 차서 가장 오래된 입자 자리부터 덮어쓴다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__scene" data-role="scene">
<canvas class="sb__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<button class="tr__send" data-role="send" type="button"><svg xmlns="http://www.w3.org/2000/svg" class="tr__icon" data-role="icon" aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg><span class="tr__label" data-role="label">초대장 보내기</span></button>
<p class="sb__status sb__status--top" data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.sb {
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;
}
// 장면 판 — 480×300 좌표로 짜 두고 스크립트가 상자 폭에 맞춰 scale 한다.
// 기준점이 왼쪽 위 모서리인 이유: 판이 상자 왼쪽 위에 붙어 있어 모서리 기준으로 줄여야 줄인 판이 상자와 겹친다
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .9);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: $sp-3;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: $r-control;
}
.sb { background: #262035; }
// 버튼 안 아이콘 가운데 = 스크립트 PATH 첫 점 (66, 236) — 왼쪽 40 + 안쪽 여백 16 + 아이콘 절반 11
.tr__send {
position: absolute;
left: 40px;
top: 212px;
width: 192px;
height: 48px;
margin: 0;
padding: 0 $sp-4;
border: 0;
border-radius: $r-pill;
background: #c8b8ff;
color: $subject-ink;
font: inherit;
font-weight: 700;
font-size: 15px;
line-height: 1;
display: flex;
align-items: center;
gap: $sp-2;
cursor: pointer;
}
.tr__send.is-sent { background: #b4ecd9; }
.tr__send:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.tr__icon {
width: 22px;
height: 22px;
flex: none;
}
@media (max-width: 340px) {
// 이 폭에서는 장면이 0.6 배 남짓으로 줄어든다 — 상태 줄 글자를 키워 줄인 뒤에도 읽히게 한다
.sb__status { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임은 전부 스크립트가 그리고, 스크립트는 reduce 에서 루프를 돌리지 않고 결과 상태만 둔다. CSS 쪽 전환도 막아 둔다
.sb__scene * {
transition: none !important;
animation: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sb {
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);
}
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: 16px;
bottom: 12px;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: 12px;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: 12px;
}
.sb {
background: #262035;
}
.tr__send {
position: absolute;
left: 40px;
top: 212px;
width: 192px;
height: 48px;
margin: 0;
padding: 0 16px;
border: 0;
border-radius: 999px;
background: #c8b8ff;
color: #17141a;
font: inherit;
font-weight: 700;
font-size: 15px;
line-height: 1;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.tr__send.is-sent {
background: #b4ecd9;
}
.tr__send:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.tr__icon {
width: 22px;
height: 22px;
flex: none;
}
@media (max-width: 340px) {
.sb__status {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__scene * {
transition: none !important;
animation: none !important;
}
}(function () {
const root = document.getElementById("sb");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK = [38, 33, 52];
const PAPER = [255, 252, 246];
const CREAM = [255, 243, 224];
const SKY = [158, 208, 255];
const LILAC = [200, 184, 255];
const MINT = [156, 228, 204];
const PINK = [255, 182, 204];
const PEACH = [255, 214, 184];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOut(x) { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]');
const statusEl = root.querySelector('[data-role="status"]');
if (!sceneEl || !statusEl) {
return;
}
const scene = sceneEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
resetIdle();
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene() {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX, clientY) {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
const canvasEl = root.querySelector('[data-role="canvas"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
let dprNow = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
dprNow = dpr;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fitCanvas();
resizeHooks.push(fitCanvas);
// 2D 값 노이즈 — 격자점마다 시드 난수 값을 두고 사이를 부드럽게 보간한다. 결과는 -1~1
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function at(xi, yi) { return vals[perm[(xi & 255) + perm[yi & 255]]]; }
return function (x, y) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = lerp(at(xi, yi), at(xi + 1, yi), u);
const b = lerp(at(xi, yi + 1), at(xi + 1, yi + 1), u);
return lerp(a, b, v);
};
}
// 꼬리 입자 풀 크기 — 이 수를 넘으면 가장 오래된 입자 자리부터 다시 쓴다
const TRAIL_COUNT = 150;
// 공기 저항 — 16ms 한 걸음마다 속도에 곱한다. 1 에 가까울수록 멀리 미끄러진다
const AIR_DRAG = 0.955;
// 노이즈 장이 입자를 미는 세기(px/걸음²). 장의 값이 방향(0~2π)이 된다
const TRAIL_NOISE = 0.06;
const NOISE_SCALE = 0.011;
const NOISE_DRIFT = 0.00045;
const LIFE_MS = 1100;
const SPAWN_PER_STEP = 3;
const FLY_MS = 1150;
const DEMO_CYCLE = 2400;
// 비행 경로 — 버튼 안 아이콘 자리에서 출발하는 3차 베지어 S 곡선
const PATH = [[66, 236], [250, 96], [318, 270], [520, 58]];
// 날아가는 종이비행기 모양 = 버튼 아이콘과 같은 Phosphor paper-plane-tilt 경로(256 격자)
const PLANE_FILL = 'm223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87';
const PLANE_LINE = 'M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z';
const TXT_IDLE = '초대장 보내기';
const TXT_SENDING = '보내는 중';
const TXT_SENT = '보냈어요';
const TXT_READY = '보낼 준비 됨';
const TXT_FLY = '꼬리를 남기며 날아가는 중';
const TXT_DONE = '보냄 — 초대장이 출발했어요';
const sendEl = root.querySelector('[data-role="send"]');
const labelEl = root.querySelector('[data-role="label"]');
const iconEl = root.querySelector('[data-role="icon"]');
if (!sendEl || !labelEl || !iconEl) {
return;
}
const send = sendEl;
const label = labelEl;
const icon = iconEl;
const planeFill = new Path2D(PLANE_FILL);
const planeLine = new Path2D(PLANE_LINE);
const noise = makeNoise(466);
const rng = makeRng(4660);
const px = new Float32Array(TRAIL_COUNT);
const py = new Float32Array(TRAIL_COUNT);
const vx = new Float32Array(TRAIL_COUNT);
const vy = new Float32Array(TRAIL_COUNT);
const age = new Float32Array(TRAIL_COUNT);
const alive = new Uint8Array(TRAIL_COUNT);
let head = 0;
let phase = 'idle';
let pt = 0;
let fieldT = 0;
let demoNext = 200;
function pathAt(t) {
const u = 1 - t;
const a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t;
return [a * PATH[0][0] + b * PATH[1][0] + c * PATH[2][0] + d * PATH[3][0], a * PATH[0][1] + b * PATH[1][1] + c * PATH[2][1] + d * PATH[3][1]];
}
function pathDir(t) {
const u = 1 - t;
const dx = 3 * u * u * (PATH[1][0] - PATH[0][0]) + 6 * u * t * (PATH[2][0] - PATH[1][0]) + 3 * t * t * (PATH[3][0] - PATH[2][0]);
const dy = 3 * u * u * (PATH[1][1] - PATH[0][1]) + 6 * u * t * (PATH[2][1] - PATH[1][1]) + 3 * t * t * (PATH[3][1] - PATH[2][1]);
const l = Math.hypot(dx, dy) || 1;
return [dx / l, dy / l];
}
function flyParam(p) { return Math.pow(clamp(p, 0, 1), 1.3); }
// 입자 하나를 꼬리 자리에 떨어뜨린다 — 진행 반대쪽으로 조금, 옆으로 시드 난수만큼 흩뿌린다
function spawn(pos, dir) {
const i = head;
head = (head + 1) % TRAIL_COUNT;
const side = (rng() - 0.5) * 0.9;
const back = 0.6 + rng() * 0.8;
px[i] = pos[0] - dir[0] * 8;
py[i] = pos[1] - dir[1] * 8;
vx[i] = -dir[0] * back - dir[1] * side;
vy[i] = -dir[1] * back + dir[0] * side;
age[i] = 0;
alive[i] = 1;
}
// 한 걸음 — 속도에 저항을 곱해 줄이고, 그 자리 노이즈 값이 가리키는 방향으로 민 뒤 옮긴다
function advance(i) {
vx[i] *= AIR_DRAG;
vy[i] *= AIR_DRAG;
const ang = noise(px[i] * NOISE_SCALE, py[i] * NOISE_SCALE + fieldT * NOISE_DRIFT) * Math.PI * 2;
vx[i] += Math.cos(ang) * TRAIL_NOISE;
vy[i] += Math.sin(ang) * TRAIL_NOISE - 0.004;
px[i] += vx[i];
py[i] += vy[i];
age[i] += FIXED_DT;
if (age[i] >= LIFE_MS) {
alive[i] = 0;
}
}
function aliveCount() {
let n = 0;
for (let i = 0; i < TRAIL_COUNT; i += 1) {
n += alive[i];
}
return n;
}
function setIcon(a, s) {
icon.style.opacity = a === 1 ? '' : a.toFixed(3);
icon.style.transform = s === 1 ? '' : 'scale(' + s.toFixed(3) + ')';
}
function resetIdle() {
phase = 'idle';
pt = 0;
alive.fill(0);
label.textContent = TXT_IDLE;
send.classList.remove('is-sent');
setIcon(1, 1);
say(TXT_READY);
}
function finish() {
phase = 'done';
pt = 0;
alive.fill(0);
label.textContent = TXT_SENT;
send.classList.add('is-sent');
setIcon(1, 1);
say(TXT_DONE);
}
function settle() { finish(); }
function press() {
if (phase !== 'idle' && phase !== 'done') {
return;
}
if (still) {
finish();
return;
}
resetIdle();
phase = 'fly';
label.textContent = TXT_SENDING;
setIcon(0, 1);
say(TXT_FLY);
}
function step() {
fieldT += FIXED_DT;
for (let i = 0; i < TRAIL_COUNT; i += 1) {
if (alive[i] === 1) {
advance(i);
}
}
if (phase === 'idle' || phase === 'done') {
return;
}
pt += FIXED_DT;
if (phase === 'fly') {
const t = flyParam(pt / FLY_MS);
const pos = pathAt(t);
const dir = pathDir(t);
if (pos[0] < W + 10) {
for (let s = 0; s < SPAWN_PER_STEP; s += 1) {
spawn(pos, dir);
}
}
if (pt >= FLY_MS) {
phase = 'drift';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
}
}
else if (phase === 'drift' && aliveCount() === 0) {
finish();
}
}
function demoTick(t) {
if (t < demoNext) {
return;
}
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw() {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < TRAIL_COUNT; i += 1) {
if (alive[i] === 0) {
continue;
}
const f = age[i] / LIFE_MS;
ctx.fillStyle = rgba(mix(LILAC, SKY, f), 0.5 * Math.pow(1 - f, 1.3));
ctx.beginPath();
ctx.arc(px[i], py[i], 1.5 + 5 * f, 0, Math.PI * 2);
ctx.fill();
}
if (phase === 'fly') {
const t = flyParam(pt / FLY_MS);
const pos = pathAt(t);
const dir = pathDir(t);
ctx.save();
ctx.translate(pos[0], pos[1]);
// 아이콘 기수는 오른쪽 위(−45°)를 본다 — 진행 각도에 45° 를 더해 기수를 접선에 맞춘다
ctx.rotate(Math.atan2(dir[1], dir[0]) + Math.PI / 4);
ctx.scale(26 / 256, 26 / 256);
ctx.translate(-128, -128);
ctx.fillStyle = rgba(PAPER, 0.55);
ctx.fill(planeFill);
ctx.fillStyle = rgba(PAPER, 1);
ctx.fill(planeLine);
ctx.restore();
}
else if (phase === 'drift') {
const p = clamp(pt / 260, 0, 1);
setIcon(p, lerp(0.4, 1, easeOut(p)));
}
}
send.addEventListener('click', function () { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize() {
fitScene();
for (const f of resizeHooks) {
f();
}
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// SendTrailDragNoise - vanilla 판(04-send-btn-trail-drag-noise) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SendTrailDragNoise.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SendTrailDragNoise() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK: RGB = [38, 33, 52];
const PAPER: RGB = [255, 252, 246];
const CREAM: RGB = [255, 243, 224];
const SKY: RGB = [158, 208, 255];
const LILAC: RGB = [200, 184, 255];
const MINT: RGB = [156, 228, 204];
const PINK: RGB = [255, 182, 204];
const PEACH: RGB = [255, 214, 184];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function easeOut(x: number): number { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
if (!sceneEl || !statusEl) { return; }
const scene: HTMLElement = sceneEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks: Array<() => void> = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
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');
resetIdle();
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene(): void {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX: number, clientY: number): [number, number] {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
let dprNow = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
dprNow = dpr;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fitCanvas();
resizeHooks.push(fitCanvas);
// 2D 값 노이즈 — 격자점마다 시드 난수 값을 두고 사이를 부드럽게 보간한다. 결과는 -1~1
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function at(xi: number, yi: number): number { return vals[perm[(xi & 255) + perm[yi & 255]]]; }
return function (x: number, y: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = lerp(at(xi, yi), at(xi + 1, yi), u);
const b = lerp(at(xi, yi + 1), at(xi + 1, yi + 1), u);
return lerp(a, b, v);
};
}
type Phase = 'idle' | 'fly' | 'drift' | 'done';
type V2 = [number, number];
// 꼬리 입자 풀 크기 — 이 수를 넘으면 가장 오래된 입자 자리부터 다시 쓴다
const TRAIL_COUNT = 150;
// 공기 저항 — 16ms 한 걸음마다 속도에 곱한다. 1 에 가까울수록 멀리 미끄러진다
const AIR_DRAG = 0.955;
// 노이즈 장이 입자를 미는 세기(px/걸음²). 장의 값이 방향(0~2π)이 된다
const TRAIL_NOISE = 0.06;
const NOISE_SCALE = 0.011;
const NOISE_DRIFT = 0.00045;
const LIFE_MS = 1100;
const SPAWN_PER_STEP = 3;
const FLY_MS = 1150;
const DEMO_CYCLE = 2400;
// 비행 경로 — 버튼 안 아이콘 자리에서 출발하는 3차 베지어 S 곡선
const PATH: V2[] = [[66, 236], [250, 96], [318, 270], [520, 58]];
// 날아가는 종이비행기 모양 = 버튼 아이콘과 같은 Phosphor paper-plane-tilt 경로(256 격자)
const PLANE_FILL = 'm223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87';
const PLANE_LINE = 'M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z';
const TXT_IDLE = '초대장 보내기';
const TXT_SENDING = '보내는 중';
const TXT_SENT = '보냈어요';
const TXT_READY = '보낼 준비 됨';
const TXT_FLY = '꼬리를 남기며 날아가는 중';
const TXT_DONE = '보냄 — 초대장이 출발했어요';
const sendEl = root.querySelector('[data-role="send"]') as HTMLButtonElement | null;
const labelEl = root.querySelector('[data-role="label"]') as HTMLElement | null;
const iconEl = root.querySelector('[data-role="icon"]') as SVGElement | null;
if (!sendEl || !labelEl || !iconEl) { return; }
const send: HTMLButtonElement = sendEl;
const label: HTMLElement = labelEl;
const icon: SVGElement = iconEl;
const planeFill = new Path2D(PLANE_FILL);
const planeLine = new Path2D(PLANE_LINE);
const noise = makeNoise(466);
const rng = makeRng(4660);
const px = new Float32Array(TRAIL_COUNT);
const py = new Float32Array(TRAIL_COUNT);
const vx = new Float32Array(TRAIL_COUNT);
const vy = new Float32Array(TRAIL_COUNT);
const age = new Float32Array(TRAIL_COUNT);
const alive = new Uint8Array(TRAIL_COUNT);
let head = 0;
let phase: Phase = 'idle';
let pt = 0;
let fieldT = 0;
let demoNext = 200;
function pathAt(t: number): V2 {
const u = 1 - t;
const a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t;
return [a * PATH[0][0] + b * PATH[1][0] + c * PATH[2][0] + d * PATH[3][0], a * PATH[0][1] + b * PATH[1][1] + c * PATH[2][1] + d * PATH[3][1]];
}
function pathDir(t: number): V2 {
const u = 1 - t;
const dx = 3 * u * u * (PATH[1][0] - PATH[0][0]) + 6 * u * t * (PATH[2][0] - PATH[1][0]) + 3 * t * t * (PATH[3][0] - PATH[2][0]);
const dy = 3 * u * u * (PATH[1][1] - PATH[0][1]) + 6 * u * t * (PATH[2][1] - PATH[1][1]) + 3 * t * t * (PATH[3][1] - PATH[2][1]);
const l = Math.hypot(dx, dy) || 1;
return [dx / l, dy / l];
}
function flyParam(p: number): number { return Math.pow(clamp(p, 0, 1), 1.3); }
// 입자 하나를 꼬리 자리에 떨어뜨린다 — 진행 반대쪽으로 조금, 옆으로 시드 난수만큼 흩뿌린다
function spawn(pos: V2, dir: V2): void {
const i = head;
head = (head + 1) % TRAIL_COUNT;
const side = (rng() - 0.5) * 0.9;
const back = 0.6 + rng() * 0.8;
px[i] = pos[0] - dir[0] * 8;
py[i] = pos[1] - dir[1] * 8;
vx[i] = -dir[0] * back - dir[1] * side;
vy[i] = -dir[1] * back + dir[0] * side;
age[i] = 0;
alive[i] = 1;
}
// 한 걸음 — 속도에 저항을 곱해 줄이고, 그 자리 노이즈 값이 가리키는 방향으로 민 뒤 옮긴다
function advance(i: number): void {
vx[i] *= AIR_DRAG;
vy[i] *= AIR_DRAG;
const ang = noise(px[i] * NOISE_SCALE, py[i] * NOISE_SCALE + fieldT * NOISE_DRIFT) * Math.PI * 2;
vx[i] += Math.cos(ang) * TRAIL_NOISE;
vy[i] += Math.sin(ang) * TRAIL_NOISE - 0.004;
px[i] += vx[i];
py[i] += vy[i];
age[i] += FIXED_DT;
if (age[i] >= LIFE_MS) { alive[i] = 0; }
}
function aliveCount(): number {
let n = 0;
for (let i = 0; i < TRAIL_COUNT; i += 1) { n += alive[i]; }
return n;
}
function setIcon(a: number, s: number): void {
icon.style.opacity = a === 1 ? '' : a.toFixed(3);
icon.style.transform = s === 1 ? '' : 'scale(' + s.toFixed(3) + ')';
}
function resetIdle(): void {
phase = 'idle';
pt = 0;
alive.fill(0);
label.textContent = TXT_IDLE;
send.classList.remove('is-sent');
setIcon(1, 1);
say(TXT_READY);
}
function finish(): void {
phase = 'done';
pt = 0;
alive.fill(0);
label.textContent = TXT_SENT;
send.classList.add('is-sent');
setIcon(1, 1);
say(TXT_DONE);
}
function settle(): void { finish(); }
function press(): void {
if (phase !== 'idle' && phase !== 'done') { return; }
if (still) { finish(); return; }
resetIdle();
phase = 'fly';
label.textContent = TXT_SENDING;
setIcon(0, 1);
say(TXT_FLY);
}
function step(): void {
fieldT += FIXED_DT;
for (let i = 0; i < TRAIL_COUNT; i += 1) { if (alive[i] === 1) { advance(i); } }
if (phase === 'idle' || phase === 'done') { return; }
pt += FIXED_DT;
if (phase === 'fly') {
const t = flyParam(pt / FLY_MS);
const pos = pathAt(t);
const dir = pathDir(t);
if (pos[0] < W + 10) { for (let s = 0; s < SPAWN_PER_STEP; s += 1) { spawn(pos, dir); } }
if (pt >= FLY_MS) {
phase = 'drift';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
}
} else if (phase === 'drift' && aliveCount() === 0) {
finish();
}
}
function demoTick(t: number): void {
if (t < demoNext) { return; }
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw(): void {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < TRAIL_COUNT; i += 1) {
if (alive[i] === 0) { continue; }
const f = age[i] / LIFE_MS;
ctx.fillStyle = rgba(mix(LILAC, SKY, f), 0.5 * Math.pow(1 - f, 1.3));
ctx.beginPath();
ctx.arc(px[i], py[i], 1.5 + 5 * f, 0, Math.PI * 2);
ctx.fill();
}
if (phase === 'fly') {
const t = flyParam(pt / FLY_MS);
const pos = pathAt(t);
const dir = pathDir(t);
ctx.save();
ctx.translate(pos[0], pos[1]);
// 아이콘 기수는 오른쪽 위(−45°)를 본다 — 진행 각도에 45° 를 더해 기수를 접선에 맞춘다
ctx.rotate(Math.atan2(dir[1], dir[0]) + Math.PI / 4);
ctx.scale(26 / 256, 26 / 256);
ctx.translate(-128, -128);
ctx.fillStyle = rgba(PAPER, 0.55);
ctx.fill(planeFill);
ctx.fillStyle = rgba(PAPER, 1);
ctx.fill(planeLine);
ctx.restore();
} else if (phase === 'drift') {
const p = clamp(pt / 260, 0, 1);
setIcon(p, lerp(0.4, 1, easeOut(p)));
}
}
send.addEventListener('click', function (): void { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize(): void {
fitScene();
for (const f of resizeHooks) { f(); }
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("sb") + " is-demo"} ref={rootRef}>
<div className={cx("sb__scene")} data-role="scene">
<canvas className={cx("sb__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<button className={cx("tr__send")} data-role="send" type="button"><svg xmlns="http://www.w3.org/2000/svg" className={cx("tr__icon")} data-role="icon" aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg><span className={cx("tr__label")} data-role="label">초대장 보내기</span></button>
<p className={cx("sb__status sb__status--top")} data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
);
}04 · 날아간 자리에 공기 저항과 노이즈로 흩어지는 꼬리 입자 — 측정 누적면적 14.133% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 한 걸음 — 속도에 저항을 곱해 줄이고, 그 자리 노이즈 값이 가리키는 방향으로 민 뒤 옮긴다
function advance(i) {
vx[i] *= AIR_DRAG;
vy[i] *= AIR_DRAG;
const ang = noise(px[i] * NOISE_SCALE, py[i] * NOISE_SCALE + fieldT * NOISE_DRIFT) * Math.PI * 2;
vx[i] += Math.cos(ang) * TRAIL_NOISE;
vy[i] += Math.sin(ang) * TRAIL_NOISE - 0.004;
px[i] += vx[i];
py[i] += vy[i];
age[i] += FIXED_DT;
if (age[i] >= LIFE_MS) {
alive[i] = 0;
}
}
05편지봉투로 바뀌어 덮개가 접히고 봉해지는 메일 버튼
버튼이 160ms 동안 180×112 봉투로 늘어나 편지를 받은 뒤, 삼각 덮개가 윗변을 축으로 180° 에서 0° 까지 480ms 에 걸쳐 닫히고 그 끝점은 초점 500 원근으로 투영돼 90° 에서 화면 쪽으로 66 만큼 튀어나온다. 덮개가 움직이기 시작해 240ms 에 90° 를 지나는 순간 안쪽 면 색으로 몸통 뒤에 그리던 것을 바깥 면 색으로 맨 위에 그리도록 바꾸고, 봉인 스티커는 처음 속도 12 를 받은 스프링으로 1.41배까지 부풀었다가 1 로 붙는다. 이메일 보내기나 문의 접수 완료 화면에 맞고, 한 번 보내는 데 2,060ms 가 걸린다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__scene" data-role="scene">
<canvas class="sb__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<button class="ev__send" data-role="send" type="button"><span class="ev__label" data-role="label">메일 보내기</span></button>
<p class="sb__status" data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sb {
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;
}
// 장면 판 — 480×300 좌표로 짜 두고 스크립트가 상자 폭에 맞춰 scale 한다.
// 기준점이 왼쪽 위 모서리인 이유: 판이 상자 왼쪽 위에 붙어 있어 모서리 기준으로 줄여야 줄인 판이 상자와 겹친다
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .9);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: $sp-3;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: $r-control;
}
.sb { background: #fbf5ee; }
// 버튼 자리 = 봉투 가운데 (ECX 240, ECY 160) · 180×56 — 누르면 같은 너비의 봉투로 늘어난다
.ev__send {
position: absolute;
left: 150px;
top: 132px;
width: 180px;
height: 56px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-sm;
background: #ffd6b8;
color: $subject-ink;
font: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.ev__send.is-sent { background: #b4ecd9; }
.ev__send:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
@media (max-width: 340px) {
// 이 폭에서는 장면이 0.6 배 남짓으로 줄어든다 — 상태 줄 글자를 키워 줄인 뒤에도 읽히게 한다
.sb__status { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임은 전부 스크립트가 그리고, 스크립트는 reduce 에서 루프를 돌리지 않고 결과 상태만 둔다. CSS 쪽 전환도 막아 둔다
.sb__scene * {
transition: none !important;
animation: none !important;
}
}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);
}
.sb {
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);
}
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: 16px;
bottom: 12px;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: 12px;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: 12px;
}
.sb {
background: #fbf5ee;
}
.ev__send {
position: absolute;
left: 150px;
top: 132px;
width: 180px;
height: 56px;
margin: 0;
padding: 0;
border: 0;
border-radius: 8px;
background: #ffd6b8;
color: #17141a;
font: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.ev__send.is-sent {
background: #b4ecd9;
}
.ev__send:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
@media (max-width: 340px) {
.sb__status {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__scene * {
transition: none !important;
animation: none !important;
}
}(function () {
const root = document.getElementById("sb");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK = [38, 33, 52];
const PAPER = [255, 252, 246];
const CREAM = [255, 243, 224];
const SKY = [158, 208, 255];
const LILAC = [200, 184, 255];
const MINT = [156, 228, 204];
const PINK = [255, 182, 204];
const PEACH = [255, 214, 184];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOut(x) { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]');
const statusEl = root.querySelector('[data-role="status"]');
if (!sceneEl || !statusEl) {
return;
}
const scene = sceneEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
resetIdle();
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene() {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX, clientY) {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
const canvasEl = root.querySelector('[data-role="canvas"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
let dprNow = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
dprNow = dpr;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fitCanvas();
resizeHooks.push(fitCanvas);
// 감쇠 스프링 한 걸음 — 질량 1, 강성 stiff, 감쇠비 zeta. 속도를 먼저 바꾸고 그 속도로 위치를 옮긴다(반암시적 오일러)
function springStep(s, stiff, zeta, dtMs) {
const dt = dtMs / 1000;
const c = 2 * zeta * Math.sqrt(stiff);
const a = -stiff * (s.x - s.target) - c * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
// 봉투(장면 px) — 버튼 180×56 이 같은 너비의 봉투 180×112 로 늘어난다
const EX = 150;
const EY = 104;
const EW = 180;
const EH = 112;
const BTN_H = 56;
const ECX = EX + EW / 2;
const ECY = EY + EH / 2;
// 덮개 회전 각 — 180° 는 위로 활짝 열린 자리, 0° 는 앞면에 닫힌 자리. 윗변이 회전축이다
const FLAP_DEG = 180;
// 덮개가 180° 에서 0° 까지 닫히는 시간
const FLAP_MS = 480;
// 봉인 스티커 스프링에 처음 주는 속도(1/s) — 클수록 크기 1 을 더 넘었다 돌아온다
const SEAL_POP = 12;
const SEAL_K = 420;
const SEAL_ZETA = 0.32;
const FLAP_H = 66;
const FOCAL = 500;
const MORPH_MS = 160;
const LETTER_MS = 360;
const SEAL_MS = 360;
const AWAY_MS = 420;
const BACK_MS = 280;
const DEMO_CYCLE = 3000;
const TXT_IDLE = '메일 보내기';
const TXT_SENT = '보냈어요';
const TXT_READY = '보낼 준비 됨';
const TXT_FOLD = '봉투에 넣고 덮개를 닫는 중';
const TXT_DONE = '보냄 — 문의 메일을 받았어요';
const sendEl = root.querySelector('[data-role="send"]');
const labelEl = root.querySelector('[data-role="label"]');
if (!sendEl || !labelEl) {
return;
}
const send = sendEl;
const label = labelEl;
const seal = { x: 0, v: 0, target: 1 };
let phase = 'idle';
let pt = 0;
let demoNext = 200;
function project(p) {
const s = FOCAL / (FOCAL + p[2]);
return [ECX + (p[0] - ECX) * s, ECY + (p[1] - ECY) * s];
}
// 덮개 끝점 — 윗변(y = EY)을 축으로 deg 만큼 세운다. 90° 에서 화면 쪽(z 음수)으로 가장 튀어나온다
function flapTip(deg) {
const a = deg * Math.PI / 180;
return [ECX, EY + FLAP_H * Math.cos(a), -FLAP_H * Math.sin(a)];
}
// 90° 를 넘었는가 — 넘으면 덮개의 안쪽 면이 보이고 몸통 뒤에 그린다
function flapInsideVisible(deg) { return deg > 90; }
function flapDegAt(t) { return FLAP_DEG * (1 - easeInOut(t / FLAP_MS)); }
function poly(pts, fill, line) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) {
ctx.lineTo(pts[i][0], pts[i][1]);
}
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = line;
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
ctx.stroke();
}
function drawFlap(deg) {
const tip = project(flapTip(deg));
const shade = 0.78 + 0.22 * Math.abs(Math.cos(deg * Math.PI / 180));
const base = flapInsideVisible(deg) ? mix(PEACH, INK, 0.16) : PEACH;
poly([[EX, EY], [EX + EW, EY], tip], rgba(mix(mix(base, INK, 0.3), base, shade), 1), rgba(INK, 0.2));
}
function drawLetter(top) {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(EX + 12, top, EW - 24, 92);
ctx.fillStyle = rgba(LILAC, 0.9);
ctx.fillRect(EX + 26, top + 16, 72, 6);
ctx.fillStyle = rgba(INK, 0.16);
ctx.fillRect(EX + 26, top + 30, 124, 4);
ctx.fillRect(EX + 26, top + 40, 104, 4);
}
function drawEnvelope(h, letterTop, deg, sealScale) {
const top = ECY - h / 2;
const bottom = ECY + h / 2;
if (flapInsideVisible(deg)) {
drawFlap(deg);
}
poly([[EX, top], [EX + EW, top], [EX + EW, bottom], [EX, bottom]], rgba(mix(PEACH, INK, 0.1), 1), rgba(INK, 0.2));
if (letterTop < 900) {
drawLetter(letterTop);
}
// 앞 주머니 — 양옆 삼각형 두 장 + 아래 삼각형 한 장
const mid = top + h * 0.58;
poly([[EX, top + 2], [ECX - 6, mid], [EX, bottom]], rgba(mix(PEACH, PAPER, 0.35), 1), rgba(INK, 0.14));
poly([[EX + EW, top + 2], [ECX + 6, mid], [EX + EW, bottom]], rgba(mix(PEACH, PAPER, 0.35), 1), rgba(INK, 0.14));
poly([[EX, bottom], [ECX, top + h * 0.46], [EX + EW, bottom]], rgba(mix(PEACH, PAPER, 0.15), 1), rgba(INK, 0.16));
if (!flapInsideVisible(deg)) {
drawFlap(deg);
}
if (sealScale > 0.01) {
const tip = flapTip(0);
ctx.save();
ctx.translate(tip[0], tip[1]);
ctx.scale(sealScale, sealScale);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(0, 0, 13, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = rgba(PAPER, 0.95);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, 7.5, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
// 자리표시 — 버튼 자리(150, 132, 180×56)를 보냄 색(scss 의 #b4ecd9)으로 옅게
const SENT = [180, 236, 217];
const GHOST_ALPHA = 0.5;
function drawGhost(a) {
if (a <= 0.01) {
return;
}
ctx.fillStyle = rgba(SENT, a);
ctx.beginPath();
ctx.roundRect(EX, ECY - BTN_H / 2, EW, BTN_H, 8);
ctx.fill();
}
function setButton(scale, alpha) {
send.style.transform = scale === 1 ? '' : 'scale(' + scale.toFixed(3) + ')';
send.style.opacity = alpha === 1 ? '' : alpha.toFixed(3);
}
function resetIdle() {
phase = 'idle';
pt = 0;
seal.x = 0;
seal.v = 0;
label.textContent = TXT_IDLE;
send.classList.remove('is-sent');
setButton(1, 1);
say(TXT_READY);
}
function finish() {
phase = 'done';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
setButton(1, 1);
say(TXT_DONE);
}
function settle() { finish(); }
function press() {
if (phase !== 'idle' && phase !== 'done') {
return;
}
if (still) {
finish();
return;
}
resetIdle();
phase = 'morph';
say(TXT_FOLD);
}
function step() {
if (phase === 'idle' || phase === 'done') {
return;
}
pt += FIXED_DT;
if (phase === 'morph' && pt >= MORPH_MS) {
phase = 'letter';
pt = 0;
}
else if (phase === 'letter' && pt >= LETTER_MS) {
phase = 'flap';
pt = 0;
}
else if (phase === 'flap' && pt >= FLAP_MS) {
phase = 'seal';
pt = 0;
seal.x = 0;
seal.v = SEAL_POP;
}
else if (phase === 'seal') {
springStep(seal, SEAL_K, SEAL_ZETA, FIXED_DT);
if (pt >= SEAL_MS) {
phase = 'away';
pt = 0;
}
}
else if (phase === 'away' && pt >= AWAY_MS) {
phase = 'back';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
}
else if (phase === 'back' && pt >= BACK_MS) {
finish();
}
}
function demoTick(t) {
if (t < demoNext) {
return;
}
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw() {
ctx.clearRect(0, 0, W, H);
if (phase === 'idle' || phase === 'done') {
return;
}
if (phase === 'back') {
const p = clamp(pt / BACK_MS, 0, 1);
drawGhost(GHOST_ALPHA * (1 - p));
setButton(lerp(0.94, 1, easeOut(p)), lerp(GHOST_ALPHA, 1, p));
return;
}
// 봉투가 떠나는 동안 버튼 자리를 보냄 색 자리표시로 채운다 — 떠난 뒤 프레임이 빈 구멍이 아니다
if (phase === 'away') {
drawGhost(GHOST_ALPHA * easeOut(pt / AWAY_MS));
}
setButton(1, phase === 'morph' ? 1 - pt / MORPH_MS : 0);
let h = EH;
let letterTop = EY + 12;
let deg = 0;
let sealScale = 1;
if (phase === 'morph') {
h = lerp(BTN_H, EH, easeOut(pt / MORPH_MS));
letterTop = 1000;
deg = FLAP_DEG;
sealScale = 0;
}
else if (phase === 'letter') {
// 편지가 봉투 위 바깥에서 내려와 주머니 안으로 들어간다
letterTop = lerp(EY - 96, EY + 12, easeInOut(pt / LETTER_MS));
deg = FLAP_DEG;
sealScale = 0;
}
else if (phase === 'flap') {
deg = flapDegAt(pt);
sealScale = 0;
}
else if (phase === 'seal') {
sealScale = Math.max(0, seal.x);
}
if (phase === 'away') {
const p = pt / AWAY_MS;
const e = p * p;
ctx.save();
ctx.translate(ECX + 300 * e, ECY - 70 * e);
ctx.rotate(-0.16 * e);
ctx.scale(1 - 0.35 * e, 1 - 0.35 * e);
ctx.translate(-ECX, -ECY);
drawEnvelope(h, letterTop, deg, sealScale);
ctx.restore();
}
else {
drawEnvelope(h, letterTop, deg, sealScale);
}
}
send.addEventListener('click', function () { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize() {
fitScene();
for (const f of resizeHooks) {
f();
}
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// SendEnvelopeFlapSeal - vanilla 판(05-send-btn-envelope-flap-seal) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SendEnvelopeFlapSeal.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SendEnvelopeFlapSeal() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK: RGB = [38, 33, 52];
const PAPER: RGB = [255, 252, 246];
const CREAM: RGB = [255, 243, 224];
const SKY: RGB = [158, 208, 255];
const LILAC: RGB = [200, 184, 255];
const MINT: RGB = [156, 228, 204];
const PINK: RGB = [255, 182, 204];
const PEACH: RGB = [255, 214, 184];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function easeOut(x: number): number { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
if (!sceneEl || !statusEl) { return; }
const scene: HTMLElement = sceneEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks: Array<() => void> = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
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');
resetIdle();
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene(): void {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX: number, clientY: number): [number, number] {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
let dprNow = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
dprNow = dpr;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fitCanvas();
resizeHooks.push(fitCanvas);
type Spring = { x: number; v: number; target: number };
// 감쇠 스프링 한 걸음 — 질량 1, 강성 stiff, 감쇠비 zeta. 속도를 먼저 바꾸고 그 속도로 위치를 옮긴다(반암시적 오일러)
function springStep(s: Spring, stiff: number, zeta: number, dtMs: number): void {
const dt = dtMs / 1000;
const c = 2 * zeta * Math.sqrt(stiff);
const a = -stiff * (s.x - s.target) - c * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
type Phase = 'idle' | 'morph' | 'letter' | 'flap' | 'seal' | 'away' | 'back' | 'done';
type V3 = [number, number, number];
// 봉투(장면 px) — 버튼 180×56 이 같은 너비의 봉투 180×112 로 늘어난다
const EX = 150;
const EY = 104;
const EW = 180;
const EH = 112;
const BTN_H = 56;
const ECX = EX + EW / 2;
const ECY = EY + EH / 2;
// 덮개 회전 각 — 180° 는 위로 활짝 열린 자리, 0° 는 앞면에 닫힌 자리. 윗변이 회전축이다
const FLAP_DEG = 180;
// 덮개가 180° 에서 0° 까지 닫히는 시간
const FLAP_MS = 480;
// 봉인 스티커 스프링에 처음 주는 속도(1/s) — 클수록 크기 1 을 더 넘었다 돌아온다
const SEAL_POP = 12;
const SEAL_K = 420;
const SEAL_ZETA = 0.32;
const FLAP_H = 66;
const FOCAL = 500;
const MORPH_MS = 160;
const LETTER_MS = 360;
const SEAL_MS = 360;
const AWAY_MS = 420;
const BACK_MS = 280;
const DEMO_CYCLE = 3000;
const TXT_IDLE = '메일 보내기';
const TXT_SENT = '보냈어요';
const TXT_READY = '보낼 준비 됨';
const TXT_FOLD = '봉투에 넣고 덮개를 닫는 중';
const TXT_DONE = '보냄 — 문의 메일을 받았어요';
const sendEl = root.querySelector('[data-role="send"]') as HTMLButtonElement | null;
const labelEl = root.querySelector('[data-role="label"]') as HTMLElement | null;
if (!sendEl || !labelEl) { return; }
const send: HTMLButtonElement = sendEl;
const label: HTMLElement = labelEl;
const seal: Spring = { x: 0, v: 0, target: 1 };
let phase: Phase = 'idle';
let pt = 0;
let demoNext = 200;
function project(p: V3): [number, number] {
const s = FOCAL / (FOCAL + p[2]);
return [ECX + (p[0] - ECX) * s, ECY + (p[1] - ECY) * s];
}
// 덮개 끝점 — 윗변(y = EY)을 축으로 deg 만큼 세운다. 90° 에서 화면 쪽(z 음수)으로 가장 튀어나온다
function flapTip(deg: number): V3 {
const a = deg * Math.PI / 180;
return [ECX, EY + FLAP_H * Math.cos(a), -FLAP_H * Math.sin(a)];
}
// 90° 를 넘었는가 — 넘으면 덮개의 안쪽 면이 보이고 몸통 뒤에 그린다
function flapInsideVisible(deg: number): boolean { return deg > 90; }
function flapDegAt(t: number): number { return FLAP_DEG * (1 - easeInOut(t / FLAP_MS)); }
function poly(pts: Array<[number, number]>, fill: string, line: string): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) { ctx.lineTo(pts[i][0], pts[i][1]); }
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = line;
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
ctx.stroke();
}
function drawFlap(deg: number): void {
const tip = project(flapTip(deg));
const shade = 0.78 + 0.22 * Math.abs(Math.cos(deg * Math.PI / 180));
const base = flapInsideVisible(deg) ? mix(PEACH, INK, 0.16) : PEACH;
poly([[EX, EY], [EX + EW, EY], tip], rgba(mix(mix(base, INK, 0.3), base, shade), 1), rgba(INK, 0.2));
}
function drawLetter(top: number): void {
ctx.fillStyle = rgba(PAPER, 1);
ctx.fillRect(EX + 12, top, EW - 24, 92);
ctx.fillStyle = rgba(LILAC, 0.9);
ctx.fillRect(EX + 26, top + 16, 72, 6);
ctx.fillStyle = rgba(INK, 0.16);
ctx.fillRect(EX + 26, top + 30, 124, 4);
ctx.fillRect(EX + 26, top + 40, 104, 4);
}
function drawEnvelope(h: number, letterTop: number, deg: number, sealScale: number): void {
const top = ECY - h / 2;
const bottom = ECY + h / 2;
if (flapInsideVisible(deg)) { drawFlap(deg); }
poly([[EX, top], [EX + EW, top], [EX + EW, bottom], [EX, bottom]], rgba(mix(PEACH, INK, 0.1), 1), rgba(INK, 0.2));
if (letterTop < 900) { drawLetter(letterTop); }
// 앞 주머니 — 양옆 삼각형 두 장 + 아래 삼각형 한 장
const mid = top + h * 0.58;
poly([[EX, top + 2], [ECX - 6, mid], [EX, bottom]], rgba(mix(PEACH, PAPER, 0.35), 1), rgba(INK, 0.14));
poly([[EX + EW, top + 2], [ECX + 6, mid], [EX + EW, bottom]], rgba(mix(PEACH, PAPER, 0.35), 1), rgba(INK, 0.14));
poly([[EX, bottom], [ECX, top + h * 0.46], [EX + EW, bottom]], rgba(mix(PEACH, PAPER, 0.15), 1), rgba(INK, 0.16));
if (!flapInsideVisible(deg)) { drawFlap(deg); }
if (sealScale > 0.01) {
const tip = flapTip(0);
ctx.save();
ctx.translate(tip[0], tip[1]);
ctx.scale(sealScale, sealScale);
ctx.fillStyle = rgba(PINK, 1);
ctx.beginPath();
ctx.arc(0, 0, 13, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = rgba(PAPER, 0.95);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, 7.5, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
// 자리표시 — 버튼 자리(150, 132, 180×56)를 보냄 색(scss 의 #b4ecd9)으로 옅게
const SENT: RGB = [180, 236, 217];
const GHOST_ALPHA = 0.5;
function drawGhost(a: number): void {
if (a <= 0.01) { return; }
ctx.fillStyle = rgba(SENT, a);
ctx.beginPath();
ctx.roundRect(EX, ECY - BTN_H / 2, EW, BTN_H, 8);
ctx.fill();
}
function setButton(scale: number, alpha: number): void {
send.style.transform = scale === 1 ? '' : 'scale(' + scale.toFixed(3) + ')';
send.style.opacity = alpha === 1 ? '' : alpha.toFixed(3);
}
function resetIdle(): void {
phase = 'idle';
pt = 0;
seal.x = 0;
seal.v = 0;
label.textContent = TXT_IDLE;
send.classList.remove('is-sent');
setButton(1, 1);
say(TXT_READY);
}
function finish(): void {
phase = 'done';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
setButton(1, 1);
say(TXT_DONE);
}
function settle(): void { finish(); }
function press(): void {
if (phase !== 'idle' && phase !== 'done') { return; }
if (still) { finish(); return; }
resetIdle();
phase = 'morph';
say(TXT_FOLD);
}
function step(): void {
if (phase === 'idle' || phase === 'done') { return; }
pt += FIXED_DT;
if (phase === 'morph' && pt >= MORPH_MS) { phase = 'letter'; pt = 0; }
else if (phase === 'letter' && pt >= LETTER_MS) { phase = 'flap'; pt = 0; }
else if (phase === 'flap' && pt >= FLAP_MS) { phase = 'seal'; pt = 0; seal.x = 0; seal.v = SEAL_POP; }
else if (phase === 'seal') {
springStep(seal, SEAL_K, SEAL_ZETA, FIXED_DT);
if (pt >= SEAL_MS) { phase = 'away'; pt = 0; }
} else if (phase === 'away' && pt >= AWAY_MS) {
phase = 'back';
pt = 0;
label.textContent = TXT_SENT;
send.classList.add('is-sent');
} else if (phase === 'back' && pt >= BACK_MS) { finish(); }
}
function demoTick(t: number): void {
if (t < demoNext) { return; }
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw(): void {
ctx.clearRect(0, 0, W, H);
if (phase === 'idle' || phase === 'done') { return; }
if (phase === 'back') {
const p = clamp(pt / BACK_MS, 0, 1);
drawGhost(GHOST_ALPHA * (1 - p));
setButton(lerp(0.94, 1, easeOut(p)), lerp(GHOST_ALPHA, 1, p));
return;
}
// 봉투가 떠나는 동안 버튼 자리를 보냄 색 자리표시로 채운다 — 떠난 뒤 프레임이 빈 구멍이 아니다
if (phase === 'away') { drawGhost(GHOST_ALPHA * easeOut(pt / AWAY_MS)); }
setButton(1, phase === 'morph' ? 1 - pt / MORPH_MS : 0);
let h = EH;
let letterTop = EY + 12;
let deg = 0;
let sealScale = 1;
if (phase === 'morph') {
h = lerp(BTN_H, EH, easeOut(pt / MORPH_MS));
letterTop = 1000;
deg = FLAP_DEG;
sealScale = 0;
} else if (phase === 'letter') {
// 편지가 봉투 위 바깥에서 내려와 주머니 안으로 들어간다
letterTop = lerp(EY - 96, EY + 12, easeInOut(pt / LETTER_MS));
deg = FLAP_DEG;
sealScale = 0;
} else if (phase === 'flap') {
deg = flapDegAt(pt);
sealScale = 0;
} else if (phase === 'seal') {
sealScale = Math.max(0, seal.x);
}
if (phase === 'away') {
const p = pt / AWAY_MS;
const e = p * p;
ctx.save();
ctx.translate(ECX + 300 * e, ECY - 70 * e);
ctx.rotate(-0.16 * e);
ctx.scale(1 - 0.35 * e, 1 - 0.35 * e);
ctx.translate(-ECX, -ECY);
drawEnvelope(h, letterTop, deg, sealScale);
ctx.restore();
} else {
drawEnvelope(h, letterTop, deg, sealScale);
}
}
send.addEventListener('click', function (): void { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize(): void {
fitScene();
for (const f of resizeHooks) { f(); }
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("sb") + " is-demo"} ref={rootRef}>
<div className={cx("sb__scene")} data-role="scene">
<canvas className={cx("sb__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<button className={cx("ev__send")} data-role="send" type="button"><span className={cx("ev__label")} data-role="label">메일 보내기</span></button>
<p className={cx("sb__status")} data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
);
}05 · 편지봉투로 바뀌어 덮개가 접히고 봉해지는 메일 전송 버튼 — 측정 누적면적 24.408% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 덮개 끝점 — 윗변(y = EY)을 축으로 deg 만큼 세운다. 90° 에서 화면 쪽(z 음수)으로 가장 튀어나온다
function flapTip(deg) {
const a = deg * Math.PI / 180;
return [ECX, EY + FLAP_H * Math.cos(a), -FLAP_H * Math.sin(a)];
}
// 90° 를 넘었는가 — 넘으면 덮개의 안쪽 면이 보이고 몸통 뒤에 그린다
function flapInsideVisible(deg) { return deg > 90; }
function flapDegAt(t) { return FLAP_DEG * (1 - easeInOut(t / FLAP_MS)); }
06보내는 중 광택 띠가 지나가고 포인터 기울기를 따라 반사광이 옮겨 가는 버튼
보내는 1,700ms 동안 폭 46 광택 띠가 skewX(-22deg) 로 기운 채 820ms 마다 −92px 에서 278px 까지 버튼을 가로지르고, 버튼은 포인터 쪽으로 남은 거리의 12% 씩 다가가는 기울기를 받아 최대 14° 돈다. 반사광 중심은 포인터 좌표가 아니라 그 늦은 기울기에서 다시 계산하므로, 시연 포인터를 따라갈 때 기울기가 최대 2.8° 늦어도 빛이 같은 만큼 늦어 둘이 어긋나지 않는다. 결제하기나 프리미엄 신청처럼 눈길을 붙잡아야 하는 버튼에 맞고, 목표 기울기의 90% 까지 다가가는 데 304ms 가 걸린다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__scene" data-role="scene">
<span class="gl__glow" data-role="glow" aria-hidden="true"></span>
<span class="gl__shadow" data-role="shadow" aria-hidden="true"></span>
<button class="gl__send" data-role="send" type="button">
<span class="gl__spot" data-role="spot" aria-hidden="true"></span>
<span class="gl__gloss" data-role="gloss" aria-hidden="true"></span>
<span class="gl__label" data-role="label">결제하기</span>
</button>
<p class="sb__status" data-role="status" aria-live="off">결제할 준비 됨</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.sb {
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;
}
// 장면 판 — 480×300 좌표로 짜 두고 스크립트가 상자 폭에 맞춰 scale 한다.
// 기준점이 왼쪽 위 모서리인 이유: 판이 상자 왼쪽 위에 붙어 있어 모서리 기준으로 줄여야 줄인 판이 상자와 겹친다
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .9);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: $sp-3;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: $r-control;
}
.sb { background: #221d30; }
// 바닥 반사 — 진하기·위치는 스크립트가 광택 띠 진행률과 기울기에서 정한다
.gl__glow {
position: absolute;
left: 100px;
top: 150px;
width: 280px;
height: 96px;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(255, 200, 216, .55), rgba(255, 200, 216, 0) 70%);
opacity: .2;
}
.gl__shadow {
position: absolute;
left: 140px;
top: 116px;
width: 200px;
height: 52px;
border-radius: $r-pill;
background: rgba(0, 0, 0, .5);
filter: blur(12px);
}
// 버튼 자리 = 스크립트의 BTN_CX 240 · BTN_CY 140 · 232×64
.gl__send {
position: absolute;
left: 124px;
top: 108px;
width: 232px;
height: 64px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-pill;
overflow: hidden;
background: linear-gradient(135deg, #ffd9c7, #ffc4d8);
color: $subject-ink;
font: inherit;
font-weight: 700;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.gl__send:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 4px;
}
// 반사광 — 위치는 스크립트가 기울기에서 계산한 translate 로만 옮긴다
.gl__spot {
position: absolute;
left: 0;
top: 0;
width: 140px;
height: 140px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, .8), rgba(255, 255, 255, 0) 62%);
pointer-events: none;
}
.gl__gloss {
position: absolute;
left: 0;
top: -12px;
width: 46px;
height: 88px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .75), rgba(255, 255, 255, 0));
pointer-events: none;
}
.gl__label {
position: relative;
pointer-events: none;
}
@media (max-width: 340px) {
// 이 폭에서는 장면이 0.6 배 남짓으로 줄어든다 — 상태 줄 글자를 키워 줄인 뒤에도 읽히게 한다
.sb__status { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임은 전부 스크립트가 그리고, 스크립트는 reduce 에서 루프를 돌리지 않고 결과 상태만 둔다. CSS 쪽 전환도 막아 둔다
.sb__scene * {
transition: none !important;
animation: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sb {
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);
}
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: 16px;
bottom: 12px;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: 12px;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: 12px;
}
.sb {
background: #221d30;
}
.gl__glow {
position: absolute;
left: 100px;
top: 150px;
width: 280px;
height: 96px;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(255, 200, 216, 0.55), rgba(255, 200, 216, 0) 70%);
opacity: 0.2;
}
.gl__shadow {
position: absolute;
left: 140px;
top: 116px;
width: 200px;
height: 52px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.5);
filter: blur(12px);
}
.gl__send {
position: absolute;
left: 124px;
top: 108px;
width: 232px;
height: 64px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
overflow: hidden;
background: linear-gradient(135deg, #ffd9c7, #ffc4d8);
color: #17141a;
font: inherit;
font-weight: 700;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.gl__send:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 4px;
}
.gl__spot {
position: absolute;
left: 0;
top: 0;
width: 140px;
height: 140px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0) 62%);
pointer-events: none;
}
.gl__gloss {
position: absolute;
left: 0;
top: -12px;
width: 46px;
height: 88px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0));
pointer-events: none;
}
.gl__label {
position: relative;
pointer-events: none;
}
@media (max-width: 340px) {
.sb__status {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__scene * {
transition: none !important;
animation: none !important;
}
}(function () {
const root = document.getElementById("sb");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK = [38, 33, 52];
const PAPER = [255, 252, 246];
const CREAM = [255, 243, 224];
const SKY = [158, 208, 255];
const LILAC = [200, 184, 255];
const MINT = [156, 228, 204];
const PINK = [255, 182, 204];
const PEACH = [255, 214, 184];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOut(x) { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]');
const statusEl = root.querySelector('[data-role="status"]');
if (!sceneEl || !statusEl) {
return;
}
const scene = sceneEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
resetIdle();
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene() {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX, clientY) {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
// 버튼 자리(장면 px) — scss 의 .gl__send 위치·크기와 같은 값
const BTN_CX = 240;
const BTN_CY = 140;
const BTN_W = 232;
const BTN_H = 64;
// 광택 띠가 버튼을 한 번 가로지르는 시간
const SWEEP_MS = 820;
// 광택 띠 너비(px) — 대각으로 기울여(skewX −22°) 지나간다
const GLOSS_WIDTH = 46;
// 기울기가 포인터 쪽으로 다가가는 비율 — 16ms 한 걸음마다 남은 거리의 12% 만큼 간다(관성 lerp)
const TILT_LERP = 0.12;
const TILT_MAX_DEG = 14;
const SPOT = 140;
const SEND_MS = 1700;
const DEMO_CYCLE = 3000;
const TXT_IDLE = '결제하기';
const TXT_SENDING = '보내는 중';
const TXT_SENT = '결제 완료';
const TXT_READY = '결제할 준비 됨';
const TXT_SEND = '결제 요청을 보내는 중';
const TXT_DONE = '결제 완료 — 영수증을 메일로 보냈어요';
const sendEl = root.querySelector('[data-role="send"]');
const labelEl = root.querySelector('[data-role="label"]');
const glossEl = root.querySelector('[data-role="gloss"]');
const spotEl = root.querySelector('[data-role="spot"]');
const shadowEl = root.querySelector('[data-role="shadow"]');
const glowEl = root.querySelector('[data-role="glow"]');
if (!sendEl || !labelEl || !glossEl || !spotEl || !shadowEl || !glowEl) {
return;
}
const glow = glowEl;
const send = sendEl;
const label = labelEl;
const gloss = glossEl;
const spot = spotEl;
const shadow = shadowEl;
let phase = 'idle';
let pt = 0;
let tx = 0;
let ty = 0;
let gx = 0;
let gy = 0;
let demoNext = 200;
// 관성 lerp 한 걸음 — 목표로 곧장 가지 않고 남은 거리의 TILT_LERP 만큼만 간다
function follow(cur, goal) { return cur + (goal - cur) * TILT_LERP; }
// 광택 띠의 x — 한 번 지나가는 동안 왼쪽 밖(−2×너비)에서 오른쪽 밖(버튼 너비 + 너비)까지
function glossX(t) {
const p = (t % SWEEP_MS) / SWEEP_MS;
return lerp(-GLOSS_WIDTH * 2, BTN_W + GLOSS_WIDTH, easeInOut(p));
}
// 하이라이트 중심 — 포인터가 아니라 지금의 기울기(tx, ty)에서 다시 계산한다. 그래서 반사광도 늦게 따라온다
function spotCenter() { return [BTN_W / 2 + tx * BTN_W * 0.38, BTN_H / 2 + ty * BTN_H * 0.5]; }
function resetIdle() {
phase = 'idle';
pt = 0;
label.textContent = TXT_IDLE;
say(TXT_READY);
}
function finish() {
phase = 'done';
pt = 0;
label.textContent = TXT_SENT;
say(TXT_DONE);
}
function settle() { finish(); }
function press() {
if (phase === 'sending') {
return;
}
if (still) {
finish();
return;
}
phase = 'sending';
pt = 0;
label.textContent = TXT_SENDING;
say(TXT_SEND);
}
function aim(clientX, clientY) {
const p = toScene(clientX, clientY);
gx = clamp((p[0] - BTN_CX) / (BTN_W / 2 + 40), -1, 1);
gy = clamp((p[1] - BTN_CY) / (BTN_H / 2 + 60), -1, 1);
}
function step() {
if (still) {
tx = gx;
ty = gy;
return;
}
tx = follow(tx, gx);
ty = follow(ty, gy);
if (phase !== 'sending') {
return;
}
pt += FIXED_DT;
if (pt >= SEND_MS) {
finish();
}
}
function demoTick(t) {
// 시연 포인터 — 버튼 둘레를 리사주 곡선으로 돈다
gx = 0.85 * Math.cos(t * 0.0021);
gy = 0.7 * Math.sin(t * 0.0033);
if (t < demoNext) {
return;
}
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw() {
send.style.transform = 'perspective(520px) rotateX(' + (-ty * TILT_MAX_DEG).toFixed(2) + 'deg) rotateY(' + (tx * TILT_MAX_DEG).toFixed(2) + 'deg)';
const c = spotCenter();
spot.style.transform = 'translate(' + (c[0] - SPOT / 2).toFixed(1) + 'px,' + (c[1] - SPOT / 2).toFixed(1) + 'px)';
spot.style.opacity = (0.45 + 0.4 * Math.min(1, Math.hypot(tx, ty))).toFixed(3);
// 그림자는 기울기 반대쪽으로 크게(가로 22px·세로 14px) 밀린다 — 같은 tx·ty 가 버튼 밖 넓은 바닥까지 전파된다
shadow.style.transform = 'translate(' + (-tx * 22).toFixed(1) + 'px,' + (14 - ty * 14).toFixed(1) + 'px) scale(' + (1 - Math.hypot(tx, ty) * 0.05).toFixed(3) + ')';
// 바닥 반사 — 광택 띠가 버튼 가운데를 지날 때 가장 밝다(띠 진행률의 사인). 위치는 기울기 쪽으로
let band = 0;
if (phase === 'sending') {
band = Math.sin(Math.PI * ((pt % SWEEP_MS) / SWEEP_MS));
}
glow.style.transform = 'translate(' + (tx * 24).toFixed(1) + 'px,' + (ty * 10).toFixed(1) + 'px)';
glow.style.opacity = (0.2 + 0.6 * band).toFixed(3);
if (phase === 'sending') {
gloss.style.opacity = '';
gloss.style.transform = 'translateX(' + glossX(pt).toFixed(1) + 'px) skewX(-22deg)';
}
else {
gloss.style.opacity = '0';
}
}
gloss.style.width = GLOSS_WIDTH + 'px';
root.addEventListener('pointermove', function (ev) { wake(); aim(ev.clientX, ev.clientY); });
root.addEventListener('pointerleave', function () { gx = 0; gy = 0; });
send.addEventListener('click', function () { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize() {
fitScene();
for (const f of resizeHooks) {
f();
}
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// SendGlossSweepPointer - vanilla 판(06-send-btn-gloss-sweep-pointer) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SendGlossSweepPointer.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SendGlossSweepPointer() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK: RGB = [38, 33, 52];
const PAPER: RGB = [255, 252, 246];
const CREAM: RGB = [255, 243, 224];
const SKY: RGB = [158, 208, 255];
const LILAC: RGB = [200, 184, 255];
const MINT: RGB = [156, 228, 204];
const PINK: RGB = [255, 182, 204];
const PEACH: RGB = [255, 214, 184];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function easeOut(x: number): number { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
if (!sceneEl || !statusEl) { return; }
const scene: HTMLElement = sceneEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks: Array<() => void> = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
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');
resetIdle();
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene(): void {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX: number, clientY: number): [number, number] {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
type Phase = 'idle' | 'sending' | 'done';
// 버튼 자리(장면 px) — scss 의 .gl__send 위치·크기와 같은 값
const BTN_CX = 240;
const BTN_CY = 140;
const BTN_W = 232;
const BTN_H = 64;
// 광택 띠가 버튼을 한 번 가로지르는 시간
const SWEEP_MS = 820;
// 광택 띠 너비(px) — 대각으로 기울여(skewX −22°) 지나간다
const GLOSS_WIDTH = 46;
// 기울기가 포인터 쪽으로 다가가는 비율 — 16ms 한 걸음마다 남은 거리의 12% 만큼 간다(관성 lerp)
const TILT_LERP = 0.12;
const TILT_MAX_DEG = 14;
const SPOT = 140;
const SEND_MS = 1700;
const DEMO_CYCLE = 3000;
const TXT_IDLE = '결제하기';
const TXT_SENDING = '보내는 중';
const TXT_SENT = '결제 완료';
const TXT_READY = '결제할 준비 됨';
const TXT_SEND = '결제 요청을 보내는 중';
const TXT_DONE = '결제 완료 — 영수증을 메일로 보냈어요';
const sendEl = root.querySelector('[data-role="send"]') as HTMLButtonElement | null;
const labelEl = root.querySelector('[data-role="label"]') as HTMLElement | null;
const glossEl = root.querySelector('[data-role="gloss"]') as HTMLElement | null;
const spotEl = root.querySelector('[data-role="spot"]') as HTMLElement | null;
const shadowEl = root.querySelector('[data-role="shadow"]') as HTMLElement | null;
const glowEl = root.querySelector('[data-role="glow"]') as HTMLElement | null;
if (!sendEl || !labelEl || !glossEl || !spotEl || !shadowEl || !glowEl) { return; }
const glow: HTMLElement = glowEl;
const send: HTMLButtonElement = sendEl;
const label: HTMLElement = labelEl;
const gloss: HTMLElement = glossEl;
const spot: HTMLElement = spotEl;
const shadow: HTMLElement = shadowEl;
let phase: Phase = 'idle';
let pt = 0;
let tx = 0;
let ty = 0;
let gx = 0;
let gy = 0;
let demoNext = 200;
// 관성 lerp 한 걸음 — 목표로 곧장 가지 않고 남은 거리의 TILT_LERP 만큼만 간다
function follow(cur: number, goal: number): number { return cur + (goal - cur) * TILT_LERP; }
// 광택 띠의 x — 한 번 지나가는 동안 왼쪽 밖(−2×너비)에서 오른쪽 밖(버튼 너비 + 너비)까지
function glossX(t: number): number {
const p = (t % SWEEP_MS) / SWEEP_MS;
return lerp(-GLOSS_WIDTH * 2, BTN_W + GLOSS_WIDTH, easeInOut(p));
}
// 하이라이트 중심 — 포인터가 아니라 지금의 기울기(tx, ty)에서 다시 계산한다. 그래서 반사광도 늦게 따라온다
function spotCenter(): [number, number] { return [BTN_W / 2 + tx * BTN_W * 0.38, BTN_H / 2 + ty * BTN_H * 0.5]; }
function resetIdle(): void {
phase = 'idle';
pt = 0;
label.textContent = TXT_IDLE;
say(TXT_READY);
}
function finish(): void {
phase = 'done';
pt = 0;
label.textContent = TXT_SENT;
say(TXT_DONE);
}
function settle(): void { finish(); }
function press(): void {
if (phase === 'sending') { return; }
if (still) { finish(); return; }
phase = 'sending';
pt = 0;
label.textContent = TXT_SENDING;
say(TXT_SEND);
}
function aim(clientX: number, clientY: number): void {
const p = toScene(clientX, clientY);
gx = clamp((p[0] - BTN_CX) / (BTN_W / 2 + 40), -1, 1);
gy = clamp((p[1] - BTN_CY) / (BTN_H / 2 + 60), -1, 1);
}
function step(): void {
if (still) { tx = gx; ty = gy; return; }
tx = follow(tx, gx);
ty = follow(ty, gy);
if (phase !== 'sending') { return; }
pt += FIXED_DT;
if (pt >= SEND_MS) { finish(); }
}
function demoTick(t: number): void {
// 시연 포인터 — 버튼 둘레를 리사주 곡선으로 돈다
gx = 0.85 * Math.cos(t * 0.0021);
gy = 0.7 * Math.sin(t * 0.0033);
if (t < demoNext) { return; }
demoNext += DEMO_CYCLE;
resetIdle();
press();
}
function draw(): void {
send.style.transform = 'perspective(520px) rotateX(' + (-ty * TILT_MAX_DEG).toFixed(2) + 'deg) rotateY(' + (tx * TILT_MAX_DEG).toFixed(2) + 'deg)';
const c = spotCenter();
spot.style.transform = 'translate(' + (c[0] - SPOT / 2).toFixed(1) + 'px,' + (c[1] - SPOT / 2).toFixed(1) + 'px)';
spot.style.opacity = (0.45 + 0.4 * Math.min(1, Math.hypot(tx, ty))).toFixed(3);
// 그림자는 기울기 반대쪽으로 크게(가로 22px·세로 14px) 밀린다 — 같은 tx·ty 가 버튼 밖 넓은 바닥까지 전파된다
shadow.style.transform = 'translate(' + (-tx * 22).toFixed(1) + 'px,' + (14 - ty * 14).toFixed(1) + 'px) scale(' + (1 - Math.hypot(tx, ty) * 0.05).toFixed(3) + ')';
// 바닥 반사 — 광택 띠가 버튼 가운데를 지날 때 가장 밝다(띠 진행률의 사인). 위치는 기울기 쪽으로
let band = 0;
if (phase === 'sending') { band = Math.sin(Math.PI * ((pt % SWEEP_MS) / SWEEP_MS)); }
glow.style.transform = 'translate(' + (tx * 24).toFixed(1) + 'px,' + (ty * 10).toFixed(1) + 'px)';
glow.style.opacity = (0.2 + 0.6 * band).toFixed(3);
if (phase === 'sending') {
gloss.style.opacity = '';
gloss.style.transform = 'translateX(' + glossX(pt).toFixed(1) + 'px) skewX(-22deg)';
} else {
gloss.style.opacity = '0';
}
}
gloss.style.width = GLOSS_WIDTH + 'px';
root.addEventListener('pointermove', function (ev: PointerEvent): void { wake(); aim(ev.clientX, ev.clientY); });
root.addEventListener('pointerleave', function (): void { gx = 0; gy = 0; });
send.addEventListener('click', function (): void { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize(): void {
fitScene();
for (const f of resizeHooks) { f(); }
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("sb") + " is-demo"} ref={rootRef}>
<div className={cx("sb__scene")} data-role="scene">
<span className={cx("gl__glow")} data-role="glow" aria-hidden="true"></span>
<span className={cx("gl__shadow")} data-role="shadow" aria-hidden="true"></span>
<button className={cx("gl__send")} data-role="send" type="button">
<span className={cx("gl__spot")} data-role="spot" aria-hidden="true"></span>
<span className={cx("gl__gloss")} data-role="gloss" aria-hidden="true"></span>
<span className={cx("gl__label")} data-role="label">결제하기</span>
</button>
<p className={cx("sb__status")} data-role="status" aria-live="off">결제할 준비 됨</p>
</div>
</div>
);
}06 · 보내는 중 광택 띠가 지나가고 포인터 기울기를 따라 반사광이 옮겨 가는 버튼 — 측정 누적면적 17.132% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 관성 lerp 한 걸음 — 목표로 곧장 가지 않고 남은 거리의 TILT_LERP 만큼만 간다
function follow(cur, goal) { return cur + (goal - cur) * TILT_LERP; }
// 광택 띠의 x — 한 번 지나가는 동안 왼쪽 밖(−2×너비)에서 오른쪽 밖(버튼 너비 + 너비)까지
function glossX(t) {
const p = (t % SWEEP_MS) / SWEEP_MS;
return lerp(-GLOSS_WIDTH * 2, BTN_W + GLOSS_WIDTH, easeInOut(p));
}
// 하이라이트 중심 — 포인터가 아니라 지금의 기울기(tx, ty)에서 다시 계산한다. 그래서 반사광도 늦게 따라온다
function spotCenter() { return [BTN_W / 2 + tx * BTN_W * 0.38, BTN_H / 2 + ty * BTN_H * 0.5]; }
07글자가 하나씩 흩어졌다가 '보냈어요'로 다시 모이는 버튼 글자
스크립트가 문구를 한 글자씩 쪼개 34ms 간격으로 흩는데, 방향·거리·회전을 글자 번호에 묶인 노이즈에서 읽어서 한국어 라벨 '댓글 보내기' 여섯 자는 32.3~42.2px 씩 저마다 다른 쪽으로 날아간다. 새 문구 '보냈어요' 는 감쇠 코사인 springLeft 를 따라 반대편에서 들어와 186ms 에 25.9% 지나쳤다가 520ms 에 0.9% 만 남기고, 알약 너비도 이 두 한국어 문구 기준으로 139px 에서 118.6px 로 같은 곡선을 타되 양끝 반원은 따로 옮겨 타원으로 늘어나지 않는다. 댓글 등록이나 피드백 보내기에 맞고, 네 자인 '보냈어요' 로 바뀌는 데 796ms, 여섯 자인 '댓글 보내기' 로 돌아갈 때는 864ms 가 걸린다(140 + 글자 수 × 34 + 520).

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__scene" data-role="scene">
<span class="lt__box" aria-hidden="true"><span class="lt__line"></span><span class="lt__line lt__line--short"></span></span>
<button class="lt__send" data-role="send" type="button">
<span class="lt__pill" aria-hidden="true">
<span class="lt__mid" data-role="mid"></span>
<span class="lt__cap" data-role="capl"></span>
<span class="lt__cap" data-role="capr"></span>
</span>
<span class="lt__letters" data-role="letters" data-glyph="lt__glyph"></span>
</button>
<p class="sb__status" data-role="status" aria-live="off">댓글을 보낼 준비 됨</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sb {
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;
}
// 장면 판 — 480×300 좌표로 짜 두고 스크립트가 상자 폭에 맞춰 scale 한다.
// 기준점이 왼쪽 위 모서리인 이유: 판이 상자 왼쪽 위에 붙어 있어 모서리 기준으로 줄여야 줄인 판이 상자와 겹친다
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .9);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: $sp-3;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: $r-control;
}
.sb { background: #eaf6f1; }
.lt__box {
position: absolute;
left: 100px;
top: 36px;
width: 280px;
height: 64px;
box-sizing: border-box;
padding: $sp-4;
border-radius: $r-sm;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, .08);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.lt__line {
display: block;
height: 8px;
border-radius: $r-pill;
background: #e3ece8;
}
.lt__line--short { width: 55%; }
// 버튼 판 = 가운데 x 240 기준. 너비는 스크립트가 두 문구 중 긴 쪽에 맞춰 한 번 정한다
.lt__send {
position: absolute;
left: 100px;
top: 128px;
width: 280px;
height: 52px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-pill;
background: none;
color: $subject-ink;
font: inherit;
cursor: pointer;
}
.lt__send:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 알약 = 가운데 몸통(scaleX) + 양끝 반원 두 개(translateX). 셋 다 판 가운데에 겹쳐 두고 스크립트가 벌린다
.lt__pill {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.lt__mid,
.lt__cap {
position: absolute;
top: 0;
height: 52px;
background: #b4ecd9;
}
.lt__mid {
left: calc(50% - 50px);
width: 100px;
}
.lt__cap {
left: calc(50% - 26px);
width: 52px;
border-radius: 50%;
}
.lt__letters {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.lt__glyph {
display: inline-block;
font-weight: 700;
font-size: 17px;
line-height: 1;
white-space: pre;
}
@media (max-width: 340px) {
// 이 폭에서는 장면이 0.6 배 남짓으로 줄어든다 — 상태 줄 글자를 키워 줄인 뒤에도 읽히게 한다
.sb__status { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임은 전부 스크립트가 그리고, 스크립트는 reduce 에서 루프를 돌리지 않고 결과 상태만 둔다. CSS 쪽 전환도 막아 둔다
.sb__scene * {
transition: none !important;
animation: none !important;
}
}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);
}
.sb {
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);
}
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: 16px;
bottom: 12px;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: 12px;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: 12px;
}
.sb {
background: #eaf6f1;
}
.lt__box {
position: absolute;
left: 100px;
top: 36px;
width: 280px;
height: 64px;
box-sizing: border-box;
padding: 16px;
border-radius: 8px;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.08);
display: flex;
flex-direction: column;
gap: 8px;
}
.lt__line {
display: block;
height: 8px;
border-radius: 999px;
background: #e3ece8;
}
.lt__line--short {
width: 55%;
}
.lt__send {
position: absolute;
left: 100px;
top: 128px;
width: 280px;
height: 52px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: #17141a;
font: inherit;
cursor: pointer;
}
.lt__send:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.lt__pill {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.lt__mid,
.lt__cap {
position: absolute;
top: 0;
height: 52px;
background: #b4ecd9;
}
.lt__mid {
left: calc(50% - 50px);
width: 100px;
}
.lt__cap {
left: calc(50% - 26px);
width: 52px;
border-radius: 50%;
}
.lt__letters {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.lt__glyph {
display: inline-block;
font-weight: 700;
font-size: 17px;
line-height: 1;
white-space: pre;
}
@media (max-width: 340px) {
.sb__status {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__scene * {
transition: none !important;
animation: none !important;
}
}(function () {
const root = document.getElementById("sb");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK = [38, 33, 52];
const PAPER = [255, 252, 246];
const CREAM = [255, 243, 224];
const SKY = [158, 208, 255];
const LILAC = [200, 184, 255];
const MINT = [156, 228, 204];
const PINK = [255, 182, 204];
const PEACH = [255, 214, 184];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOut(x) { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]');
const statusEl = root.querySelector('[data-role="status"]');
if (!sceneEl || !statusEl) {
return;
}
const scene = sceneEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
resetIdle();
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene() {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX, clientY) {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
// 2D 값 노이즈 — 격자점마다 시드 난수 값을 두고 사이를 부드럽게 보간한다. 결과는 -1~1
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function at(xi, yi) { return vals[perm[(xi & 255) + perm[yi & 255]]]; }
return function (x, y) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = lerp(at(xi, yi), at(xi + 1, yi), u);
const b = lerp(at(xi, yi + 1), at(xi + 1, yi + 1), u);
return lerp(a, b, v);
};
}
const BTN_H = 52;
// 알약 가운데 몸통의 기준 너비(px) — scaleX 로 늘이고 양끝 반원은 따로 옮긴다
const MID_BASE = 100;
const PAD_X = 28;
// 글자 사이 출발 시차
const LETTER_STAGGER_MS = 34;
// 흩어지는 거리(px) — 글자마다 노이즈로 0.55~1 배
const SCATTER_AMP = 46;
// 새 글자가 스프링으로 제자리에 드는 시간 — 흔들림 폭이 처음의 3% 밑으로 줄어드는 시각
const REGATHER_MS = 520;
const REGATHER_ZETA = 0.42;
const REGATHER_DELAY = 140;
const SCATTER_MS = 360;
const DEMO_CYCLE = 2000;
const TXT_IDLE = '댓글 보내기';
const TXT_SENT = '보냈어요';
const TXT_READY = '댓글을 보낼 준비 됨';
const TXT_SWAP = '글자를 흩었다가 다시 모으는 중';
const TXT_DONE = '보냄 — 댓글이 올라갔어요';
const TXT_BACK = '새 댓글을 쓸 준비 됨';
const sendEl = root.querySelector('[data-role="send"]');
const lettersEl = root.querySelector('[data-role="letters"]');
const capLEl = root.querySelector('[data-role="capl"]');
const capREl = root.querySelector('[data-role="capr"]');
const midEl = root.querySelector('[data-role="mid"]');
if (!sendEl || !lettersEl || !capLEl || !capREl || !midEl) {
return;
}
const send = sendEl;
const letters = lettersEl;
const capL = capLEl;
const capR = capREl;
const mid = midEl;
const noise = makeNoise(466);
// 스프링 감쇠 곡선의 두 값 — REGATHER_MS 에 폭이 3% 가 되게 감쇠율을 정하고, 감쇠비로 진동수를 낸다
const decayRate = Math.log(1 / 0.03) / REGATHER_MS;
const omegaD = (decayRate / REGATHER_ZETA) * Math.sqrt(1 - REGATHER_ZETA * REGATHER_ZETA);
let phase = 'idle';
let pt = 0;
let sent = false;
let oldGlyphs = [];
let newGlyphs = [];
let w0 = 0;
let w1 = 0;
let demoNext = 200;
// 남은 변위 비율 — 1 에서 시작해 진동하며 0 으로. 감쇠 사인(코사인) 하나로 스프링 해를 그린다
function springLeft(t) {
if (t <= 0) {
return 1;
}
return Math.exp(-decayRate * t) * Math.cos(omegaD * t);
}
// 글자 i 의 흩어질 방향·거리·회전 — 같은 시드면 같은 글자는 같은 쪽으로 난다
function scatterOf(i, row) {
const a = -Math.PI / 2 + noise(i * 0.61, row) * Math.PI * 0.9;
const d = SCATTER_AMP * (0.55 + 0.45 * (noise(i * 0.61, row + 7.3) * 0.5 + 0.5));
return [Math.cos(a) * d, Math.sin(a) * d, noise(i * 0.61, row + 13.1) * 40];
}
function makeLayer(text, row) {
const out = [];
Array.from(text).forEach(function (ch, i) {
const s = document.createElement('span');
s.className = letters.getAttribute('data-glyph') || '';
s.textContent = ch === ' ' ? ' ' : ch;
letters.appendChild(s);
const v = scatterOf(i, row);
out.push({ el: s, ox: v[0], oy: v[1], rot: v[2] });
});
return out;
}
function layerWidth(gs) {
let w = 0;
for (const g of gs) {
w += g.el.getBoundingClientRect().width / k;
}
return w > 0 ? w : gs.length * 14;
}
// 알약 너비 w — 양끝 반원은 ±(w/2 − 높이/2) 로 옮기고 가운데만 scaleX 로 늘인다(모서리가 늘어나 찌그러지지 않는다)
function setPill(w) {
const half = Math.max(0, w / 2 - BTN_H / 2);
capL.style.transform = 'translateX(' + (-half).toFixed(2) + 'px)';
capR.style.transform = 'translateX(' + half.toFixed(2) + 'px)';
mid.style.transform = 'scaleX(' + ((half * 2) / MID_BASE).toFixed(4) + ')';
}
function clearLetters() {
while (letters.firstChild) {
letters.removeChild(letters.firstChild);
}
}
function showLabel(text) {
clearLetters();
oldGlyphs = makeLayer(text, 0.5);
w0 = layerWidth(oldGlyphs) + PAD_X * 2;
setPill(w0);
send.setAttribute('aria-label', text);
}
// 버튼 판 너비 — 두 문구 중 긴 쪽 알약에 맞춰 한 번 정한다(포커스 테두리가 알약을 감싸게). 가운데 x 240 은 그대로
function fitButton() {
const probe = makeLayer(TXT_SENT, 9.9);
const w = Math.ceil(Math.max(layerWidth(probe) + PAD_X * 2, w0));
for (const g of probe) {
letters.removeChild(g.el);
}
send.style.width = w + 'px';
send.style.left = (W / 2 - w / 2).toFixed(1) + 'px';
}
function resetIdle() {
phase = 'idle';
pt = 0;
sent = false;
newGlyphs = [];
showLabel(TXT_IDLE);
fitButton();
say(TXT_READY);
}
function finish() {
phase = 'done';
pt = 0;
showLabel(sent ? TXT_SENT : TXT_IDLE);
say(sent ? TXT_DONE : TXT_BACK);
}
function settle() {
sent = true;
finish();
}
function press() {
if (phase === 'swap') {
return;
}
sent = !sent;
if (still) {
finish();
return;
}
const next = sent ? TXT_SENT : TXT_IDLE;
for (const g of oldGlyphs) {
g.el.setAttribute('aria-hidden', 'true');
}
newGlyphs = makeLayer(next, 3.7);
w1 = layerWidth(newGlyphs) + PAD_X * 2;
send.setAttribute('aria-label', next);
phase = 'swap';
pt = 0;
say(TXT_SWAP);
}
function swapEnd() {
return REGATHER_DELAY + newGlyphs.length * LETTER_STAGGER_MS + REGATHER_MS;
}
function step() {
if (phase !== 'swap') {
return;
}
pt += FIXED_DT;
if (pt >= swapEnd()) {
finish();
}
}
function demoTick(t) {
if (t < demoNext) {
return;
}
demoNext += DEMO_CYCLE;
if (phase === 'swap') {
finish();
}
press();
}
function draw() {
if (phase !== 'swap') {
return;
}
oldGlyphs.forEach(function (g, i) {
const p = clamp((pt - i * LETTER_STAGGER_MS) / SCATTER_MS, 0, 1);
const e = easeOut(p);
g.el.style.transform = 'translate(' + (g.ox * e).toFixed(2) + 'px,' + (g.oy * e + 18 * p * p).toFixed(2) + 'px) rotate(' + (g.rot * e).toFixed(1) + 'deg)';
g.el.style.opacity = (1 - p).toFixed(3);
});
newGlyphs.forEach(function (g, j) {
const tau = pt - REGATHER_DELAY - j * LETTER_STAGGER_MS;
const f = springLeft(tau);
// 새 글자는 흩어질 방향의 반대편에서 들어온다
g.el.style.transform = 'translate(' + (-g.ox * f).toFixed(2) + 'px,' + (-g.oy * f).toFixed(2) + 'px) rotate(' + (-g.rot * f).toFixed(1) + 'deg)';
g.el.style.opacity = clamp(tau / 120, 0, 1).toFixed(3);
});
// 너비도 같은 스프링 곡선으로 — 새 너비를 살짝 지나쳤다가 돌아온다
setPill(w1 + (w0 - w1) * springLeft(pt - REGATHER_DELAY));
}
send.addEventListener('click', function () { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize() {
fitScene();
for (const f of resizeHooks) {
f();
}
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// SendLabelLetterScatter - vanilla 판(07-send-btn-label-letter-scatter) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SendLabelLetterScatter.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SendLabelLetterScatter() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK: RGB = [38, 33, 52];
const PAPER: RGB = [255, 252, 246];
const CREAM: RGB = [255, 243, 224];
const SKY: RGB = [158, 208, 255];
const LILAC: RGB = [200, 184, 255];
const MINT: RGB = [156, 228, 204];
const PINK: RGB = [255, 182, 204];
const PEACH: RGB = [255, 214, 184];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function easeOut(x: number): number { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
if (!sceneEl || !statusEl) { return; }
const scene: HTMLElement = sceneEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks: Array<() => void> = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
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');
resetIdle();
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene(): void {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX: number, clientY: number): [number, number] {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
// 2D 값 노이즈 — 격자점마다 시드 난수 값을 두고 사이를 부드럽게 보간한다. 결과는 -1~1
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function at(xi: number, yi: number): number { return vals[perm[(xi & 255) + perm[yi & 255]]]; }
return function (x: number, y: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = lerp(at(xi, yi), at(xi + 1, yi), u);
const b = lerp(at(xi, yi + 1), at(xi + 1, yi + 1), u);
return lerp(a, b, v);
};
}
type Glyph = { el: HTMLElement; ox: number; oy: number; rot: number };
type Phase = 'idle' | 'swap' | 'done';
const BTN_H = 52;
// 알약 가운데 몸통의 기준 너비(px) — scaleX 로 늘이고 양끝 반원은 따로 옮긴다
const MID_BASE = 100;
const PAD_X = 28;
// 글자 사이 출발 시차
const LETTER_STAGGER_MS = 34;
// 흩어지는 거리(px) — 글자마다 노이즈로 0.55~1 배
const SCATTER_AMP = 46;
// 새 글자가 스프링으로 제자리에 드는 시간 — 흔들림 폭이 처음의 3% 밑으로 줄어드는 시각
const REGATHER_MS = 520;
const REGATHER_ZETA = 0.42;
const REGATHER_DELAY = 140;
const SCATTER_MS = 360;
const DEMO_CYCLE = 2000;
const TXT_IDLE = '댓글 보내기';
const TXT_SENT = '보냈어요';
const TXT_READY = '댓글을 보낼 준비 됨';
const TXT_SWAP = '글자를 흩었다가 다시 모으는 중';
const TXT_DONE = '보냄 — 댓글이 올라갔어요';
const TXT_BACK = '새 댓글을 쓸 준비 됨';
const sendEl = root.querySelector('[data-role="send"]') as HTMLButtonElement | null;
const lettersEl = root.querySelector('[data-role="letters"]') as HTMLElement | null;
const capLEl = root.querySelector('[data-role="capl"]') as HTMLElement | null;
const capREl = root.querySelector('[data-role="capr"]') as HTMLElement | null;
const midEl = root.querySelector('[data-role="mid"]') as HTMLElement | null;
if (!sendEl || !lettersEl || !capLEl || !capREl || !midEl) { return; }
const send: HTMLButtonElement = sendEl;
const letters: HTMLElement = lettersEl;
const capL: HTMLElement = capLEl;
const capR: HTMLElement = capREl;
const mid: HTMLElement = midEl;
const noise = makeNoise(466);
// 스프링 감쇠 곡선의 두 값 — REGATHER_MS 에 폭이 3% 가 되게 감쇠율을 정하고, 감쇠비로 진동수를 낸다
const decayRate = Math.log(1 / 0.03) / REGATHER_MS;
const omegaD = (decayRate / REGATHER_ZETA) * Math.sqrt(1 - REGATHER_ZETA * REGATHER_ZETA);
let phase: Phase = 'idle';
let pt = 0;
let sent = false;
let oldGlyphs: Glyph[] = [];
let newGlyphs: Glyph[] = [];
let w0 = 0;
let w1 = 0;
let demoNext = 200;
// 남은 변위 비율 — 1 에서 시작해 진동하며 0 으로. 감쇠 사인(코사인) 하나로 스프링 해를 그린다
function springLeft(t: number): number {
if (t <= 0) { return 1; }
return Math.exp(-decayRate * t) * Math.cos(omegaD * t);
}
// 글자 i 의 흩어질 방향·거리·회전 — 같은 시드면 같은 글자는 같은 쪽으로 난다
function scatterOf(i: number, row: number): [number, number, number] {
const a = -Math.PI / 2 + noise(i * 0.61, row) * Math.PI * 0.9;
const d = SCATTER_AMP * (0.55 + 0.45 * (noise(i * 0.61, row + 7.3) * 0.5 + 0.5));
return [Math.cos(a) * d, Math.sin(a) * d, noise(i * 0.61, row + 13.1) * 40];
}
function makeLayer(text: string, row: number): Glyph[] {
const out: Glyph[] = [];
Array.from(text).forEach(function (ch: string, i: number): void {
const s = document.createElement('span');
s.className = letters.getAttribute('data-glyph') || '';
s.textContent = ch === ' ' ? ' ' : ch;
letters.appendChild(s);
const v = scatterOf(i, row);
out.push({ el: s, ox: v[0], oy: v[1], rot: v[2] });
});
return out;
}
function layerWidth(gs: Glyph[]): number {
let w = 0;
for (const g of gs) { w += g.el.getBoundingClientRect().width / k; }
return w > 0 ? w : gs.length * 14;
}
// 알약 너비 w — 양끝 반원은 ±(w/2 − 높이/2) 로 옮기고 가운데만 scaleX 로 늘인다(모서리가 늘어나 찌그러지지 않는다)
function setPill(w: number): void {
const half = Math.max(0, w / 2 - BTN_H / 2);
capL.style.transform = 'translateX(' + (-half).toFixed(2) + 'px)';
capR.style.transform = 'translateX(' + half.toFixed(2) + 'px)';
mid.style.transform = 'scaleX(' + ((half * 2) / MID_BASE).toFixed(4) + ')';
}
function clearLetters(): void {
while (letters.firstChild) { letters.removeChild(letters.firstChild); }
}
function showLabel(text: string): void {
clearLetters();
oldGlyphs = makeLayer(text, 0.5);
w0 = layerWidth(oldGlyphs) + PAD_X * 2;
setPill(w0);
send.setAttribute('aria-label', text);
}
// 버튼 판 너비 — 두 문구 중 긴 쪽 알약에 맞춰 한 번 정한다(포커스 테두리가 알약을 감싸게). 가운데 x 240 은 그대로
function fitButton(): void {
const probe = makeLayer(TXT_SENT, 9.9);
const w = Math.ceil(Math.max(layerWidth(probe) + PAD_X * 2, w0));
for (const g of probe) { letters.removeChild(g.el); }
send.style.width = w + 'px';
send.style.left = (W / 2 - w / 2).toFixed(1) + 'px';
}
function resetIdle(): void {
phase = 'idle';
pt = 0;
sent = false;
newGlyphs = [];
showLabel(TXT_IDLE);
fitButton();
say(TXT_READY);
}
function finish(): void {
phase = 'done';
pt = 0;
showLabel(sent ? TXT_SENT : TXT_IDLE);
say(sent ? TXT_DONE : TXT_BACK);
}
function settle(): void {
sent = true;
finish();
}
function press(): void {
if (phase === 'swap') { return; }
sent = !sent;
if (still) { finish(); return; }
const next = sent ? TXT_SENT : TXT_IDLE;
for (const g of oldGlyphs) { g.el.setAttribute('aria-hidden', 'true'); }
newGlyphs = makeLayer(next, 3.7);
w1 = layerWidth(newGlyphs) + PAD_X * 2;
send.setAttribute('aria-label', next);
phase = 'swap';
pt = 0;
say(TXT_SWAP);
}
function swapEnd(): number {
return REGATHER_DELAY + newGlyphs.length * LETTER_STAGGER_MS + REGATHER_MS;
}
function step(): void {
if (phase !== 'swap') { return; }
pt += FIXED_DT;
if (pt >= swapEnd()) { finish(); }
}
function demoTick(t: number): void {
if (t < demoNext) { return; }
demoNext += DEMO_CYCLE;
if (phase === 'swap') { finish(); }
press();
}
function draw(): void {
if (phase !== 'swap') { return; }
oldGlyphs.forEach(function (g: Glyph, i: number): void {
const p = clamp((pt - i * LETTER_STAGGER_MS) / SCATTER_MS, 0, 1);
const e = easeOut(p);
g.el.style.transform = 'translate(' + (g.ox * e).toFixed(2) + 'px,' + (g.oy * e + 18 * p * p).toFixed(2) + 'px) rotate(' + (g.rot * e).toFixed(1) + 'deg)';
g.el.style.opacity = (1 - p).toFixed(3);
});
newGlyphs.forEach(function (g: Glyph, j: number): void {
const tau = pt - REGATHER_DELAY - j * LETTER_STAGGER_MS;
const f = springLeft(tau);
// 새 글자는 흩어질 방향의 반대편에서 들어온다
g.el.style.transform = 'translate(' + (-g.ox * f).toFixed(2) + 'px,' + (-g.oy * f).toFixed(2) + 'px) rotate(' + (-g.rot * f).toFixed(1) + 'deg)';
g.el.style.opacity = clamp(tau / 120, 0, 1).toFixed(3);
});
// 너비도 같은 스프링 곡선으로 — 새 너비를 살짝 지나쳤다가 돌아온다
setPill(w1 + (w0 - w1) * springLeft(pt - REGATHER_DELAY));
}
send.addEventListener('click', function (): void { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize(): void {
fitScene();
for (const f of resizeHooks) { f(); }
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("sb") + " is-demo"} ref={rootRef}>
<div className={cx("sb__scene")} data-role="scene">
<span className={cx("lt__box")} aria-hidden="true"><span className={cx("lt__line")}></span><span className={cx("lt__line lt__line--short")}></span></span>
<button className={cx("lt__send")} data-role="send" type="button">
<span className={cx("lt__pill")} aria-hidden="true">
<span className={cx("lt__mid")} data-role="mid"></span>
<span className={cx("lt__cap")} data-role="capl"></span>
<span className={cx("lt__cap")} data-role="capr"></span>
</span>
<span className={cx("lt__letters")} data-role="letters" data-glyph={cx("lt__glyph")}></span>
</button>
<p className={cx("sb__status")} data-role="status" aria-live="off">댓글을 보낼 준비 됨</p>
</div>
</div>
);
}07 · 글자가 하나씩 흩어졌다가 '보냄'으로 다시 모이는 버튼 글자 — 측정 누적면적 4.192% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 남은 변위 비율 — 1 에서 시작해 진동하며 0 으로. 감쇠 사인(코사인) 하나로 스프링 해를 그린다
function springLeft(t) {
if (t <= 0) {
return 1;
}
return Math.exp(-decayRate * t) * Math.cos(omegaD * t);
}
// 글자 i 의 흩어질 방향·거리·회전 — 같은 시드면 같은 글자는 같은 쪽으로 난다
function scatterOf(i, row) {
const a = -Math.PI / 2 + noise(i * 0.61, row) * Math.PI * 0.9;
const d = SCATTER_AMP * (0.55 + 0.45 * (noise(i * 0.61, row + 7.3) * 0.5 + 0.5));
return [Math.cos(a) * d, Math.sin(a) * d, noise(i * 0.61, row + 13.1) * 40];
}
08전송에 실패하면 점점 잦아드는 흔들림으로 알리고 다시 보내기로 바뀌는 버튼
첫 시도는 420ms 동안 보내는 중을 보여 준 뒤 실패로 바뀌고, 336px 입력줄 전체가 12·e^(−6τ)·sin(2π·8τ) 로 흔들려 첫 꼭짓점 10.0px 에서 시작해 615ms 동안 4.9번 왕복하며 잦아든다. 흔들기를 멈추는 시각은 폭이 0.3px 밑으로 떨어지는 때를 감쇠율에서 거꾸로 계산하고, 보내기 버튼은 그 흔들림을 강성 3600·감쇠비 0.5 스프링으로 따라와 입력줄과 최대 0.62px 만 어긋난다. 네트워크 오류나 입력 누락을 알릴 때 맞고, 한 번 더 누르면 다시 보내기가 성공해 민트색으로 끝난다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__scene" data-role="scene">
<div class="sh__bar" data-role="bar">
<span class="sh__text">회의 자료 보내 드려요</span>
<button class="sh__send" data-role="send" type="button" aria-label="메시지 보내기"><svg xmlns="http://www.w3.org/2000/svg" class="sh__icon" data-role="icon-send" aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="M227.91 134.86L59.93 231a8 8 0 0 1-11.44-9.67L80 128L48.49 34.72a8 8 0 0 1 11.44-9.67l168 95.85a8 8 0 0 1-.02 13.96" opacity=".2"/><path d="m231.87 114l-168-95.89a16 16 0 0 0-22.95 19.23L71.55 128l-30.63 90.67A16 16 0 0 0 56 240a16.15 16.15 0 0 0 7.93-2.1l167.92-96.05a16 16 0 0 0 .05-27.89ZM56 224a.6.6 0 0 0 0-.12L85.74 136H144a8 8 0 0 0 0-16H85.74L56.06 32.16A.5.5 0 0 0 56 32l168 95.83Z"/></g></svg><svg xmlns="http://www.w3.org/2000/svg" class="sh__icon" data-role="icon-retry" aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0"/></g></svg></button>
</div>
<p class="sh__note" data-role="note">전송하지 못했어요</p>
<p class="sb__status sb__status--top" data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sb {
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;
}
// 장면 판 — 480×300 좌표로 짜 두고 스크립트가 상자 폭에 맞춰 scale 한다.
// 기준점이 왼쪽 위 모서리인 이유: 판이 상자 왼쪽 위에 붙어 있어 모서리 기준으로 줄여야 줄인 판이 상자와 겹친다
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .9);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: $sp-3;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: $r-control;
}
.sb { background: #f4eef6; }
.sh__bar {
position: absolute;
left: 72px;
top: 124px;
width: 336px;
height: 56px;
box-sizing: border-box;
padding: 0 $sp-2 0 $sp-5;
border-radius: $r-pill;
background: #ffffff;
box-shadow: inset 0 0 0 2px rgba(23, 20, 26, .08), 0 8px 18px rgba(23, 20, 26, .08);
display: flex;
align-items: center;
gap: $sp-3;
}
.sh__bar.is-fail { box-shadow: inset 0 0 0 2px #f0c4bc, 0 8px 18px rgba(240, 196, 188, .5); }
.sh__bar.is-ok { box-shadow: inset 0 0 0 2px #a9e3cf, 0 8px 18px rgba(23, 20, 26, .08); }
.sh__text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: $subject-ink;
font-size: 14px;
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
}
.sh__send {
position: relative;
flex: none;
width: 44px;
height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #c8b8ff;
color: $subject-ink;
cursor: pointer;
}
.sh__bar.is-fail .sh__send {
background: $error-soft;
color: $error;
}
.sh__bar.is-ok .sh__send { background: #b4ecd9; }
.sh__send:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sh__icon {
position: absolute;
left: 11px;
top: 11px;
width: 22px;
height: 22px;
}
.sh__note {
position: absolute;
left: 96px;
top: 192px;
margin: 0;
color: $error;
font-weight: 700;
font-size: 13px;
line-height: 1.4;
}
@media (max-width: 340px) {
// 이 폭에서는 장면이 0.6 배 남짓으로 줄어든다 — 상태 줄 글자를 키워 줄인 뒤에도 읽히게 한다
.sb__status { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임은 전부 스크립트가 그리고, 스크립트는 reduce 에서 루프를 돌리지 않고 결과 상태만 둔다. CSS 쪽 전환도 막아 둔다
.sb__scene * {
transition: none !important;
animation: none !important;
}
}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);
}
.sb {
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);
}
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: 16px;
bottom: 12px;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: 12px;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: 12px;
}
.sb {
background: #f4eef6;
}
.sh__bar {
position: absolute;
left: 72px;
top: 124px;
width: 336px;
height: 56px;
box-sizing: border-box;
padding: 0 8px 0 20px;
border-radius: 999px;
background: #ffffff;
box-shadow: inset 0 0 0 2px rgba(23, 20, 26, 0.08), 0 8px 18px rgba(23, 20, 26, 0.08);
display: flex;
align-items: center;
gap: 12px;
}
.sh__bar.is-fail {
box-shadow: inset 0 0 0 2px #f0c4bc, 0 8px 18px rgba(240, 196, 188, 0.5);
}
.sh__bar.is-ok {
box-shadow: inset 0 0 0 2px #a9e3cf, 0 8px 18px rgba(23, 20, 26, 0.08);
}
.sh__text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: #17141a;
font-size: 14px;
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
}
.sh__send {
position: relative;
flex: none;
width: 44px;
height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #c8b8ff;
color: #17141a;
cursor: pointer;
}
.sh__bar.is-fail .sh__send {
background: #ffe0d8;
color: #c2361a;
}
.sh__bar.is-ok .sh__send {
background: #b4ecd9;
}
.sh__send:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sh__icon {
position: absolute;
left: 11px;
top: 11px;
width: 22px;
height: 22px;
}
.sh__note {
position: absolute;
left: 96px;
top: 192px;
margin: 0;
color: #c2361a;
font-weight: 700;
font-size: 13px;
line-height: 1.4;
}
@media (max-width: 340px) {
.sb__status {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__scene * {
transition: none !important;
animation: none !important;
}
}(function () {
const root = document.getElementById("sb");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK = [38, 33, 52];
const PAPER = [255, 252, 246];
const CREAM = [255, 243, 224];
const SKY = [158, 208, 255];
const LILAC = [200, 184, 255];
const MINT = [156, 228, 204];
const PINK = [255, 182, 204];
const PEACH = [255, 214, 184];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOut(x) { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]');
const statusEl = root.querySelector('[data-role="status"]');
if (!sceneEl || !statusEl) {
return;
}
const scene = sceneEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
resetIdle();
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene() {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX, clientY) {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
// 감쇠 스프링 한 걸음 — 질량 1, 강성 stiff, 감쇠비 zeta. 속도를 먼저 바꾸고 그 속도로 위치를 옮긴다(반암시적 오일러)
function springStep(s, stiff, zeta, dtMs) {
const dt = dtMs / 1000;
const c = 2 * zeta * Math.sqrt(stiff);
const a = -stiff * (s.x - s.target) - c * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
// 처음 흔들림 폭(px)
const SHAKE_AMP_PX = 12;
// 지수 감쇠율(1/s) — 폭이 반으로 줄어드는 시간 = ln2 / SHAKE_DECAY
const SHAKE_DECAY = 6;
// 초당 왕복 수
const SHAKE_HZ = 8;
// 흔들림이 끝났다고 보는 폭(px) — 이 밑으로 줄어드는 시각까지만 흔든다
const SHAKE_REST_PX = 0.3;
const SHAKE_MS = Math.ceil(Math.log(SHAKE_AMP_PX / SHAKE_REST_PX) / SHAKE_DECAY * 1000);
// 보내기 버튼은 입력줄에 스프링으로 매달려 한 발 늦게 따라온다 — 강성을 낮추면 따라오지 못하고 제자리에 남는다
const LAG_K = 3600;
const LAG_ZETA = 0.5;
const SEND_MS = 420;
const DEMO_CYCLE = 3200;
const DEMO_RETRY_AT = 1400;
const TXT_READY = '보낼 준비 됨';
const TXT_SENDING = '보내는 중';
const TXT_FAIL = '전송 실패 — 다시 보내기를 눌러 주세요';
const TXT_DONE = '보냄 — 다시 보내기에 성공했어요';
const TXT_ARIA_SEND = '메시지 보내기';
const TXT_ARIA_RETRY = '다시 보내기';
const TXT_ARIA_SENT = '보냄';
const barEl = root.querySelector('[data-role="bar"]');
const sendEl = root.querySelector('[data-role="send"]');
const noteEl = root.querySelector('[data-role="note"]');
const iconSendEl = root.querySelector('[data-role="icon-send"]');
const iconRetryEl = root.querySelector('[data-role="icon-retry"]');
if (!barEl || !sendEl || !noteEl || !iconSendEl || !iconRetryEl) {
return;
}
const bar = barEl;
const send = sendEl;
const note = noteEl;
const iconSend = iconSendEl;
const iconRetry = iconRetryEl;
const lag = { x: 0, v: 0, target: 0 };
let phase = 'idle';
let pt = 0;
let attempt = 0;
let demoStart = -1;
let retryDone = false;
// 흔들림 식 — 사인파를 지수로 줄어드는 봉투 안에 넣는다. tau 는 초
function shakeOffset(tau) {
return SHAKE_AMP_PX * Math.exp(-SHAKE_DECAY * tau) * Math.sin(2 * Math.PI * SHAKE_HZ * tau);
}
function shakeEnvelope(tau) { return Math.exp(-SHAKE_DECAY * tau); }
function setState(cls, aria) {
bar.classList.remove('is-fail', 'is-ok');
if (cls) {
bar.classList.add(cls);
}
send.setAttribute('aria-label', aria);
const retry = cls === 'is-fail';
iconSend.style.opacity = retry ? '0' : '';
iconRetry.style.opacity = retry ? '' : '0';
}
function resetIdle() {
phase = 'idle';
pt = 0;
attempt = 0;
lag.x = 0;
lag.v = 0;
lag.target = 0;
setState('', TXT_ARIA_SEND);
note.style.opacity = '0';
bar.style.transform = '';
send.style.transform = '';
say(TXT_READY);
}
function fail() {
setState('is-fail', TXT_ARIA_RETRY);
note.style.opacity = '';
say(TXT_FAIL);
}
function succeed() {
phase = 'done';
pt = 0;
setState('is-ok', TXT_ARIA_SENT);
note.style.opacity = '0';
say(TXT_DONE);
}
function settle() {
resetIdle();
attempt = 1;
phase = 'failed';
fail();
}
function press() {
if (phase === 'sending' || phase === 'shake') {
return;
}
if (phase === 'done') {
resetIdle();
return;
}
attempt += 1;
if (still) {
// 줄인 움직임 — 흔들지 않고 결과 상태로 바로 간다
if (attempt === 1) {
phase = 'failed';
fail();
}
else {
succeed();
}
return;
}
phase = 'sending';
pt = 0;
say(TXT_SENDING);
}
function step() {
if (phase === 'sending') {
pt += FIXED_DT;
if (pt >= SEND_MS) {
pt = 0;
// 첫 시도는 실패, 다시 보내기는 성공 — 실패 화면을 보여 주는 시연 순서
if (attempt === 1) {
phase = 'shake';
fail();
}
else {
succeed();
}
}
}
else if (phase === 'shake') {
pt += FIXED_DT;
if (pt >= SHAKE_MS) {
phase = 'failed';
pt = 0;
}
}
lag.target = phase === 'shake' ? shakeOffset(pt / 1000) : 0;
springStep(lag, LAG_K, LAG_ZETA, FIXED_DT);
}
function demoTick(t) {
// 첫 시연은 200ms 뒤에 — 렌더 하네스가 로드 직후를 따로 재는 구간을 비운다
if (t < 200) {
return;
}
if (demoStart < 0 || t - demoStart >= DEMO_CYCLE) {
demoStart = t;
retryDone = false;
resetIdle();
press();
}
else if (!retryDone && t - demoStart >= DEMO_RETRY_AT) {
retryDone = true;
press();
}
}
function draw() {
const x = phase === 'shake' ? shakeOffset(pt / 1000) : 0;
// 기울기도 같은 값에서 — 밀린 쪽으로 x × 0.12° 만큼 고개를 돌린다
bar.style.transform = x === 0 ? '' : 'translateX(' + x.toFixed(2) + 'px) rotate(' + (x * 0.12).toFixed(2) + 'deg)';
let nudge = 0;
if (phase === 'sending') {
nudge = Math.sin(Math.PI * pt / SEND_MS) * 6;
}
const rel = lag.x - x + nudge;
send.style.transform = Math.abs(rel) < 0.01 ? '' : 'translateX(' + rel.toFixed(2) + 'px)';
if (phase === 'shake') {
note.style.opacity = clamp(1 - shakeEnvelope(pt / 1000) * 0.6, 0, 1).toFixed(3);
}
}
send.addEventListener('click', function () { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize() {
fitScene();
for (const f of resizeHooks) {
f();
}
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// SendFailDampedShake - vanilla 판(08-send-btn-fail-damped-shake) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SendFailDampedShake.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SendFailDampedShake() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK: RGB = [38, 33, 52];
const PAPER: RGB = [255, 252, 246];
const CREAM: RGB = [255, 243, 224];
const SKY: RGB = [158, 208, 255];
const LILAC: RGB = [200, 184, 255];
const MINT: RGB = [156, 228, 204];
const PINK: RGB = [255, 182, 204];
const PEACH: RGB = [255, 214, 184];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function easeOut(x: number): number { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
if (!sceneEl || !statusEl) { return; }
const scene: HTMLElement = sceneEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks: Array<() => void> = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
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');
resetIdle();
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene(): void {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX: number, clientY: number): [number, number] {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
type Spring = { x: number; v: number; target: number };
// 감쇠 스프링 한 걸음 — 질량 1, 강성 stiff, 감쇠비 zeta. 속도를 먼저 바꾸고 그 속도로 위치를 옮긴다(반암시적 오일러)
function springStep(s: Spring, stiff: number, zeta: number, dtMs: number): void {
const dt = dtMs / 1000;
const c = 2 * zeta * Math.sqrt(stiff);
const a = -stiff * (s.x - s.target) - c * s.v;
s.v += a * dt;
s.x += s.v * dt;
}
type Phase = 'idle' | 'sending' | 'shake' | 'failed' | 'done';
// 처음 흔들림 폭(px)
const SHAKE_AMP_PX = 12;
// 지수 감쇠율(1/s) — 폭이 반으로 줄어드는 시간 = ln2 / SHAKE_DECAY
const SHAKE_DECAY = 6;
// 초당 왕복 수
const SHAKE_HZ = 8;
// 흔들림이 끝났다고 보는 폭(px) — 이 밑으로 줄어드는 시각까지만 흔든다
const SHAKE_REST_PX = 0.3;
const SHAKE_MS = Math.ceil(Math.log(SHAKE_AMP_PX / SHAKE_REST_PX) / SHAKE_DECAY * 1000);
// 보내기 버튼은 입력줄에 스프링으로 매달려 한 발 늦게 따라온다 — 강성을 낮추면 따라오지 못하고 제자리에 남는다
const LAG_K = 3600;
const LAG_ZETA = 0.5;
const SEND_MS = 420;
const DEMO_CYCLE = 3200;
const DEMO_RETRY_AT = 1400;
const TXT_READY = '보낼 준비 됨';
const TXT_SENDING = '보내는 중';
const TXT_FAIL = '전송 실패 — 다시 보내기를 눌러 주세요';
const TXT_DONE = '보냄 — 다시 보내기에 성공했어요';
const TXT_ARIA_SEND = '메시지 보내기';
const TXT_ARIA_RETRY = '다시 보내기';
const TXT_ARIA_SENT = '보냄';
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const sendEl = root.querySelector('[data-role="send"]') as HTMLButtonElement | null;
const noteEl = root.querySelector('[data-role="note"]') as HTMLElement | null;
const iconSendEl = root.querySelector('[data-role="icon-send"]') as SVGElement | null;
const iconRetryEl = root.querySelector('[data-role="icon-retry"]') as SVGElement | null;
if (!barEl || !sendEl || !noteEl || !iconSendEl || !iconRetryEl) { return; }
const bar: HTMLElement = barEl;
const send: HTMLButtonElement = sendEl;
const note: HTMLElement = noteEl;
const iconSend: SVGElement = iconSendEl;
const iconRetry: SVGElement = iconRetryEl;
const lag: Spring = { x: 0, v: 0, target: 0 };
let phase: Phase = 'idle';
let pt = 0;
let attempt = 0;
let demoStart = -1;
let retryDone = false;
// 흔들림 식 — 사인파를 지수로 줄어드는 봉투 안에 넣는다. tau 는 초
function shakeOffset(tau: number): number {
return SHAKE_AMP_PX * Math.exp(-SHAKE_DECAY * tau) * Math.sin(2 * Math.PI * SHAKE_HZ * tau);
}
function shakeEnvelope(tau: number): number { return Math.exp(-SHAKE_DECAY * tau); }
function setState(cls: string, aria: string): void {
bar.classList.remove('is-fail', 'is-ok');
if (cls) { bar.classList.add(cls); }
send.setAttribute('aria-label', aria);
const retry = cls === 'is-fail';
iconSend.style.opacity = retry ? '0' : '';
iconRetry.style.opacity = retry ? '' : '0';
}
function resetIdle(): void {
phase = 'idle';
pt = 0;
attempt = 0;
lag.x = 0;
lag.v = 0;
lag.target = 0;
setState('', TXT_ARIA_SEND);
note.style.opacity = '0';
bar.style.transform = '';
send.style.transform = '';
say(TXT_READY);
}
function fail(): void {
setState('is-fail', TXT_ARIA_RETRY);
note.style.opacity = '';
say(TXT_FAIL);
}
function succeed(): void {
phase = 'done';
pt = 0;
setState('is-ok', TXT_ARIA_SENT);
note.style.opacity = '0';
say(TXT_DONE);
}
function settle(): void {
resetIdle();
attempt = 1;
phase = 'failed';
fail();
}
function press(): void {
if (phase === 'sending' || phase === 'shake') { return; }
if (phase === 'done') { resetIdle(); return; }
attempt += 1;
if (still) {
// 줄인 움직임 — 흔들지 않고 결과 상태로 바로 간다
if (attempt === 1) { phase = 'failed'; fail(); } else { succeed(); }
return;
}
phase = 'sending';
pt = 0;
say(TXT_SENDING);
}
function step(): void {
if (phase === 'sending') {
pt += FIXED_DT;
if (pt >= SEND_MS) {
pt = 0;
// 첫 시도는 실패, 다시 보내기는 성공 — 실패 화면을 보여 주는 시연 순서
if (attempt === 1) { phase = 'shake'; fail(); } else { succeed(); }
}
} else if (phase === 'shake') {
pt += FIXED_DT;
if (pt >= SHAKE_MS) { phase = 'failed'; pt = 0; }
}
lag.target = phase === 'shake' ? shakeOffset(pt / 1000) : 0;
springStep(lag, LAG_K, LAG_ZETA, FIXED_DT);
}
function demoTick(t: number): void {
// 첫 시연은 200ms 뒤에 — 렌더 하네스가 로드 직후를 따로 재는 구간을 비운다
if (t < 200) { return; }
if (demoStart < 0 || t - demoStart >= DEMO_CYCLE) {
demoStart = t;
retryDone = false;
resetIdle();
press();
} else if (!retryDone && t - demoStart >= DEMO_RETRY_AT) {
retryDone = true;
press();
}
}
function draw(): void {
const x = phase === 'shake' ? shakeOffset(pt / 1000) : 0;
// 기울기도 같은 값에서 — 밀린 쪽으로 x × 0.12° 만큼 고개를 돌린다
bar.style.transform = x === 0 ? '' : 'translateX(' + x.toFixed(2) + 'px) rotate(' + (x * 0.12).toFixed(2) + 'deg)';
let nudge = 0;
if (phase === 'sending') { nudge = Math.sin(Math.PI * pt / SEND_MS) * 6; }
const rel = lag.x - x + nudge;
send.style.transform = Math.abs(rel) < 0.01 ? '' : 'translateX(' + rel.toFixed(2) + 'px)';
if (phase === 'shake') {
note.style.opacity = clamp(1 - shakeEnvelope(pt / 1000) * 0.6, 0, 1).toFixed(3);
}
}
send.addEventListener('click', function (): void { press(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize(): void {
fitScene();
for (const f of resizeHooks) { f(); }
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("sb") + " is-demo"} ref={rootRef}>
<div className={cx("sb__scene")} data-role="scene">
<div className={cx("sh__bar")} data-role="bar">
<span className={cx("sh__text")}>회의 자료 보내 드려요</span>
<button className={cx("sh__send")} data-role="send" type="button" aria-label="메시지 보내기"><svg xmlns="http://www.w3.org/2000/svg" className={cx("sh__icon")} data-role="icon-send" aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="M227.91 134.86L59.93 231a8 8 0 0 1-11.44-9.67L80 128L48.49 34.72a8 8 0 0 1 11.44-9.67l168 95.85a8 8 0 0 1-.02 13.96" opacity=".2"/><path d="m231.87 114l-168-95.89a16 16 0 0 0-22.95 19.23L71.55 128l-30.63 90.67A16 16 0 0 0 56 240a16.15 16.15 0 0 0 7.93-2.1l167.92-96.05a16 16 0 0 0 .05-27.89ZM56 224a.6.6 0 0 0 0-.12L85.74 136H144a8 8 0 0 0 0-16H85.74L56.06 32.16A.5.5 0 0 0 56 32l168 95.83Z"/></g></svg><svg xmlns="http://www.w3.org/2000/svg" className={cx("sh__icon")} data-role="icon-retry" aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0"/></g></svg></button>
</div>
<p className={cx("sh__note")} data-role="note">전송하지 못했어요</p>
<p className={cx("sb__status sb__status--top")} data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
);
}08 · 전송에 실패하면 점점 잦아드는 흔들림으로 알리고 다시 보내기로 바뀌는 버튼 — 측정 누적면적 6.092% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 처음 흔들림 폭(px)
const SHAKE_AMP_PX = 12;
// 지수 감쇠율(1/s) — 폭이 반으로 줄어드는 시간 = ln2 / SHAKE_DECAY
const SHAKE_DECAY = 6;
// 초당 왕복 수
const SHAKE_HZ = 8;
// 흔들림이 끝났다고 보는 폭(px) — 이 밑으로 줄어드는 시각까지만 흔든다
const SHAKE_REST_PX = 0.3;
const SHAKE_MS = Math.ceil(Math.log(SHAKE_AMP_PX / SHAKE_REST_PX) / SHAKE_DECAY * 1000);
// 흔들림 식 — 사인파를 지수로 줄어드는 봉투 안에 넣는다. tau 는 초
function shakeOffset(tau) {
return SHAKE_AMP_PX * Math.exp(-SHAKE_DECAY * tau) * Math.sin(2 * Math.PI * SHAKE_HZ * tau);
}
function shakeEnvelope(tau) { return Math.exp(-SHAKE_DECAY * tau); }
09채팅 보내기 버튼에서 말풍선이 포물선을 그리며 대화창에 내려앉는 장면
누르면 입력창 글자 자리(First)를 재고 말풍선을 대화창에 붙인 뒤 붙은 자리(Last)를 다시 재서, 그 차이만큼 거꾸로 옮겨 둔 말풍선을 560ms 동안 포물선으로 풀며 내려앉힌다. 세로에는 직선 위로 80·4p(1−p) 를 더해 진행 절반에서 직선보다 정확히 80px 높고, 내려앉는 순간 세로 0.78·가로 1.282 로 넓이를 지키며 납작해졌다가 84ms 에 살짝 길쭉해지고 300ms 에 제 모양으로 선다. 채팅 위젯이나 상담 창에 맞고, 먼저 와 있던 말풍선도 같은 재기 방식으로 260ms 동안 밀려 올라간다.

<main class="stage">
<div class="sb is-demo" id="sb">
<div class="sb__scene" data-role="scene">
<div class="ch__panel">
<div class="ch__thread" data-role="thread" data-bubble="ch__bubble ch__bubble--me">
<p class="ch__bubble">내일 몇 시에 볼까요?</p>
<p class="ch__bubble ch__bubble--me">3시 어때요?</p>
</div>
<div class="ch__composer">
<span class="ch__draft" data-role="draft">좋아요, 3시에 봐요</span>
<button class="ch__send" data-role="send" type="button" aria-label="보내기: 좋아요, 3시에 봐요"><svg xmlns="http://www.w3.org/2000/svg" class="ch__icon" aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="M227.91 134.86L59.93 231a8 8 0 0 1-11.44-9.67L80 128L48.49 34.72a8 8 0 0 1 11.44-9.67l168 95.85a8 8 0 0 1-.02 13.96" opacity=".2"/><path d="m231.87 114l-168-95.89a16 16 0 0 0-22.95 19.23L71.55 128l-30.63 90.67A16 16 0 0 0 56 240a16.15 16.15 0 0 0 7.93-2.1l167.92-96.05a16 16 0 0 0 .05-27.89ZM56 224a.6.6 0 0 0 0-.12L85.74 136H144a8 8 0 0 0 0-16H85.74L56.06 32.16A.5.5 0 0 0 56 32l168 95.83Z"/></g></svg></button>
</div>
</div>
<p class="sb__status sb__status--side" data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sb {
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;
}
// 장면 판 — 480×300 좌표로 짜 두고 스크립트가 상자 폭에 맞춰 scale 한다.
// 기준점이 왼쪽 위 모서리인 이유: 판이 상자 왼쪽 위에 붙어 있어 모서리 기준으로 줄여야 줄인 판이 상자와 겹친다
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .9);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: $sp-3;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: $r-control;
}
.sb { background: #e8f2fc; }
.ch__panel {
position: absolute;
left: 168px;
top: 12px;
width: 296px;
height: 276px;
border-radius: $r-card;
overflow: hidden;
background: #ffffff;
box-shadow: 0 10px 24px rgba(23, 20, 26, .1);
}
// 말풍선 줄 — 아래(최신)부터 쌓는다. 날아오는 말풍선이 판 밖에서 출발하므로 여기서는 자르지 않는다
.ch__thread {
position: absolute;
left: $sp-3;
right: $sp-3;
top: $sp-3;
bottom: 72px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: $sp-2;
}
.ch__bubble {
align-self: flex-start;
max-width: 200px;
margin: 0;
padding: $sp-2 $sp-3;
border-radius: $r-card;
background: #eef0f5;
color: $subject-ink;
font-size: 13px;
line-height: 1.4;
}
// 내 말풍선은 아래 변으로 내려앉는다 — 납작해질 때 바닥에 붙은 채 눌려야 해서 기준점을 아래 가운데에 둔다
.ch__bubble--me {
align-self: flex-end;
background: #d6ccff;
transform-origin: 50% 100%;
}
.ch__composer {
position: absolute;
left: $sp-3;
right: $sp-3;
bottom: $sp-3;
height: 48px;
box-sizing: border-box;
padding: 0 $sp-1 0 $sp-4;
border-radius: $r-pill;
background: #f3f1f8;
display: flex;
align-items: center;
gap: $sp-2;
}
.ch__draft {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: $subject-ink;
font-size: 13px;
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
}
.ch__send {
position: relative;
flex: none;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #c8b8ff;
color: $subject-ink;
cursor: pointer;
}
.ch__send:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 2px;
}
.ch__icon {
position: absolute;
left: 10px;
top: 10px;
width: 20px;
height: 20px;
}
@media (max-width: 340px) {
// 이 폭에서는 장면이 0.6 배 남짓으로 줄어든다 — 상태 줄 글자를 키워 줄인 뒤에도 읽히게 한다
.sb__status { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임은 전부 스크립트가 그리고, 스크립트는 reduce 에서 루프를 돌리지 않고 결과 상태만 둔다. CSS 쪽 전환도 막아 둔다
.sb__scene * {
transition: none !important;
animation: none !important;
}
}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);
}
.sb {
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);
}
.sb__scene {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
transform-origin: 0 0;
}
.sb__canvas {
position: absolute;
left: 0;
top: 0;
width: 480px;
height: 300px;
pointer-events: none;
}
.sb__status {
position: absolute;
left: 16px;
bottom: 12px;
box-sizing: border-box;
max-width: 260px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.sb__status--top {
top: 12px;
bottom: auto;
}
.sb__status--side {
max-width: 140px;
border-radius: 12px;
}
.sb {
background: #e8f2fc;
}
.ch__panel {
position: absolute;
left: 168px;
top: 12px;
width: 296px;
height: 276px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 10px 24px rgba(23, 20, 26, 0.1);
}
.ch__thread {
position: absolute;
left: 12px;
right: 12px;
top: 12px;
bottom: 72px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 8px;
}
.ch__bubble {
align-self: flex-start;
max-width: 200px;
margin: 0;
padding: 8px 12px;
border-radius: 16px;
background: #eef0f5;
color: #17141a;
font-size: 13px;
line-height: 1.4;
}
.ch__bubble--me {
align-self: flex-end;
background: #d6ccff;
transform-origin: 50% 100%;
}
.ch__composer {
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
height: 48px;
box-sizing: border-box;
padding: 0 4px 0 16px;
border-radius: 999px;
background: #f3f1f8;
display: flex;
align-items: center;
gap: 8px;
}
.ch__draft {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: #17141a;
font-size: 13px;
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
}
.ch__send {
position: relative;
flex: none;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #c8b8ff;
color: #17141a;
cursor: pointer;
}
.ch__send:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.ch__icon {
position: absolute;
left: 10px;
top: 10px;
width: 20px;
height: 20px;
}
@media (max-width: 340px) {
.sb__status {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sb__scene * {
transition: none !important;
animation: none !important;
}
}(function () {
const root = document.getElementById("sb");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK = [38, 33, 52];
const PAPER = [255, 252, 246];
const CREAM = [255, 243, 224];
const SKY = [158, 208, 255];
const LILAC = [200, 184, 255];
const MINT = [156, 228, 204];
const PINK = [255, 182, 204];
const PEACH = [255, 214, 184];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOut(x) { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(x) {
const c = clamp(x, 0, 1);
return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]');
const statusEl = root.querySelector('[data-role="status"]');
if (!sceneEl || !statusEl) {
return;
}
const scene = sceneEl;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
resetIdle();
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene() {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX, clientY) {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
// 포물선 꼭대기가 직선 경로보다 위로 솟는 높이(px)
const ARC_HEIGHT = 80;
// 입력창에서 대화창 도착 자리까지 날아가는 시간
const FLIP_MS = 560;
// 착지 납작함 — 내려앉는 순간 세로 비율이 1 − LAND_SQUASH, 가로는 넓이를 지키게 1/(1 − LAND_SQUASH)
const LAND_SQUASH = 0.22;
const SQUASH_MS = 300;
const SHIFT_MS = 260;
const MAX_MINE = 3;
const DEMO_CYCLE = 1250;
const MESSAGES = ['좋아요, 3시에 봐요', '자료도 같이 보낼게요', '도착하면 연락할게요'];
const TXT_READY = '보낼 준비 됨';
const TXT_FLY = '말풍선이 대화창으로 날아가는 중';
const TXT_DONE = '보냄 — 대화창에 올라갔어요';
const threadEl = root.querySelector('[data-role="thread"]');
const draftEl = root.querySelector('[data-role="draft"]');
const sendEl = root.querySelector('[data-role="send"]');
if (!threadEl || !draftEl || !sendEl) {
return;
}
const thread = threadEl;
const draft = draftEl;
const send = sendEl;
const bubbleClass = thread.getAttribute('data-bubble') || '';
let flights = [];
let shifts = [];
let mine = [];
let msgIndex = 0;
let draftT = 9999;
let demoNext = 200;
// 날아가는 동안의 자리 — 가로는 시간에 정비례, 세로는 직선 위에 4h·p(1−p) 를 더해 포물선이 된다
function arcOffset(dx, dy, p) {
return [dx * (1 - p), dy * (1 - p) - ARC_HEIGHT * 4 * p * (1 - p)];
}
// 착지 뒤 tau(ms) 의 납작함 — 감쇠 코사인. 처음엔 납작, 1/3 지점에서 살짝 길쭉, 끝에 0
function squashAt(tau) {
if (tau < 0 || tau >= SQUASH_MS) {
return 0;
}
return LAND_SQUASH * Math.exp(-5 * tau / SQUASH_MS) * Math.cos(3 * Math.PI * tau / SQUASH_MS);
}
function centerOf(el) {
const r = el.getBoundingClientRect();
return [(r.left + r.width / 2) / k, (r.top + r.height / 2) / k];
}
function setDraft(text) {
draft.textContent = text;
send.setAttribute('aria-label', '보내기: ' + text);
}
function sendNow() {
const text = draft.textContent || '';
if (!text) {
return;
}
// 오래된 내 말풍선은 측정 전에 뺀다 — 빼고 나서 재야 남은 말풍선의 위치 변화가 한 번에 잡힌다
if (mine.length >= MAX_MINE) {
const old = mine.shift();
if (old && old.parentNode) {
old.parentNode.removeChild(old);
}
flights = flights.filter(function (f) { return f.el !== old; });
shifts = shifts.filter(function (s) { return s.el !== old; });
}
// First — 입력창 글자 자리와 지금 말풍선들의 세로 위치
const first = centerOf(draft);
const before = [];
const kids = thread.children;
for (let i = 0; i < kids.length; i += 1) {
const el = kids[i];
before.push([el, centerOf(el)[1]]);
}
const b = document.createElement('p');
b.className = bubbleClass;
b.textContent = text;
thread.appendChild(b);
mine.push(b);
// Last — 대화창에 들어간 자리. Invert — 그 차이만큼 거꾸로 옮겨 두고, Play — 0 으로 풀어 준다
const last = centerOf(b);
for (const pair of before) {
const dy = pair[1] - centerOf(pair[0])[1];
if (Math.abs(dy) > 0.5) {
shifts = shifts.filter(function (s) { return s.el !== pair[0]; });
shifts.push({ el: pair[0], dy: dy, t: still ? SHIFT_MS : 0 });
}
}
flights.push({ el: b, dx: first[0] - last[0], dy: first[1] - last[1], t: still ? FLIP_MS + SQUASH_MS : 0 });
msgIndex = (msgIndex + 1) % MESSAGES.length;
setDraft(MESSAGES[msgIndex]);
draftT = still ? 9999 : 0;
say(still ? TXT_DONE : TXT_FLY);
}
function clearMine() {
for (const el of mine) {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
}
mine = [];
flights = [];
shifts = [];
}
function resetIdle() {
clearMine();
msgIndex = 0;
draftT = 9999;
setDraft(MESSAGES[0]);
draft.style.opacity = '';
say(TXT_READY);
}
function settle() {
resetIdle();
sendNow();
draw();
}
function step() {
for (const f of flights) {
f.t += FIXED_DT;
}
for (const s of shifts) {
s.t += FIXED_DT;
}
draftT += FIXED_DT;
const before = flights.length;
flights = flights.filter(function (f) {
if (f.t < FLIP_MS + SQUASH_MS) {
return true;
}
f.el.style.transform = '';
return false;
});
shifts = shifts.filter(function (s) {
if (s.t < SHIFT_MS) {
return true;
}
s.el.style.transform = '';
return false;
});
if (before > 0 && flights.length === 0) {
say(TXT_DONE);
}
}
function demoTick(t) {
if (t < demoNext) {
return;
}
demoNext += DEMO_CYCLE;
if (mine.length >= MAX_MINE) {
resetIdle();
}
sendNow();
}
function draw() {
for (const s of shifts) {
const e = 1 - easeOut(s.t / SHIFT_MS);
s.el.style.transform = 'translateY(' + (s.dy * e).toFixed(2) + 'px)';
}
for (const f of flights) {
if (f.t < FLIP_MS) {
const p = f.t / FLIP_MS;
const o = arcOffset(f.dx, f.dy, p);
f.el.style.transform = 'translate(' + o[0].toFixed(2) + 'px,' + o[1].toFixed(2) + 'px) scale(' + lerp(0.86, 1, p).toFixed(3) + ')';
}
else {
const s = squashAt(f.t - FLIP_MS);
f.el.style.transform = 'scale(' + (1 / (1 - s)).toFixed(4) + ',' + (1 - s).toFixed(4) + ')';
}
}
if (draftT < 300) {
draft.style.opacity = clamp(draftT / 300, 0, 1).toFixed(3);
}
else if (draft.style.opacity !== '') {
draft.style.opacity = '';
}
}
send.addEventListener('click', function () { sendNow(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize() {
fitScene();
for (const f of resizeHooks) {
f();
}
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// SendBubbleArcToThread - vanilla 판(09-send-btn-bubble-arc-to-thread) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SendBubbleArcToThread.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SendBubbleArcToThread() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 캔버스와 스크립트가 칠하는 색은 여기서만 고른다 (scss 쪽 색은 demo-tokens)
const INK: RGB = [38, 33, 52];
const PAPER: RGB = [255, 252, 246];
const CREAM: RGB = [255, 243, 224];
const SKY: RGB = [158, 208, 255];
const LILAC: RGB = [200, 184, 255];
const MINT: RGB = [156, 228, 204];
const PINK: RGB = [255, 182, 204];
const PEACH: RGB = [255, 214, 184];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function easeOut(x: number): number { const c = clamp(x, 0, 1); return 1 - Math.pow(1 - c, 3); }
function easeInOut(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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
// 시드 고정 난수 — 같은 시드면 같은 값이 다시 나온다 (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 sceneEl = root.querySelector('[data-role="scene"]') as HTMLElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
if (!sceneEl || !statusEl) { return; }
const scene: HTMLElement = sceneEl;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
const resizeHooks: Array<() => void> = [];
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 처음 상태로 돌린 뒤 상태 줄을 읽어 주기 시작한다
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');
resetIdle();
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 장면 배율 — 480×300 좌표로 짠 판을 상자 폭에 맞춰 통째로 줄인다
let k = 1;
function fitScene(): void {
const w = root.clientWidth;
k = w > 0 ? w / W : 1;
scene.style.transform = 'scale(' + k.toFixed(4) + ')';
}
// 화면 좌표(CSS px) → 장면 좌표(480×300). 줄인 배율 k 로 나눠야 판 안의 위치가 된다
function toScene(clientX: number, clientY: number): [number, number] {
const r = root.getBoundingClientRect();
return [(clientX - r.left) / k, (clientY - r.top) / k];
}
// 캡처 단계에서 듣는다 — 버튼 자신의 pointerdown 보다 먼저 시연을 끝내야 사람의 첫 누름이 처음 상태에서 시작한다
root.addEventListener('pointerdown', wake, true);
root.addEventListener('keydown', wake, true);
type Flight = { el: HTMLElement; dx: number; dy: number; t: number };
type Shift = { el: HTMLElement; dy: number; t: number };
// 포물선 꼭대기가 직선 경로보다 위로 솟는 높이(px)
const ARC_HEIGHT = 80;
// 입력창에서 대화창 도착 자리까지 날아가는 시간
const FLIP_MS = 560;
// 착지 납작함 — 내려앉는 순간 세로 비율이 1 − LAND_SQUASH, 가로는 넓이를 지키게 1/(1 − LAND_SQUASH)
const LAND_SQUASH = 0.22;
const SQUASH_MS = 300;
const SHIFT_MS = 260;
const MAX_MINE = 3;
const DEMO_CYCLE = 1250;
const MESSAGES = ['좋아요, 3시에 봐요', '자료도 같이 보낼게요', '도착하면 연락할게요'];
const TXT_READY = '보낼 준비 됨';
const TXT_FLY = '말풍선이 대화창으로 날아가는 중';
const TXT_DONE = '보냄 — 대화창에 올라갔어요';
const threadEl = root.querySelector('[data-role="thread"]') as HTMLElement | null;
const draftEl = root.querySelector('[data-role="draft"]') as HTMLElement | null;
const sendEl = root.querySelector('[data-role="send"]') as HTMLButtonElement | null;
if (!threadEl || !draftEl || !sendEl) { return; }
const thread: HTMLElement = threadEl;
const draft: HTMLElement = draftEl;
const send: HTMLButtonElement = sendEl;
const bubbleClass = thread.getAttribute('data-bubble') || '';
let flights: Flight[] = [];
let shifts: Shift[] = [];
let mine: HTMLElement[] = [];
let msgIndex = 0;
let draftT = 9999;
let demoNext = 200;
// 날아가는 동안의 자리 — 가로는 시간에 정비례, 세로는 직선 위에 4h·p(1−p) 를 더해 포물선이 된다
function arcOffset(dx: number, dy: number, p: number): [number, number] {
return [dx * (1 - p), dy * (1 - p) - ARC_HEIGHT * 4 * p * (1 - p)];
}
// 착지 뒤 tau(ms) 의 납작함 — 감쇠 코사인. 처음엔 납작, 1/3 지점에서 살짝 길쭉, 끝에 0
function squashAt(tau: number): number {
if (tau < 0 || tau >= SQUASH_MS) { return 0; }
return LAND_SQUASH * Math.exp(-5 * tau / SQUASH_MS) * Math.cos(3 * Math.PI * tau / SQUASH_MS);
}
function centerOf(el: HTMLElement): [number, number] {
const r = el.getBoundingClientRect();
return [(r.left + r.width / 2) / k, (r.top + r.height / 2) / k];
}
function setDraft(text: string): void {
draft.textContent = text;
send.setAttribute('aria-label', '보내기: ' + text);
}
function sendNow(): void {
const text = draft.textContent || '';
if (!text) { return; }
// 오래된 내 말풍선은 측정 전에 뺀다 — 빼고 나서 재야 남은 말풍선의 위치 변화가 한 번에 잡힌다
if (mine.length >= MAX_MINE) {
const old = mine.shift();
if (old && old.parentNode) { old.parentNode.removeChild(old); }
flights = flights.filter(function (f: Flight): boolean { return f.el !== old; });
shifts = shifts.filter(function (s: Shift): boolean { return s.el !== old; });
}
// First — 입력창 글자 자리와 지금 말풍선들의 세로 위치
const first = centerOf(draft);
const before: Array<[HTMLElement, number]> = [];
const kids = thread.children;
for (let i = 0; i < kids.length; i += 1) {
const el = kids[i] as HTMLElement;
before.push([el, centerOf(el)[1]]);
}
const b = document.createElement('p');
b.className = bubbleClass;
b.textContent = text;
thread.appendChild(b);
mine.push(b);
// Last — 대화창에 들어간 자리. Invert — 그 차이만큼 거꾸로 옮겨 두고, Play — 0 으로 풀어 준다
const last = centerOf(b);
for (const pair of before) {
const dy = pair[1] - centerOf(pair[0])[1];
if (Math.abs(dy) > 0.5) {
shifts = shifts.filter(function (s: Shift): boolean { return s.el !== pair[0]; });
shifts.push({ el: pair[0], dy: dy, t: still ? SHIFT_MS : 0 });
}
}
flights.push({ el: b, dx: first[0] - last[0], dy: first[1] - last[1], t: still ? FLIP_MS + SQUASH_MS : 0 });
msgIndex = (msgIndex + 1) % MESSAGES.length;
setDraft(MESSAGES[msgIndex]);
draftT = still ? 9999 : 0;
say(still ? TXT_DONE : TXT_FLY);
}
function clearMine(): void {
for (const el of mine) { if (el.parentNode) { el.parentNode.removeChild(el); } }
mine = [];
flights = [];
shifts = [];
}
function resetIdle(): void {
clearMine();
msgIndex = 0;
draftT = 9999;
setDraft(MESSAGES[0]);
draft.style.opacity = '';
say(TXT_READY);
}
function settle(): void {
resetIdle();
sendNow();
draw();
}
function step(): void {
for (const f of flights) { f.t += FIXED_DT; }
for (const s of shifts) { s.t += FIXED_DT; }
draftT += FIXED_DT;
const before = flights.length;
flights = flights.filter(function (f: Flight): boolean {
if (f.t < FLIP_MS + SQUASH_MS) { return true; }
f.el.style.transform = '';
return false;
});
shifts = shifts.filter(function (s: Shift): boolean {
if (s.t < SHIFT_MS) { return true; }
s.el.style.transform = '';
return false;
});
if (before > 0 && flights.length === 0) { say(TXT_DONE); }
}
function demoTick(t: number): void {
if (t < demoNext) { return; }
demoNext += DEMO_CYCLE;
if (mine.length >= MAX_MINE) { resetIdle(); }
sendNow();
}
function draw(): void {
for (const s of shifts) {
const e = 1 - easeOut(s.t / SHIFT_MS);
s.el.style.transform = 'translateY(' + (s.dy * e).toFixed(2) + 'px)';
}
for (const f of flights) {
if (f.t < FLIP_MS) {
const p = f.t / FLIP_MS;
const o = arcOffset(f.dx, f.dy, p);
f.el.style.transform = 'translate(' + o[0].toFixed(2) + 'px,' + o[1].toFixed(2) + 'px) scale(' + lerp(0.86, 1, p).toFixed(3) + ')';
} else {
const s = squashAt(f.t - FLIP_MS);
f.el.style.transform = 'scale(' + (1 / (1 - s)).toFixed(4) + ',' + (1 - s).toFixed(4) + ')';
}
}
if (draftT < 300) {
draft.style.opacity = clamp(draftT / 300, 0, 1).toFixed(3);
} else if (draft.style.opacity !== '') {
draft.style.opacity = '';
}
}
send.addEventListener('click', function (): void { sendNow(); });
resetIdle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function onResize(): void {
fitScene();
for (const f of resizeHooks) { f(); }
draw();
}
fitScene();
window.addEventListener('resize', onResize);
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("sb") + " is-demo"} ref={rootRef}>
<div className={cx("sb__scene")} data-role="scene">
<div className={cx("ch__panel")}>
<div className={cx("ch__thread")} data-role="thread" data-bubble={cx("ch__bubble ch__bubble--me")}>
<p className={cx("ch__bubble")}>내일 몇 시에 볼까요?</p>
<p className={cx("ch__bubble ch__bubble--me")}>3시 어때요?</p>
</div>
<div className={cx("ch__composer")}>
<span className={cx("ch__draft")} data-role="draft">좋아요, 3시에 봐요</span>
<button className={cx("ch__send")} data-role="send" type="button" aria-label="보내기: 좋아요, 3시에 봐요"><svg xmlns="http://www.w3.org/2000/svg" className={cx("ch__icon")} aria-hidden="true" focusable="false" viewBox="0 0 256 256"><g fill="currentColor"><path d="M227.91 134.86L59.93 231a8 8 0 0 1-11.44-9.67L80 128L48.49 34.72a8 8 0 0 1 11.44-9.67l168 95.85a8 8 0 0 1-.02 13.96" opacity=".2"/><path d="m231.87 114l-168-95.89a16 16 0 0 0-22.95 19.23L71.55 128l-30.63 90.67A16 16 0 0 0 56 240a16.15 16.15 0 0 0 7.93-2.1l167.92-96.05a16 16 0 0 0 .05-27.89ZM56 224a.6.6 0 0 0 0-.12L85.74 136H144a8 8 0 0 0 0-16H85.74L56.06 32.16A.5.5 0 0 0 56 32l168 95.83Z"/></g></svg></button>
</div>
</div>
<p className={cx("sb__status sb__status--side")} data-role="status" aria-live="off">보낼 준비 됨</p>
</div>
</div>
);
}09 · 채팅 전송 버튼에서 말풍선이 포물선을 그리며 대화창에 내려앉는 장면 — 측정 누적면적 22.231% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 날아가는 동안의 자리 — 가로는 시간에 정비례, 세로는 직선 위에 4h·p(1−p) 를 더해 포물선이 된다
function arcOffset(dx, dy, p) {
return [dx * (1 - p), dy * (1 - p) - ARC_HEIGHT * 4 * p * (1 - p)];
}
// 착지 뒤 tau(ms) 의 납작함 — 감쇠 코사인. 처음엔 납작, 1/3 지점에서 살짝 길쭉, 끝에 0
function squashAt(tau) {
if (tau < 0 || tau >= SQUASH_MS) {
return 0;
}
return LAND_SQUASH * Math.exp(-5 * tau / SQUASH_MS) * Math.cos(3 * Math.PI * tau / SQUASH_MS);
}
종이비행기가 곡선 위에서 기수를 돌리는 각도는 어떻게 구하는가
01 의 비행은 각도 두 개로 움직인다. 하나는 기수가 가리키는 방향이고, 다른 하나는 날개가 옆으로 눕는 기울기다.
기수 방향은 곡선을 미분한 접선에서 나온다
3차 베지어의 위치 식을 매개변수로 한 번 미분하면 그 지점의 진행 방향이 나오고, 화면 평면 성분 두 개를 Math.atan2 에 넣으면 각도가 된다. 이 경로에서 기수는 출발할 때 +19.7°, 곡선 매개변수 0.54 에서 가장 작은 −41.9°, 끝에서 −10.4° 다. 출발이 느리고 갈수록 빨라지도록 진행률을 1.45 제곱해서 매개변수로 쓰기 때문에, 같은 곡선이라도 앞쪽에서는 기수가 천천히, 뒤쪽에서는 빠르게 돈다.
// 접선 = 베지어를 t 로 미분한 것. 화면 평면 성분의 각도가 기수 방향이다
function headingAt(t) {
const u = 1 - t;
const c = FLIGHT_CTRL;
const dx = 3 * u * u * (c[1][0] - c[0][0]) + 6 * u * t * (c[2][0] - c[1][0]) + 3 * t * t * (c[3][0] - c[2][0]);
const dy = 3 * u * u * (c[1][1] - c[0][1]) + 6 * u * t * (c[2][1] - c[1][1]) + 3 * t * t * (c[3][1] - c[2][1]);
return Math.atan2(dy, dx);
}
날개 기울기는 기수가 얼마나 빨리 도는지에 비례한다
곡선 위 앞뒤 1% 지점의 기수 각도 차를 그 사이 시간으로 나누면 초당 선회율이 되고, 여기에 BANK_GAIN 0.3 을 곱한 값이 목표 기울기다. 날개는 목표로 곧장 가지 않고 16ms 걸음마다 남은 차이의 25% 씩만 다가가서, 곡선이 휘는 방향이 바뀌는 구간에서도 튀지 않는다. 그렇게 따라간 뒤 화면에 그려진 기울기는 비행 68걸음 동안 400ms 에 −39.4°, 1088ms 에 +38.7° 까지 갔다.
// 목표 기울기 = 선회율 × BANK_GAIN. 선회율은 앞뒤 1% 지점의 기수 각도 차를 그 사이 시간으로 나눈 값
function bankTarget(p) {
const p0 = clamp(p - 0.01, 0, 1);
const p1 = clamp(p + 0.01, 0, 1);
let dh = headingAt(flightParam(p1)) - headingAt(flightParam(p0));
if (dh > Math.PI) {
dh -= Math.PI * 2;
}
if (dh < -Math.PI) {
dh += Math.PI * 2;
}
const rate = dh / ((p1 - p0) * FLIGHT_MS / 1000);
return clamp(rate * BANK_GAIN, -BANK_MAX, BANK_MAX);
}
BANK_MAX 0.95rad(54.4°) 클램프는 이 경로에서 한 걸음도 걸리지 않는다. 목표 기울기의 절댓값이 가장 클 때도 41.6° 라서다. 날개를 덜 눕히고 싶어 이 값을 50° 로 낮춰도 화면은 그대로이고, 41.6° 아래로 내려야 비로소 클램프가 일을 한다.
멀어질수록 작아지고 겹치는 순서는 깊이로 정한다
면의 꼭짓점은 초점 거리 620 의 원근 투영으로 화면에 옮겨져, 깊이 320 끝점에서는 620/940 = 0.66 배로 줄고 화면 좌표 (451.1, 68.2) 에 닿는다. 크기는 이와 별도로 비행 앞 40% 동안 1 에서 0.62 로 줄인다. 면 네 장은 평균 깊이가 먼 것부터 칠하고, 법선과 빛 방향의 내적으로 밝기를 정하며, 뒤집혀 보이는 면은 종이 안쪽 색으로 칠한다.
두 번 연타·느린 응답·좁은 화면 — 전송 버튼 연출이 깨지는 순간
여기 적은 경우는 소스 분기를 따라 읽고 함수를 따로 불러 숫자로 다시 본 것들이다.
연출 도중에 한 번 더 눌렀을 때
01·03·04·05 는 처음 상태나 완료 상태가 아니면 누름을 받지 않고, 06 은 보내는 중에, 07 은 글자가 바뀌는 중에, 08 은 보내는 중과 흔들리는 중에 누름을 버린다. 막지 않는 것은 둘이다. 02 는 눌린 채 들어오는 키 반복만 무시하고 누를 때마다 같은 글자를 다시 쓸 뿐이라 문제가 없지만, 09 는 누를 때마다 말풍선을 새로 띄우고, 내 말풍선이 이미 세 개면 네 번째를 붙이기 전에 가장 오래된 것을 날아가는 중이어도 빼 버린다. 실제 채팅에 붙인다면 요청이 끝나기 전의 연타를 어떻게 다룰지 따로 정해야 한다.
응답이 연출보다 빠르거나 느릴 때
아홉 판 어디에도 네트워크 요청은 없다. 03 의 링이 덩어리지며 차는 모양은 시드 난수로 흉내 낸 것이고, 08 의 실패는 첫 시도라서 정해진 결과다. 01 은 한 번 누르면 110 + 780 + 1100 + 320 = 2,310ms 를 다 돌아야 버튼이 돌아오므로, 응답이 0.3초 만에 와도 사용자는 2.3초짜리 장면을 보게 된다. 붙일 때는 03 의 진행률 자리에 실제 진행 비율을 넣고, 08 은 실패 분기를 요청 결과에 걸고, 01·05 처럼 긴 장면은 응답을 기다리는 동안 반복하거나 끝까지 보여 줄지를 정한다.
16ms 걸음이 공식과 다른 값을 낸다
02 를 감쇠비 0.45 의 연속 해 공식으로 계산하면 넘침이 20.5% 인데, 16ms 고정 걸음으로 적분하면 17.0% 가 나온다. 걸음이 클수록 넘침을 깎기 때문이다. 감쇠비 0.2 에서는 공식 52.7%·적분 52.4% 로 둘이 거의 같다. 강성을 크게 올리는 쪽은 더 위험하다 — 08 을 만들 때 버튼 스프링을 강성 6400·감쇠비 0.6 으로 두자 적분이 발산했고, 강성 900·감쇠비 0.3 은 초당 8번 흔들리는 입력줄을 못 따라가 버튼이 10px 어긋난 채 제자리에 남았다.
좁은 화면에서 좌표가 k 배로 어긋난다
장면을 scale(k) 로 줄였기 때문에 getBoundingClientRect 가 돌려주는 값은 줄어든 화면 좌표다. 09 의 centerOf 가 이 값을 k 로 나누지 않으면 입력창과 도착 자리의 거리가 k 배로 틀어져 말풍선이 엉뚱한 곳에서 출발한다. 아홉 판의 스타일에는 max-width: 340px 미디어 쿼리가 하나씩 들어 있다.
같은 평면 위의 두 면이 프레임마다 깜빡인다
01 에서 접히는 모서리 삼각형은 다 접힌 뒤 몸통과 같은 평면(깊이 0)에 눕는다. 평균 깊이만으로 순서를 정하면 둘의 순서가 프레임마다 뒤집혀 깜빡이므로, 소스는 모서리 두 장에 −0.8 을 쳐서 몸통 위에 오게 했다. 05 의 덮개도 같은 종류의 문제라 90° 를 기준으로 그리는 순서를 직접 바꾼다.
길이 0 인 선에도 둥근 끝이 찍힌다
03 의 체크는 stroke-linecap: round 로 그어서, 아직 길이가 0 인 대시에도 둥근 끝이 점 하나로 보였다. 긋기 시작하기 전에는 opacity 를 0 으로 둬서 숨긴다. 링 반지름을 바꿀 때는 둘레 RING_C 가 반지름 식에서 다시 계산되는지 확인해야 링 끝이 덜 닫히거나 겹치지 않는다.
받은 파일에서 감쇠비나 흔들림 상수를 바꿔 위 상황을 직접 만들어 보려면 압축 파일에 8vmkz6v4 을 넣어 푼다. 버튼마다 index.html 과 style.scss 가 한 쌍씩 있고, 바꿀 상수는 각 스크립트 위쪽에 이름을 붙여 모아 두었다.
스프링 감쇠·흔들림 줄어드는 빠르기·포물선 높이를 옮겨 보기와 보내는 중 상태의 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 스프링 감쇠 낮추기 | 02 SPRING_DAMP 0.45 → 0.2 | 넘침이 17.0%(2.0px)에서 52.4%(6.3px)로 커지고, 쉬는 높이 위로 0.5px 넘게 솟는 꼭짓점이 1번에서 2번, 머무는 시각이 0.21초에서 0.58초로 늘어난다. |
| 흔들림 잦아드는 빠르기 올리기 | 08 SHAKE_DECAY 6 → 12 | 폭이 절반이 되는 시간이 0.116초에서 0.058초로, 0.3초 시점 폭은 처음의 17% 에서 3% 로, 흔드는 시간은 615ms 에서 308ms 로 준다. 첫 꼭짓점도 10.0px 에서 8.5px 이 된다. |
| 포물선 높이 올리기 | 09 ARC_HEIGHT 80 → 160 | 직선 위로 솟는 높이가 정확히 두 배가 되고, 화면에서는 말풍선 꼭대기가 도착 자리 위 약 56px 에서 약 135px 로, 처음 솟는 속도가 0.67 에서 1.24px/ms 로 약 1.9배가 된다. |
아홉 개 모두 진짜 <button type="button"> 이라 Tab 으로 닿고 Enter·Space 로 눌린다. 02 는 그 위에 keydown·keyup 을 따로 들어 키보드로 눌러도 깊이와 그림자가 손으로 누를 때처럼 내려갔다 올라온다. 포커스 테두리는 아홉 개 모두 :focus-visible 로 그리고, 원으로 줄어드는 03 은 그 테두리를 버튼 상자가 아니라 svg 몸통에 그린다. 글자를 한 자씩 쪼개 두는 07 과 아이콘만 남는 08·09 는 aria-label 을 스크립트가 함께 고쳐서, 08 은 「메시지 보내기」 에서 「다시 보내기」, 「보냄」 으로, 09 는 「보내기: 」 뒤에 입력 문구를 붙여 읽힌다.
판마다 있는 상태 줄은 시연이 도는 동안 aria-live="off" 라 화면 읽기 프로그램이 시연의 변화를 읽지 않고, 사람이 처음 누르거나 키를 치면 polite 로 바뀐다. 이 첫 입력은 루트가 캡처 단계에서 받기 때문에 버튼 자신의 처리보다 먼저 시연을 거둔다.
prefers-reduced-motion 이 켜진 화면에서는 아홉 판이 프레임 루프를 아예 시작하지 않고 settle() 로 결과 상태를 바로 세운다. 이 설정은 판이 시작할 때 matchMedia 로 한 번 읽을 뿐 바뀌는 것을 따로 듣지 않으므로, 보는 도중에 설정을 바꾸면 페이지를 다시 열어야 반영된다. 그 상태에서 버튼을 누르면 접히거나 흔들리는 과정 없이 다음 결과로 건너뛰고, 08 은 처음부터 실패와 다시 보내기 상태로 서 있다가 한 번 누르면 성공 상태가 된다. 보냈는지 못 보냈는지는 움직임 없이도 글자와 색으로 남는다.
버튼 애니메이션 css 규칙만으로 종이비행기 전송 효과가 되는가?
이 편의 버튼은 그렇게 만들지 않았다. 스타일 파일은 배치, 판 축소의 기준점, 포커스 테두리, 움직임 줄이기 블록을 맡고, 움직이는 값은 전부 스크립트가 transform·opacity·SVG 속성·캔버스로 바꾼다. 키프레임은 시작 전에 정해 둔 시간표를 그대로 재생하는데, 02 의 넘침은 손을 뗀 순간의 깊이와 속도에서, 06 의 반사광은 지금의 기울기에서, 01 의 면 순서는 그 프레임의 깊이에서 정해진다. 미리 그려 둔 시간표로는 이 값들을 받아 낼 자리가 없다.
비행기가 직선으로 날아가며 작아지는 정도라면 transform 키프레임과 perspective 로도 흉내 낼 수 있다. 다만 종이가 접히며 면의 앞뒤가 바뀌고, 곡선의 꺾임에 맞춰 날개가 눕는 모습까지 원한다면 requestAnimationFrame 루프 안에서 계산하는 편이 짧다. 03 의 체크 길이는 getTotalLength 로 재고, 이 함수가 없으면 선분 길이를 직접 더한다.
다운로드 버튼에서 같은 스프링과 진행 표시를 다룬 다운로드 버튼 9가지, 눌림 값이 되돌아오는 방식만 모은 버튼 눌림 효과 9종, 스피너에서 성공 체크까지 상태를 바꾸는 버튼 로딩 9종, 클릭 지점에서 캔버스로 퍼지는 버튼 클릭 애니메이션 9가지 가 이웃 편이다.
종이비행기 애니메이션을 내 폼에 붙일 때 자주 걸리는 것들
회원가입 폼의 제출 버튼에 02 를 붙이면 폼이 실제로 보내지나?
지금 그대로는 아니다. 버튼이 type="button" 이라 폼 안에 넣어도 폼을 보내지 않고, click 처리는 글자를 「신청 완료」 로 바꾸고 상태 줄에 알리는 일만 한다. type 을 submit 으로 바꾸거나 submit() 함수 안에서 폼을 직접 보내면 되고, 눌림 깊이와 그림자는 pointerdown·keydown 에서 따로 돌기 때문에 그대로 남는다.
실제 요청이 실패했을 때만 08 을 흔들려면 어디를 고치나?
step() 안에서 420ms 보내는 중이 끝날 때 attempt === 1 이면 fail(), 아니면 succeed() 로 가는 두 줄이 시연용 결과다. 이 조건을 요청 결과로 바꾸고, 결과가 오기 전에는 phase 를 sending 에 머물게 하면 된다. 흔들림의 폭·감쇠·떨림 수는 SHAKE_AMP_PX·SHAKE_DECAY·SHAKE_HZ 세 상수만 바꾸면 되고, 흔드는 시간은 그 값들에서 저절로 다시 계산된다.
07 에 다른 언어 문구를 넣어도 글자가 흩어지나?
흩어진다. 문구를 html 에 미리 쪼개 두지 않고 스크립트가 Array.from 으로 한 글자씩 나누기 때문에 글자 수가 달라도 그대로 돌고, 빈칸은 폭이 0 이 되지 않도록 줄바꿈 없는 공백으로 바꿔 넣는다. 버튼 판 너비는 두 문구 가운데 긴 쪽에 맞춰 한 번 정하므로, 문구를 바꾸면 그 길이에 따라 포커스 테두리 크기도 함께 바뀐다.









