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

모달 창 애니메이션 9종 — 스프링·원형 마스크·화소로 열고 닫기

열 때는 공들인 모달도 닫힐 때는 대개 그냥 사라진다. 여기서 03번은 가장 먼 모서리까지 잰 원으로 창을 열고, 05번은 칸마다 다른 노이즈 문턱을 넘는 순서로 화소가 맺히며, 07번은 줄마다 폭을 좁혀 한 점으로 빨려 들며 닫힌다.

아홉 가지 방식으로 열리고 닫히는 모달 창 무대

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

칸마다 한 바퀴는 2,400ms 다. 200ms 에 창이 열리기 시작하고, 01 은 1,500ms, 06 은 1,850ms, 07·08 은 1,150ms 처럼 항목이 정한 시각에 닫기 쪽으로 넘어간다. 07 은 「담기」 를, 08 은 「삭제」 를 누른 셈으로 닫혀서 장바구니로 들어가거나 깨지고, 09 는 900ms 에 「확정하기」 를 누른 순간부터 눌림과 체크, 축소가 이어진다.

모달 창 애니메이션 9종 — 스프링·버튼 자리 확장·원형 마스크·종이 접기·화소 디졸브·물결 굴절·깔때기 닫힘·유리 조각·결제 확인 흐름

01~06 은 여는 길이 저마다 다르고, 07·08 은 닫히는 길이 주인공이며, 09 는 열고 누르고 닫는 한 바퀴를 창 하나에 담았다. 스타일시트에는 @keyframes 가 한 줄도 없다. 움직임은 requestAnimationFrame 이 넘겨 주는 시간을 16ms 고정 틱으로 쪼개 매 틱 스프링·보간·노이즈·파동·조각 물리를 푸는 루프가 맡고, 닫힘도 열림을 거꾸로 돌리는 대신 항목마다 따로 계산한다.

화면의 글자는 캔버스가 그리고, 누르는 자리는 투명한 DOM 이 받는다

이 데모의 창과 버튼, 제목과 본문 글자는 전부 480×300 장면 좌표의 캔버스가 매 틱 그린 그림이다. 캔버스는 aria-hidden="true" 로 화면 낭독기에서 빠지고, 같은 자리에는 role="dialog"·aria-modal="true"·aria-labelledby·aria-describedby 를 단 대화상자와 여는 버튼, 창 안 두 버튼이 color: transparent 로 글자를 감춘 DOM 층으로 겹쳐 있어 키보드와 클릭과 낭독을 받는다. 효과가 창을 잘라 내고 늘리고 비틀 수 있는 것은 makeSprite 가 창 그림을 화면 밖 캔버스 세 장(배경까지 다 그린 판, 배경 없는 판, 확인 버튼을 뺀 판)에 미리 떠 두고 그 그림 조각을 drawImage 로 옮겨 그리기 때문이다. 화소를 읽는 getImageData 는 아홉 소스 어디에도 없다. 대신 그림 글자와 진짜 글자가 둘로 나뉘는 구조라서, 실서비스 모달에 그대로 옮기기보다는 같은 계산을 DOM 글자 위의 transform·clip-path 로 얹는 편이 낫다. 그 길은 아래 질문 절에서 다시 짚는다.

렌더 캡처 24장으로 잰 아홉 창의 움직임

2초 동안 찍은 24장 사이의 변화 23번 가운데 화면이 바뀐 횟수를 프레임 칸에 적었다.

창 누적면적 움직인 프레임 강도 평균
01 스프링 튀어 오름 74.8% 12/23 46.8
02 버튼 자리 확장 75.8% 13/23 55.6
03 원형 마스크 67.1% 15/23 94.2
04 종이 접기 75.8% 16/23 50.9
05 화소 디졸브 75.5% 17/23 36.9
06 물결 굴절 75.9% 21/23 34.4
07 깔때기 닫힘 75.9% 20/23 41.4
08 유리 조각 75.9% 17/23 45.8
09 결제 확인 흐름 75.9% 19/23 47.1

누적면적이 아홉 개 모두 67~76% 로 큰 것은 창이 그만큼 넓게 움직여서가 아니다. 창이 열릴 때 뒤 페이지를 덮는 어두운 막(최대 불투명도 0.46)이 무대 전체에 깔렸다가 걷히기 때문에, 막이 지나간 화소가 모두 바뀐 것으로 잡힌다. 03 만 67.1% 로 낮은 것은 막과 창을 원 안에만 칠하기 때문으로 보인다. 프레임 수는 01 이 12/23 으로 가장 적은데, 스프링이 480ms 무렵 자리를 잡은 뒤 닫힐 때까지 화면이 그대로 머물기 때문이고, 06 은 물결이 1,504ms 까지 잦아들며 화면을 흔들어 21/23 까지 올라간 것으로 보인다.

가입 안내부터 주문 확정까지, 이런 창에 붙인다

창마다 실제 서비스의 한 장면을 맡겼다. 01 은 회원가입 안내, 02 는 상품 목록의 빠른 보기, 03 은 알림 배지를 눌렀을 때의 알림 상세, 04 는 이용 약관 동의, 05 는 쿠폰 당첨 안내, 06 은 여름 기획전 공지다. 07 은 옵션을 골라 장바구니에 담는 창, 08 은 파일 삭제 확인, 09 는 주문 최종 확인이고 화면에 결제 수단이나 금액은 없는 가상 주문이다.

