GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

버튼 애니메이션 효과 9종 — 전송 버튼이 접혀 날아가는 계산

전송 버튼은 눌린 뒤에도 '보냈다'를 보여 줘야 한다 — 01번은 버튼이 종이비행기로 접혀 곡선을 따라 기울며 날고, 02번은 스프링으로 눌렸다 튕기며 그림자 높이가 따라 바뀌고, 08번은 실패하면 점점 잦아드는 흔들림으로 알린다.

보내기 버튼 아홉 개를 눌러 보고 일부러 실패시켜 보는 판

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

판은 사람이 만지기 전까지 정해진 간격으로 스스로 눌렸다가 처음으로 돌아가기를 되풀이한다(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 로 옅어진다. 문의 폼처럼 '보냈다'를 한 장면으로 보여 주고 싶은 곳에 맞고, 비행하는 동안 버튼 자리에는 민트색 자리표시가 반투명으로 깔려 빈 구멍이 남지 않는다.

누르면 종이비행기로 접혀 곡선을 따라 기울며 날아가는 전송 버튼
새 창에서 열기

01 · 누르면 종이비행기로 접혀 곡선을 따라 기울며 날아가는 전송 버튼 — 측정 누적면적 12.511% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOLD_STEPSFLIGHT_CTRLBANK_GAIN
    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 로 바짝 붙고, 버튼은 속도에 비례해 세로로 눌린 만큼 가로로 넓어져 넓이를 지킨다. 회원가입·결제 확인처럼 '눌렸다'는 손맛이 중요한 자리에 맞고, 클릭이 끝나면 색은 그대로 두고 글자만 「신청 완료」 로 바꾼다.

눌린 깊이를 스프링으로 풀며 그림자 높이가 함께 바뀌는 제출 버튼
새 창에서 열기

02 · 눌린 깊이를 스프링으로 풀며 그림자 높이가 함께 바뀌는 제출 버튼 — 측정 누적면적 9.168% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPRING_KSPRING_DAMPSHADOW_LIFT_PX
    // 감쇠 스프링 한 걸음 — 질량 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 에 완료 상태가 된다.

원으로 줄어든 둘레를 진행 링이 돌고 체크 표시로 끝나는 버튼
새 창에서 열기

03 · 원으로 줄어든 둘레를 진행 링이 돌고 체크 표시로 끝나는 버튼 — 측정 누적면적 10.944% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SHRINK_MSRING_STROKECHECK_DRAW_MS
    // 알약 너비 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칸이라 날아가는 도중에 이미 꽉 차서 가장 오래된 입자 자리부터 덮어쓴다.

날아간 자리에 공기 저항과 노이즈로 흩어지는 꼬리 입자
새 창에서 열기

04 · 날아간 자리에 공기 저항과 노이즈로 흩어지는 꼬리 입자 — 측정 누적면적 14.133% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TRAIL_COUNTAIR_DRAGTRAIL_NOISE
    // 한 걸음 — 속도에 저항을 곱해 줄이고, 그 자리 노이즈 값이 가리키는 방향으로 민 뒤 옮긴다
    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 가 걸린다.

편지봉투로 바뀌어 덮개가 접히고 봉해지는 메일 전송 버튼
새 창에서 열기

05 · 편지봉투로 바뀌어 덮개가 접히고 봉해지는 메일 전송 버튼 — 측정 누적면적 24.408% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLAP_DEGFLAP_MSSEAL_POP
    // 덮개 끝점 — 윗변(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 가 걸린다.

보내는 중 광택 띠가 지나가고 포인터 기울기를 따라 반사광이 옮겨 가는 버튼
새 창에서 열기

06 · 보내는 중 광택 띠가 지나가고 포인터 기울기를 따라 반사광이 옮겨 가는 버튼 — 측정 누적면적 17.132% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWEEP_MSGLOSS_WIDTHTILT_LERP
    // 관성 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).

글자가 하나씩 흩어졌다가 '보냄'으로 다시 모이는 버튼 글자
새 창에서 열기

07 · 글자가 하나씩 흩어졌다가 '보냄'으로 다시 모이는 버튼 글자 — 측정 누적면적 4.192% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LETTER_STAGGER_MSSCATTER_AMPREGATHER_MS
    // 남은 변위 비율 — 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 만 어긋난다. 네트워크 오류나 입력 누락을 알릴 때 맞고, 한 번 더 누르면 다시 보내기가 성공해 민트색으로 끝난다.

전송에 실패하면 점점 잦아드는 흔들림으로 알리고 다시 보내기로 바뀌는 버튼
새 창에서 열기

08 · 전송에 실패하면 점점 잦아드는 흔들림으로 알리고 다시 보내기로 바뀌는 버튼 — 측정 누적면적 6.092% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SHAKE_AMP_PXSHAKE_DECAYSHAKE_HZ
    // 처음 흔들림 폭(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 동안 밀려 올라간다.

채팅 전송 버튼에서 말풍선이 포물선을 그리며 대화창에 내려앉는 장면
새 창에서 열기

09 · 채팅 전송 버튼에서 말풍선이 포물선을 그리며 대화창에 내려앉는 장면 — 측정 누적면적 22.231% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ARC_HEIGHTFLIP_MSLAND_SQUASH
    // 날아가는 동안의 자리 — 가로는 시간에 정비례, 세로는 직선 위에 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 이 되지 않도록 줄바꿈 없는 공백으로 바꿔 넣는다. 버튼 판 너비는 두 문구 가운데 긴 쪽에 맞춰 한 번 정하므로, 문구를 바꾸면 그 길이에 따라 포커스 테두리 크기도 함께 바뀐다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.