쇼핑몰이나 예약 서비스에서 모달 창 만들기를 맡으면 창 안의 내용보다 창이 나타나고 사라지는 순간에 손이 덜 가기 쉽다. 같은 확인 창이라도 무엇을 확인하는지에 따라 닫히는 모습이 달라야 할 때가 있어서, 07 은 담긴 물건이 장바구니로 가는 길을, 08 은 지운 파일이 돌아오지 않는다는 느낌을 닫힘 자체로 보여 준다. 08 이 「취소」 로 닫힐 때는 깨지지 않는 것도 그래서다.

01스프링으로 튀어 올라 그림자가 깊어지는 모달

창 크기는 0.55배에서 출발해 강성 150·감쇠 14 스프링을 16ms 마다 적분한 값으로 커지는데, 감쇠비가 0.57 이라 한 번 104.1% 까지 부풀었다가 480ms 부터는 목표의 1% 안에 머문다. 떠오른 높이가 크기에 비례해 그림자 번짐과 아래 거리를 함께 키우므로 넘친 순간에는 떠오름 값이 28.4px 로 SHADOW_LIFT_MAX 26 을 넘어서고, 가볍게 반기는 회원가입 안내 창에 어울린다. 렌더 값은 누적면적 74.8%, 움직인 프레임 12/23, 강도 평균 46.8 이다.

스프링으로 튀어 올라 그림자가 깊어지는 모달
새 창에서 열기

01 · 스프링으로 튀어 올라 그림자가 깊어지는 모달 — 측정 누적면적 74.769% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPRING_STIFFNESSSPRING_DAMPINGSHADOW_LIFT_MAX
    // 감쇠 스프링 한 스텝 — 가속도 = -강성 x (위치 - 목표) - 감쇠 x 속도. 속도를 먼저 고친 뒤 그 속도로 위치를 옮긴다. dt 는 초
    function springStep(s, target, stiffness, damping, dt) {
        const a = -stiffness * (s.x - target) - damping * s.v;
        s.v += a * dt;
        s.x += s.v * dt;
    }
    function springRest(s, target) { return Math.abs(s.x - target) < 0.002 && Math.abs(s.v) < 0.02; }
    // 크기 — 스프링 값이 1 을 넘으면 목표보다 커진다(넘침)
    function popScale() { return SCALE_FROM + (1 - SCALE_FROM) * pop.x; }
    // 떠오른 높이 — 크기에 비례한다. 넘친 순간에는 목표 높이보다 더 떠서 그림자가 더 깊고 멀다
    function popLift() { return SHADOW_LIFT_MAX * Math.max(0, (popScale() - SCALE_FROM) / (1 - SCALE_FROM)); }
    function itemPage() { drawOpener(1); }
    function itemDraw() {
        if (pop.x <= 0.001) {
            return;
        }
        drawScrim(pop.x);
        drawPanelScaled(spriteFull, popScale(), pop.x * 1.8, popLift());
    }

02버튼 자리에서 자라나 창이 되는 모달

128×34 「빠른 보기」 버튼 사각형에서 272×172 창 사각형까지 위치·폭·높이를 520ms 동안 한꺼번에 보간하고, 모서리 반경은 17 에서 14 로 줄이면서 늘어난 좌표계에 맞춰 배율로 나눈 값을 넣는다. 안쪽 글자는 부모 배율의 역수로 되돌려 그리는데 시작 순간 그 값이 가로 2.125·세로 5.059 이고, 상품 목록에서 누른 버튼이 그대로 빠른 보기 창으로 펼쳐지는 흐름에 맞다. 이 창은 75.8% 를 덮었고 13/23 프레임에서 강도 평균 55.6 으로 움직였다.

버튼 자리에서 자라나 창이 되는 모달
새 창에서 열기

02 · 버튼 자리에서 자라나 창이 되는 모달 — 측정 누적면적 75.818% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GROW_DURATION_MSCORNER_RADIUS_FROMCORNER_RADIUS_TO
    function itemDraw() {
        if (grow <= 0) {
            return;
        }
        const e = easeInOutCubic(grow);
        drawScrim(e);
        const r = growRect(e);
        const rad = lerp(CORNER_RADIUS_FROM, CORNER_RADIUS_TO, e);
        const sx = r.w / PANEL.w;
        const sy = r.h / PANEL.h;
        ctx.save();
        ctx.translate(r.x, r.y);
        // 부모 층 — 창 전체를 sx, sy 로 늘린 좌표계(DOM 이라면 창에 transform: scale(sx, sy) 한 줄)
        ctx.scale(sx, sy);
        // 반경을 배율로 나눠 넣는다 — 늘어난 뒤에도 모서리가 반경 rad 의 원으로 보인다
        roundRectPath(ctx, 0, 0, PANEL.w, PANEL.h, rad / sx, rad / sy);
        ctx.shadowColor = rgba(SCRIM, 0.16 + 0.18 * e);
        ctx.shadowBlur = (6 + 24 * e) * dpr;
        ctx.shadowOffsetY = (3 + 10 * e) * dpr;
        ctx.fillStyle = rgba(mix(ACCENT, PANEL_BG, smooth(e * 1.6)), 1);
        ctx.fill();
        ctx.shadowColor = 'rgba(0,0,0,0)';
        ctx.clip();
        // 자식 층 — 역비율 1/sx, 1/sy 로 되돌려 안쪽 글자가 늘어나 보이지 않게 한다
        ctx.scale(1 / sx, 1 / sy);

03가장 먼 모서리까지 잰 원으로 열리는 모달

원의 중심은 여는 버튼 가운데(396, 265)이고, 무대 네 모서리까지 거리 476.5·278·397.5·91px 가운데 가장 큰 왼쪽 위 476.5px 를 마지막 반경으로 삼아 700ms 동안 감속 곡선으로 키운다. 막과 창은 원 안에만 그려져서 알림 배지를 누른 자리에서 알림 상세가 번져 나오는 화면에 쓰기 좋고, 닫을 때는 같은 곡선을 거꾸로 밟아 끝에서 빨라진다. 아홉 가운데 누적면적은 67.1% 로 가장 작고 강도 평균은 94.2 로 가장 크며, 15/23 프레임이 바뀌었다.

가장 먼 모서리까지 잰 원으로 열리는 모달
새 창에서 열기

03 · 가장 먼 모서리까지 잰 원으로 열리는 모달 — 측정 누적면적 67.108% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

IRIS_ORIGIN_XIRIS_ORIGIN_YIRIS_DURATION_MS
    // 마지막 반경 — 시작점에서 무대 네 모서리까지 거리 가운데 가장 큰 값. 이 반경이면 원이 무대를 빈틈없이 덮는다
    function irisMaxRadius(ox, oy) {
        const corners = [[0, 0], [W, 0], [0, H], [W, H]];
        let m = 0;
        for (let i = 0; i < corners.length; i += 1) {
            m = Math.max(m, Math.hypot(corners[i][0] - ox, corners[i][1] - oy));
        }
        return m;
    }
    const IRIS_X = IRIS_ORIGIN_X * W;
    const IRIS_Y = IRIS_ORIGIN_Y * H;
    const IRIS_R = irisMaxRadius(IRIS_X, IRIS_Y);

04접힌 종이가 펴지듯 세 칸으로 열리는 모달

창을 57.33px 높이의 세 판으로 나눠 84° 로 접힌 상태에서 760ms 동안 평평하게 펴고, 판마다 2px 띠로 잘라 띠의 깊이에 따른 원근 배율(먼 끝 0.901)을 곱해 그린다. 밝기는 판 법선과 빛 방향 사이 각의 코사인으로 정해 빛을 등진 판이 0.42 에서 42° 지점 0.672 로 밝아지는 동안 빛 쪽 판은 1.03 하이라이트를 받고, 끝까지 읽고 동의해야 하는 이용 약관 창처럼 종이 느낌이 어울리는 자리에 쓴다. 캡처에서는 누적면적 75.8%, 16/23 프레임, 강도 평균 50.9 가 잡혔다.

접힌 종이가 펴지듯 세 칸으로 열리는 모달
새 창에서 열기

04 · 접힌 종이가 펴지듯 세 칸으로 열리는 모달 — 측정 누적면적 75.815% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOLD_PANELSFOLD_START_DEGFOLD_SHADE_MIN
    // 지금 판 각도(라디안)
    function foldAngle() { return FOLD_START_DEG * (1 - easeInOutCubic(fold)) * Math.PI / 180; }
    // 판 밝기 — 판 법선과 빛 방향 사이 각의 코사인. 똑바로 서면 1, 빛을 등지고 누울수록 FOLD_SHADE_MIN 쪽으로 어두워진다.
    // 1 을 넘는 몫(빛 쪽으로 기운 판)은 하이라이트로 돌려준다
    function foldShade(th, dir) {
        const flat = -LIGHT_Z;
        const lit = (dir * Math.sin(th) * LIGHT_Y - Math.cos(th) * LIGHT_Z) / flat;
        return FOLD_SHADE_MIN + (1 - FOLD_SHADE_MIN) * Math.max(0, lit);
    }

05칸마다 다른 노이즈 문턱으로 화소가 맺히는 모달

창을 6px 칸 46×29 = 1,334칸으로 나눠 시드 4735 의 값 노이즈 두 겹으로 칸마다 문턱을 정해 두고, 800ms 진행률이 문턱을 넘은 칸만 창 그림에서 잘라 옮기며 막 넘은 칸은 빛 띠 색으로 덧칠한다. 문턱이 고르게 퍼져 있지 않아 진행 25% 에는 18.4% 만 보이다가 50% 에 66.5% 가 맺히므로, 쿠폰 당첨처럼 무엇이 나올지 기대하게 만드는 안내에 맞다. 화면 변화는 17/23 프레임, 누적면적 75.5%, 강도 평균 36.9 였다.

칸마다 다른 노이즈 문턱으로 화소가 맺히는 모달
새 창에서 열기

05 · 칸마다 다른 노이즈 문턱으로 화소가 맺히는 모달 — 측정 누적면적 75.538% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DISSOLVE_CELL_PXNOISE_SCALEDISSOLVE_DURATION_MS
    // 칸마다 문턱 — 두 겹 노이즈(주파수 x2 는 세기 x0.5)를 더한 뒤 0 에서 (1 - 빛 폭) 사이로 늘린다.
    // 그래서 진행률 1 에서는 마지막 칸까지 빛 띠를 지나 창이 맑게 남는다
    function buildThresholds() {
        const rng = makeRng(4735);
        const lw = Math.ceil(PANEL.w * NOISE_SCALE * 2) + 3;
        const lh = Math.ceil(PANEL.h * NOISE_SCALE * 2) + 3;
        const lattice = [];
        for (let i = 0; i < lw * lh; i += 1) {
            lattice.push(rng());
        }
        const raw = [];
        let lo = 1e9;
        let hi = -1e9;
        for (let r = 0; r < DISSOLVE_ROWS; r += 1) {
            for (let c = 0; c < DISSOLVE_COLS; c += 1) {
                const px = (c + 0.5) * DISSOLVE_CELL_PX;
                const py = (r + 0.5) * DISSOLVE_CELL_PX;
                const v = valueNoise(lattice, lw, px * NOISE_SCALE, py * NOISE_SCALE)
                    + 0.5 * valueNoise(lattice, lw, px * NOISE_SCALE * 2, py * NOISE_SCALE * 2);
                raw.push(v);
                lo = Math.min(lo, v);
                hi = Math.max(hi, v);
            }
        }
        return raw.map(function (v) { return ((v - lo) / Math.max(hi - lo, 1e-6)) * (1 - DISSOLVE_EDGE); });
    }

06물결이 번지며 일렁이던 창이 맑아지는 모달

4px 칸 68×43 높이 격자에 파동 방정식을 16ms 마다 풀어 가운데 물방울 하나와 작은 물방울 둘을 퍼뜨리고, 칸마다 높이 기울기에 0.08 을 곱한 만큼 비켜 간 자리의 창 그림을 가져와 그린다. 굴절로 비켜 가는 거리는 가장 클 때 15.0px 이고 가장 높은 물결이 문턱 6 아래로 내려가는 1,504ms 에 열림이 끝나서, 여름 기획전 공지처럼 계절감을 내고 싶은 창에 쓴다. 21/23 프레임으로 아홉 가운데 가장 오래 움직였고 누적면적 75.9%, 강도 평균 34.4 다.

물결이 번지며 일렁이던 창이 맑아지는 모달
새 창에서 열기

06 · 물결이 번지며 일렁이던 창이 맑아지는 모달 — 측정 누적면적 75.897% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RIPPLE_DAMPINGRIPPLE_STRENGTHREFRACT_SCALE
    // 파동 방정식 한 스텝 — 다음 높이 = (이웃 넷의 합 / 2 - 이전 높이) x 감쇠. 가장자리 칸은 0 에 묶어 물결이 벽에서 되돌아온다
    function waveStep() {
        const c = waveCur;
        const p = wavePrev;
        for (let y = 1; y < RIPPLE_ROWS - 1; y += 1) {
            for (let x = 1; x < RIPPLE_COLS - 1; x += 1) {
                const i = y * RIPPLE_COLS + x;
                p[i] = ((c[i - 1] + c[i + 1] + c[i - RIPPLE_COLS] + c[i + RIPPLE_COLS]) / 2 - p[i]) * RIPPLE_DAMPING;
            }
        }
        waveCur = p;
        wavePrev = c;
    }

07줄마다 폭을 좁혀 한 점으로 빨려 드는 닫힘

옵션을 골라 장바구니에 담는 창이 닫힐 때 창 그림을 가로 30줄로 잘라 줄마다 목표점(위쪽 장바구니 알약)까지의 거리로 출발을 늦추고, 폭은 먼저 좁히고 자리와 높이는 제곱 곡선으로 늦게 옮긴다. 장바구니에 가까운 맨 위 줄이 405ms 에 점으로 모일 때 맨 아래 줄은 495ms 에야 출발해 912ms 에 다 들어가며, 「담기」 로 닫으면 장바구니 숫자가 1 오르고 「닫기」·Esc 로 닫으면 여는 버튼 쪽으로 빨려 든다. 누적면적 75.9% 에 20/23 프레임, 강도 평균 41.4 로 측정됐다.

줄마다 폭을 좁혀 한 점으로 빨려 드는 닫힘
새 창에서 열기

07 · 줄마다 폭을 좁혀 한 점으로 빨려 드는 닫힘 — 측정 누적면적 75.871% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FUNNEL_ROWSFUNNEL_TARGET_XFUNNEL_DURATION_MS
    // 줄 하나의 진행률 — 목표점에서 먼 줄일수록 늦게 출발한다(거리에 비례한 지연). 늦은 만큼 남은 시간에 더 빨리 따라잡는다
    function rowProgress(p, dn) { return clamp((p - FUNNEL_LAG * dn) / (1 - FUNNEL_LAG), 0, 1); }
    function itemPage() {
        drawOpener(1);
        const s = 1 + 0.4 * cartBump.x;
        ctx.save();
        ctx.translate(CART.x + CART.w / 2, CART.y + CART.h / 2);
        ctx.scale(s, s);
        fillRound(-CART.w / 2, -CART.h / 2, CART.w, CART.h, CART.h / 2, CART_FILL, 1);
        ctx.fillStyle = rgba(INK, 1);
        ctx.beginPath();
        ctx.arc(-CART.w / 2 + 12, 0, 4, 0, TAU);
        ctx.fill();
        ctx.font = fontOf(700, 12);
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(String(cartCount), 6, 0.5);
        ctx.restore();
    }
        // 먼 줄부터 그려 목표점에 가까운 줄이 위에 온다
        const order = [];
        for (let i = 0; i < FUNNEL_ROWS; i += 1) {
            order.push(i);
        }
        order.sort(function (a, b) { return dist[b] - dist[a]; });
        for (let n = 0; n < order.length; n += 1) {
            const i = order[n];
            const dn = (dist[i] - dmin) / Math.max(dmax - dmin, 1);
            const q = rowProgress(p, dn);
            // 폭은 빨리 좁히고(1.6배 빠르게) 자리는 늦게 옮긴다(제곱) — 먼저 가늘어진 뒤 빨려 든다
            const wq = smooth(q * 1.6);
            const mq = q * q;
            const w = lerp(PANEL.w, 3, wq);
            const cx = lerp(cx0, pointX, mq);
            const cy = lerp(PANEL.y + (i + 0.5) * rh, pointY, mq);
            const h = lerp(rh, rh * 0.2, mq);
            ctx.globalAlpha = 1 - smooth((q - 0.85) / 0.15);
            blit(spriteFull, 0, i * rh, PANEL.w, rh, cx - w / 2, cy - h / 2, w, h + 0.4);
        }
        ctx.globalAlpha = 1;
    }

08유리처럼 조각나 떨어지는 닫힘

창 위에 7×5 점을 깔아 시드 4738 로 칸의 ±35% 안에서 흔들고 칸마다 대각선으로 잘라 삼각 조각 48개를 만든 뒤, 깨진 중심인 삭제 버튼 가운데에서 바깥으로 158.6~237px/s 의 처음 속도와 회전을 주어 중력 1,400px/s² 로 떨어뜨린다. 금이 가는 120ms 와 낙하 900ms 를 지나 1,024ms 에는 48조각이 모두 무대 아래로 빠지고, 「취소」·Esc·막으로 닫으면 깨지지 않고 스프링으로 줄어들어 파일 삭제 확인처럼 되돌릴 수 없는 일에만 깨짐이 남는다. 이 장면의 수치는 누적면적 75.9%, 17/23 프레임, 강도 평균 45.8 이다.

유리처럼 조각나 떨어지는 닫힘
새 창에서 열기

08 · 유리처럼 조각나 떨어지는 닫힘 — 측정 누적면적 75.914% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SHARD_COUNTSHARD_GRAVITYSHARD_SPIN_MAX
    // 조각 나누기 — 창 위에 (열+1)x(줄+1) 점을 깔고 시드 난수로 흔든 뒤, 칸마다 대각선 하나로 삼각형 둘을 만든다.
    // 처음 속도는 깨진 중심(bx, by)에서 무게중심 쪽(바깥) 방향, 가까울수록 빠르다
    function buildShards(bx, by) {
        const rng = makeRng(4738);
        const cw = PANEL.w / SHARD_COLS;
        const ch = PANEL.h / SHARD_ROWS;
        const px = [];
        const py = [];
        for (let r = 0; r <= SHARD_ROWS; r += 1) {
            for (let c = 0; c <= SHARD_COLS; c += 1) {
                let x = c * cw;
                let y = r * ch;
                if (c > 0 && c < SHARD_COLS) {
                    x += (rng() - 0.5) * cw * 0.7;
                }
                if (r > 0 && r < SHARD_ROWS) {
                    y += (rng() - 0.5) * ch * 0.7;
                }
                px.push(x);
                py.push(y);
            }
        }
        const idx = function (r, c) { return r * (SHARD_COLS + 1) + c; };
        const tris = [];

09열리고 확인되고 버튼으로 돌아가 닫히는 결제 확인 모달

주문 확정 창이 감쇠비 0.62 스프링으로 열린 뒤 「확정하기」 버튼이 눌림 스프링으로 0.924배까지 들어갔다 돌아오고, 누른 뒤 384ms 동안 버튼이 동그라미로 좁아지며 체크 획이 점선 간격을 줄여 그려진다. 688ms 부터 창이 여는 버튼 자리로 줄어들어 1,136ms 에 닫힘이 끝나는 한 바퀴라 주문 최종 확인 단계에 맞고, 화면에는 결제 수단도 금액도 없다. 75.9% 를 덮으며 19/23 프레임에서 강도 평균 47.1 로 움직였다.

열리고 확인되고 버튼으로 돌아가 닫히는 결제 확인 모달
새 창에서 열기

09 · 열리고 확인되고 버튼으로 돌아가 닫히는 결제 확인 모달 — 측정 누적면적 75.903% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CONFIRM_HOLD_MSCHECK_MORPH_MSLOOP_MS
    // 확정 버튼 — 눌림 스프링으로 크기가, 모프로 폭(동그라미까지)·색·체크 획이 정해진다. 창 기준 좌표
    function drawConfirm() {
        const m = morph();
        const bx = CONFIRM_BOX.x + CONFIRM_BOX.w / 2;
        const by = CONFIRM_BOX.y + CONFIRM_BOX.h / 2;
        const k = 1 + press.x * PRESS_DEPTH;
        const w = lerp(CONFIRM_BOX.w, CONFIRM_BOX.h, m) * k;
        const h = CONFIRM_BOX.h * k;
        roundRectPath(ctx, bx - w / 2, by - h / 2, w, h, lerp(BUTTON_RADIUS, h / 2, m), lerp(BUTTON_RADIUS, h / 2, m));
        ctx.fillStyle = rgba(mix(ACCENT, SUCCESS, m), 1);
        ctx.fill();
        const la = 1 - smooth(m / 0.4);
        if (la > 0.001) {
            ctx.save();
            ctx.globalAlpha *= la;
            ctx.translate(bx, by);
            ctx.scale(k, k);
            fitFont(ctx, CONFIRM_LABEL, 600, 12.5, CONFIRM_BOX.w - 14);
            ctx.fillStyle = rgba(INK, 1);
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText(CONFIRM_LABEL, 0, 0.5);
            ctx.restore();
        }
        // 체크 획 — 꺾인 선 두 마디를 획 길이만큼 점선 간격을 주고 그 간격을 줄여 앞에서부터 그려 나간다
        const ck = smooth((m - 0.35) / 0.65);
        if (ck > 0.001) {
            const len = Math.hypot(5, 5) + Math.hypot(10, 11);
            ctx.save();
            ctx.translate(bx, by);
            ctx.strokeStyle = rgba(INK, 1);
            ctx.lineWidth = 3;
            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';
            ctx.setLineDash([len, len]);
            ctx.lineDashOffset = len * (1 - ck);
            ctx.beginPath();
            ctx.moveTo(-7, 0);
            ctx.lineTo(-2, 5);
            ctx.lineTo(8, -6);
            ctx.stroke();
            ctx.restore();
        }
    }

투명도와 크기만 바꾸던 모달은 왜 어디서 왔는지 알려 주지 못하는가?

투명도와 크기만 바꾸는 창은 기준점이 창 가운데에 있다. 여는 버튼이 화면 오른쪽 아래에 있어도 창은 가운데서 커지고 가운데로 작아지니, 무엇을 눌러서 이 창이 나왔는지를 움직임이 말해 주지 않는다. 이 편 무대도 여는 버튼은 오른쪽 아래(가운데 396, 265), 창은 가운데(104, 56 에서 272×172)에 있다. 그래서 02 는 그 버튼 사각형에서 창을 키우고, 03 은 버튼 가운데에서 원을 퍼뜨리며, 07 은 닫힐 때 장바구니나 여는 버튼으로 빨려 들고, 09 는 닫힘을 여는 버튼 자리로 되돌린다. 출발점과 도착점을 화면 안의 실제 물건에 묶어 두면 눈은 창이 어디서 나와 어디로 들어갔는지를 따로 설명 없이 따라간다. 키프레임 페이드와 스케일로 꾸미는 모달 팝업 디자인은 CSS 모달 팝업 애니메이션 9종 에 모여 있고, 이 편은 그 위에 출발점을 기억하는 계산을 얹은 쪽이다.

열린 자리를 기억하는 사각형 보간과, 원 반경을 가장 먼 모서리까지 재는 계산

버튼 사각형을 창 사각형으로 늘리면서 글자는 되돌리는 02

02 는 매 틱 lerpRect(OPENER, PANEL, e) 로 지금의 사각형을 구하고, 그 사각형과 완성된 창의 폭·높이 비 sx·sy 로 부모 층을 늘린다. 늘린 좌표계 안에서 그대로 글자를 그리면 글자도 같이 찌그러지므로 자식 층은 1 / sx, 1 / sy 로 다시 줄인다. 진행에 따라 그 역비율은 이렇게 바뀐다.

시간 진행 곡선 값 e 배율 sx × sy 글자 역비율
0% 0 0.471 × 0.198 2.125 × 5.059
50% 0.5 0.735 × 0.599 1.36 × 1.67
75% 0.938 0.967 × 0.950 1.034 × 1.053

출발 순간 세로 역비율이 5 를 넘는 것은 버튼 높이 34px 가 창 높이 172px 의 0.198 배밖에 안 되어서다. 버튼 글자는 곡선 값 0.35 전에 사라지고 창 내용은 0.4 뒤에 나타나도록 투명도를 나눠, 가장 심하게 되돌려야 하는 앞부분에는 두 글자가 겹쳐 보이지 않는다. DOM 으로 옮긴다면 부모에 transform: scale(sx, sy), 자식에 그 역수를 거는 두 줄이고, 모서리 반경을 배율로 나눠 넣는 일도 같은 원리다.

가장 먼 모서리를 재지 않으면 구석이 남는 03

03 의 원이 무대를 다 덮으려면 반경이 중심에서 가장 먼 점까지 닿아야 한다. 직사각형 안에서 가장 먼 점은 네 모서리 가운데 하나라서 irisMaxRadius 는 네 거리만 재고 큰 값을 고른다. 반경을 왼쪽 아래까지인 397.5px 로 두었다면 왼쪽 위 모서리 근처가 79px 만큼 덮이지 않은 채 열림이 끝났을 것이다. 감속 곡선 easeOutCubic 을 씌운 반경은 시간에 따라 이렇게 자란다.

시간 진행 10% 25% 50% 75% 100%
반경 129.1px 275.5px 416.9px 469px 476.5px

시간의 절반이 지났을 때 반경은 이미 87.5% 에 와 있고 나머지 절반은 마지막 59.6px 를 천천히 채우는 데 쓴다. 이미지 위에서 원이나 띠로 그림을 드러내는 계산은 이미지 전환 효과 9종 에 더 있다.

창이 화면보다 클 때·스크롤 도중에 열 때·닫히는 중에 다시 열 때 — 모달 전환이 깨지는 자리

닫히는 중에 다시 열면 항목마다 되돌아가는 길이 다르다

02·03·04·05 는 진행률이 지금 값에서 반대로 돌아서고, 01·09 는 스프링의 목표만 바뀌어 속도를 이어받은 채 방향을 튼다. 07 은 빨려 들던 점에서 거꾸로 펼쳐져 나오고, 08 은 깨지던 중이면 크기 0 에서 스프링을 다시 시작하며, 06 은 물방울을 하나 더 떨어뜨려 물결 위에 물결을 얹는다. 시연에서는 2,400ms 바퀴가 넘어가는 순간 아직 닫히지 않은 창을 snapTo(false) 로 끊어서, 앞 바퀴의 닫힘이 다음 바퀴의 열림과 섞이지 않게 했다.

창이 화면보다 클 때

창 크기는 장면 좌표 272×172 로 고정이고, 화면이 좁아지면 무대 전체가 aspect-ratio: 480 / 300 을 지킨 채 줄어든다. 그래서 이 데모에서는 창이 화면을 넘칠 일이 없고, 본문이 길면 최대 높이 56px 안에 들어갈 때까지 글자를 0.5px 씩 줄일 뿐이다. 긴 약관 전문처럼 창 안에서 스크롤해야 하는 내용은 다루지 않으며, 실서비스에서는 창 안 스크롤 영역을 따로 두어야 할 것으로 보인다.

스크롤 도중에 열 때

창을 열면 초점이 확인 버튼으로, 닫으면 여는 버튼으로 옮겨 가는데 세 군데 .focus() 호출 모두 { preventScroll: true } 를 넘겨 초점 이동이 페이지를 그 버튼 쪽으로 끌어당기지 않게 했다. 창이 떠 있는 동안 뒤 페이지 스크롤을 잠그는 코드는 이 데모에 없다. 열린 창 뒤로 페이지가 굴러가는 것을 막으려면 실서비스에서 문서 쪽 overflow: hidden 같은 잠금이 따로 필요해 보인다.

움직이는 동안 그림과 누르는 자리가 어긋난다

투명 DOM 층은 다 열린 자리에 고정돼 있다. 02 처럼 버튼 크기에서 자라는 중이거나 08 처럼 조각이 떨어지는 중에는 그림과 클릭 자리가 맞지 않고, 열리는 중에 누르면 최종 자리 기준으로 받는다. 초점 고리도 다 열린 자리에 맞춰져 있어서 .md--open 이 붙기 전에는 고리를 투명하게 감춘다.

문구를 바꿔도 그림은 옛 글자다

캔버스는 DOM 글자를 읽어 창 그림을 한 번 떠 두고 그 그림을 쓴다. 실행 중에 제목이나 본문을 바꾸면 크기가 바뀌어 fitCanvas 가 그림을 다시 뜰 때까지 화면에는 옛 문구가 남는다. 이 데모는 로케일 문구를 불러오기 전에 끼워 넣어서 이 문제를 비켜 갔다.

물결과 화소는 그리는 횟수가 많다

06 은 프레임마다 drawImage 를 2,924번 부르고 명암 칠하기도 최대 그만큼 더하며, 05 는 최대 1,334번, 04 는 87띠마다 그림과 그늘을, 08 은 조각 48개를 저마다 잘라 그린다. 캔버스 배율은 Math.min(window.devicePixelRatio || 1, 2) 로 막아 두어 배율 3 기기에서도 화소 수는 장면의 4배를 넘지 않는다. 개발 중 06 은 감쇠를 더 약하게 두었을 때 물결이 시연의 닫힘 시각까지 잦아들지 않아 지금의 0.95·문턱 6 으로 바꿨고, 09 의 눌림도 처음 값으로는 크기 변화가 눈에 띄지 않아 PRESS_DEPTH 를 0.6 으로 키웠다.

아홉 폴더의 vanilla 소스와 같은 계산을 옮긴 리액트 판은 내려받은 zip 에 함께 들어 있고, 압축을 풀 때 hp73ywt5 을 넣으면 내 서비스 문구로 바로 바꿔 볼 수 있다.

스프링 감쇠·디졸브 칸 크기·빨려 드는 시간을 바꾸면 열림과 닫힘은 어떻게 달라지는가

이름 값 느낌
스프링 감쇠 낮추기 01 SPRING_DAMPING 14 → 8 14 에서는 감쇠비 0.57 로 창이 104.1% 까지 한 번 부풀고 480ms 부터 1% 안에 머문다. 8 로 낮추면 감쇠비 0.33 이 되어 114.7% 까지 부풀고 목표 위로 세 번 솟았다가 1,088ms 에야 1% 안에 들어오며, 떠오름도 34.5px 까지 깊어졌다 얕아지기를 되풀이한다.
디졸브 칸 키우기 05 DISSOLVE_CELL_PX 6 → 16 6px 이면 46×29 = 1,334칸이 고운 모래처럼 맺히고, 16px 이면 17×11 = 187칸으로 줄어 굵은 모자이크 조각이 툭툭 채워진다. 진행 50% 에 맺힌 칸도 66.5% 에서 58.3% 로 내려간다.
빨려 드는 시간 줄이기 07 FUNNEL_DURATION_MS 900 → 500 900 이면 맨 위 줄이 405ms 에 점으로 모이고 맨 아래 줄은 495ms 에 출발해 깔때기 모양이 또렷하다. 500 이면 같은 비율이 225ms·275ms 로 당겨지고 512ms 에 다 들어가, 줄 사이 빠르기 차가 짧은 시간에 몰려 창이 휘는 모습은 덜 보인다.

표의 세 값 말고도 03 의 IRIS_ORIGIN_X·IRIS_ORIGIN_Y 를 옮기면 irisMaxRadius 가 새 중심에서 네 모서리를 다시 재므로 반경을 손으로 고칠 필요가 없다. 08 의 SHARD_COUNT 48 은 직접 적어 둔 값이고, SHARD_COLS 가 SHARD_COUNT / (2 * SHARD_ROWS) 로 거기서 6 을 얻는다. 조각 수를 바꿀 때는 SHARD_COUNT 와 SHARD_ROWS 를 함께 맞추되, 열 수가 정수로 떨어지게 SHARD_COUNT 를 SHARD_ROWS 2배의 배수로 둔다.

창이 열렸다는 것을 알리는 대화상자 역할과 움직임 줄이기 접근성

아홉 창 모두 투명 DOM 층의 <div class="md__dialog"> 가 role="dialog"·aria-modal="true" 를 갖고, aria-labelledby 로 제목 <h2> 를, aria-describedby 로 본문 <p> 를 가리킨다. 여는 버튼에는 aria-haspopup="dialog" 가 붙고, 닫힌 동안 대화상자는 hidden 이라 낭독기가 빈 창을 읽지 않는다. 캔버스 그림은 aria-hidden="true" 로 빠져 있어 들리는 것은 DOM 글자뿐이다.

키보드로 열고 닫고 초점이 돌아오는 길

Esc 는 창이 떠 있을 때만 닫기로 받고, 창 바깥 막을 눌러도 닫힌다. 열 때 첫 초점은 확인 버튼으로 가지만 08 만 되돌릴 수 없는 삭제라 「취소」 버튼이 먼저 초점을 받는다. 창이 떠 있는 동안 여는 버튼에는 inert 가 걸리고, Tab 은 창 안 두 버튼 사이만 돌도록 초점 가두기도 들어 있다. 닫을 때 초점을 여는 버튼으로 되돌리는 부분은 아홉 소스에 같은 모양으로 있다.

    function requestClose(user, via) {
        if (!shown()) {
            return;
        }
        const hadFocus = dialog.contains(document.activeElement);
        showDialog(false);
        setPhase('closing');
        itemClose(via);
        if (still) {
            snapTo(false);
        }
        // 닫으면 초점은 창을 열었던 버튼으로 돌아간다
        if (user || hadFocus) {
            openBtn.focus({ preventScroll: true });
        }
    }

사람이 누른 닫기이거나 초점이 창 안에 있던 경우에만 초점을 옮기므로, 자동 시연이 창을 닫을 때 보던 곳에서 초점을 빼앗지 않는다. 초점 고리는 focus-visible 규칙으로 그리고, 창이 움직이는 동안에는 고리를 투명하게 둔다. 왼쪽 아래 상태 줄은 aria-live="off" 로 시연 중에 조용하다가, 사람이 처음 누르거나 키를 치는 순간 polite 로 바뀌어 그 뒤의 열림과 닫힘을 알려 준다.

움직임을 줄인 화면에서는 그 자리에서 열고 닫는다

prefers-reduced-motion 에서 reduce 를 고른 화면은 requestAnimationFrame 루프를 돌리지 않는다. 첫 화면은 다 열린 창 한 장이고, 열기와 닫기는 snapTo 가 스프링도 조각도 없이 끝 상태로 바로 바꿔 그린다. 09 는 「확정하기」 를 누르면 눌림과 체크를 건너뛰고 곧장 닫힌다. 아홉 소스 모두 matchMedia 의 change 리스너가 설정을 지켜보고 있어서, 페이지를 연 채 설정을 켜면 가던 방향의 끝 모습으로 멈추고 끄면 루프가 다시 돈다. 대화상자 역할과 aria-modal 의 뜻은 MDN 의 aria-modal 설명 이 정리해 두었다.

팝업 애니메이션 효과를 실제 서비스 모달에 붙일 때 자주 묻는 질문

모달 창 구현을 할 때도 이 데모처럼 캔버스로 그려야 하나?

그럴 필요는 없고, 대부분은 DOM 글자에 같은 계산을 얹는 쪽이 낫다. 이 데모는 창을 잘라 옮기는 효과를 보여 주려고 글자까지 캔버스로 그리고 투명 DOM 을 겹쳤지만, 그 탓에 움직이는 동안 그림과 클릭 자리가 어긋나고 문구를 바꾸면 그림을 다시 떠야 한다. 01·02·09 의 스프링과 사각형 보간은 transform, 03 의 원은 clip-path 의 circle() 반경에 계산값을 넣으면 DOM 그대로 옮겨지고, 05·06·07·08 처럼 창 그림을 칸이나 줄이나 조각으로 나누는 효과만 캔버스가 맡을 몫으로 남는다. 페이지 사이를 잇는 전환이라면 view transitions api 9가지 가, 사진을 키워 보이는 창이라면 css 이미지 확대 애니메이션 9종 이 더 가깝다.

09 를 실제 주문 확인 창에 그대로 써도 되나?

데모의 체크는 버튼을 누른 뒤 정해진 시간표대로 그려지고, 실제 주문 요청은 어디에도 보내지 않는다. 실서비스라면 눌림 스프링까지만 바로 보여 주고 체크 모프와 축소는 주문 응답을 받은 뒤에 시작하도록 순서를 바꾸는 편이 맞다. 그래야 실패한 주문에 체크가 그려지는 일이 없다.

닫히는 효과가 길면 Esc 를 눌러도 기다려야 하나?

기다리지 않아도 된다. Esc 를 누르는 순간 DOM 대화상자는 바로 hidden 이 되고 초점도 여는 버튼으로 돌아가며, 화면의 닫힘 그림만 뒤에 이어진다. 09 는 체크가 그려지는 중에도 Esc 나 「닫기」 가 먹어서 곧장 축소로 넘어가고, 08 은 Esc 로 닫으면 깨지지 않고 스프링으로 줄어든다.

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

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