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

로고 애니메이션 만들기 9종 — 진행률을 그리는 로딩 로고

로딩 중인 로고는 진행률 0~1 을 무엇으로 바꿔 보여 줄까? 03번은 로고 화소에서 뽑은 점들이 스프링으로 제자리를 찾고, 05번은 노이즈 문턱값이 내려가며 로고가 번져 나오며, 07번은 원 둘레의 점을 로고 외곽선과 짝지어 녹여 넣는다.

진행률을 받아 로고를 완성하는 로딩 화면 아홉 개를 이 자리에서 돌려 보기

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

로고 애니메이션 만들기 9종 — 외곽선 긋기·물 채우기·점 모으기·타일 뒤집기·노이즈 번짐·혜성 꼬리·모양 녹이기·두께 쌓기·숫자 세기

앞의 둘은 선(01)과 면(02)으로 마크를 채우고, 가운데 셋은 점(03)·타일(04)·화소(05) 단위로 모양을 드러내며, 뒤의 넷은 둘레를 도는 꼬리(06), 원에서 마크로 바뀌는 윤곽(07), 뒤로 쌓이는 두께(08), 들쭉날쭉한 숫자를 고르는 계산(09)을 맡는다. 아홉 장면 모두 <canvas> 2D 한 장에 그리고, 스타일시트에는 무대 비율과 버튼, 포커스 테두리, 아래 진행 띠만 있다. CSS @keyframes 는 한 줄도 없고, 움직임은 requestAnimationFrame 타임스탬프를 쌓아 16ms 고정 스텝을 한 프레임에 최대 6번 푸는 루프가 전부 맡는다.

로고는 직접 그린 기하 마크, 진행은 시드로 뽑은 가상 값

화면의 마크는 꼭짓점이 위를 향한 육각형(중심에서 꼭짓점까지 104px) 안에 바깥 꼭짓점 44px·안쪽 14px 짜리 네 갈래 별 구멍을 뚫은 모양이고, 어느 회사나 브랜드의 로고도 아니다. roundCorners 가 꼭짓점 양옆을 잘라 2차 베지어로 둥글리므로 바깥 외곽선은 42점(둘레 601.7px), 별 구멍은 32점(둘레 260.1px)이 되고, 칠할 때는 evenodd 규칙으로 바깥 안쪽이면서 별 바깥인 곳만 채운다. 진행 값도 진짜 내려받기가 아니다. makeLoad(469) 가 시드 469 의 정수 섞기 난수로 1,300ms 동안 7조각이 도착하는 표를 만들고, 그중 484ms 부터 758ms 까지 274ms 는 받는 양이 49% 에서 멈춰 있다. 01~08 은 도착 사이를 직선으로 이은 loadAt 을, 09 는 도착하는 순간에만 뛰는 계단 reportedAt 을 받아서, 900ms 시점의 값이 앞쪽은 0.585, 09 는 0.49 로 다르다. 소스 어디에도 fetch·Math.random·Date.now 가 없어서 같은 시드로 열면 같은 그림이 같은 시각에 나온다.

렌더 캡처로 잰 아홉 장면의 움직임 크기

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

장면 누적면적 움직인 프레임 강도 평균
01 외곽선 긋기 21.6% 17/23 94.0
02 물 채우기 15.5% 22/23 114.8
03 화소 점 모으기 39.6% 23/23 79.0
04 타일 뒤집기 22.2% 17/23 80.7
05 노이즈 번짐 17.7% 17/23 120.1
06 혜성 꼬리 26.9% 23/23 64.3
07 원에서 마크로 16.2% 20/23 92.2
08 두께 쌓기 12.3% 23/23 73.8
09 퍼센트 세기 28.8% 23/23 137.6

시연 한 바퀴는 2,000ms 이고 첫 200ms 는 진행률 0, 마지막 500ms 는 다 받은 상태로 머문다. 01·04·05 는 진행률만 보고 그리는 장면이라 이 두 구간에서 화면이 멈춰 17장에 그친 것으로 보이고, 03 은 아직 차례가 오지 않은 점이 흩어진 자리에서 맴돌고 06 은 머리가 계속 돌며 08 은 마크가 좌우로 흔들려서 23장 내내 무언가 바뀐다. 누적면적은 08 이 12.3% 로 가장 작고 03 이 39.6% 로 가장 넓은데, 08 은 층이 앞면 바로 뒤에 겹쳐 새로 칠해지는 면이 좁고 03 은 반지름 128~224px 고리에서 점들이 날아들기 때문으로 보인다.

로고 로딩 화면을 고를 때 어울리는 자리

브랜드 사이트에 처음 들어올 때처럼 로딩 로고가 그대로 첫인상이 되는 곳은 01 의 외곽선 긋기가 무난하고, 앱 설치나 업데이트를 기다리게 하는 화면은 02 의 차오르는 물이 상황을 설명해 준다. 서비스 시작 스플래시는 03, 대시보드 첫 로딩은 04, 포트폴리오 인트로는 05 가 맞다. 결제 처리처럼 기다림이 얼마나 길지 모르는 표시는 06, 로그인 뒤 다음 화면을 기다리는 전환은 07, 게임이나 이벤트 페이지 입구는 08, 파일 내보내기와 보고서 생성처럼 숫자를 오래 지켜보게 되는 대기는 09 가 맡는다.

로딩 로고는 왜 시간이 아니라 진행률에 묶어야 하는가?

시간에 묶은 연출은 1초짜리든 3초짜리든 정해진 길이에 끝난다. 실제 불러오기가 그보다 길면 다 그린 마크가 하염없이 서 있고, 짧으면 그리다 만 채 다음 화면으로 넘어간다. 이 편의 아홉 장면은 거꾸로 0 에서 1 사이 진행 값 하나를 받아 그 값으로 그림을 정하므로, 가상 진행이 274ms 멈춘 구간에서는 01 의 펜도 그 자리에 서고 04 의 파면도 진행을 멈춘다. 로딩 중 로고가 받은 양을 그대로 보여 주면 멈춘 구간이 화면에서도 멈춰 보이는데, 이것을 숨기지 않고 다듬는 쪽이 09 다. 진행 값이 들어오는 길은 targetProgress() 한 줄이고, 끌거나 키로 옮긴 값(manual)이 있으면 그것을, 없으면 가상 표의 loadAt(loadMs) 를 돌려준다. 실서비스에 붙일 때는 이 자리의 가상 값만 내 진행 값으로 바꿔 넣으면 되고, 그림을 그리는 쪽은 손대지 않아도 된다.

0 과 1 사이 숫자 하나가 선 길이·수면 높이·점 개수로 갈라지는 계산

장면마다 진행률 p 를 그림의 어느 양으로 바꾸는지, 그리고 아래 띠가 어떤 값을 알리는지를 표로 모았다.

장면 p 를 바꾸는 식 띠가 알리는 값
01 그은 길이 = 861.4px × (1 − (1 − p)^1.6) p
02 수위가 스텝마다 p 와의 차를 8% 씩 좁힘 수위
03 섞은 순번이 p × 718 보다 작은 점만 목표로 1.5px 안에 든 점 비율
04 뒤집힘 = (p × 1,120 − (d − 2) × 60) ÷ 400 p
05 문턱 = 1 − p × 1.07 p
06 꼬리 점 수 = 4 + round(p × 86), 최대 90 p
07 섞기 = 곡선(p), 25·50·75% 에서 0.109·0.5·0.891 p
08 층 수 = p × 14, 소수 부분은 미끄러져 드는 층 p
09 표시값이 계단 값과의 차를 6% 씩 좁힘 표시값

01 의 곡선은 앞을 빠르게 긋고 끝을 천천히 닫아서 진행률 25·50·75% 에 이미 36.9%(318px)·67.0%(577px)·89.1%(768px) 를 그어 둔다. 띠가 알리는 값이 장면마다 다른 이유는 띠와 화면이 어긋나지 않게 하려는 데 있다. 02 는 물이 진행률보다 늦게 차오르므로(가장 큰 차 19.4%p) 수위를, 03 은 점이 날아가는 중이므로 도착한 점의 비율을 띠에 싣는다. 그 일은 공통 함수 syncBar 가 한다.

    // 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
    let barPct = -1;
    function syncBar(v) {
        const c = clamp(v, 0, 1);
        fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
        const p = Math.round(c * 100);
        if (p !== barPct) {
            barPct = p;
            bar.setAttribute('aria-valuenow', String(p));
        }
    }

띠의 길이는 transform: scaleX() 로만 바뀌어 레이아웃을 다시 계산하지 않고, aria-valuenow 는 정수 퍼센트가 달라질 때만 고쳐 써서 화면 읽기 프로그램에 같은 값을 거듭 보내지 않는다.

01로고 외곽선을 같은 빠르기로 따라 그으며 펜 끝이 빛나는 로더

꼭짓점 번호를 같은 간격으로 나눠 찍은 점은 둥근 모서리에 몰려 이웃 간격이 0.22~12.85px, 59.3배까지 벌어지므로, 점 번호 대신 누적 호 길이 표(362칸, 총 861.4px)를 이분 탐색해 9번 비교로 칸을 찾고 그 칸 안에서 남은 거리 비율로 펜 끝을 옮긴다. 바깥 외곽선과 별 구멍을 각각 다른 색으로 긋고 펜 끝에 반지름 18px 빛을 얹으며 진행률 70% 부터 속이 차오르니, 브랜드 사이트 첫 진입 화면처럼 마크를 한 획씩 소개하는 자리에 맞다. 이 장면의 렌더 값은 누적면적 21.6%, 강도 94.0, 17/23 프레임이다.

로고 외곽선을 같은 빠르기로 따라 그으며 펜 끝이 빛나는 로더
새 창에서 열기

01 · 로고 외곽선을 같은 빠르기로 따라 그으며 펜 끝이 빛나는 로더 — 측정 누적면적 21.629% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TRACE_SAMPLESPEN_GLOW_PXTRACE_EASE_POW
    function findSeg(d) {
        let lo = 0;
        let hi = CUM.length - 1;
        while (hi - lo > 1) {
            const mid = (lo + hi) >> 1;
            if (CUM[mid] <= d) {
                lo = mid;
            }
            else {
                hi = mid;
            }
        }
        return lo;
    }
    function traceEase(p) { return 1 - Math.pow(1 - clamp(p, 0, 1), TRACE_EASE_POW); }
    // 펜 끝 — 그 칸 안에서 남은 거리 비율만큼 두 점 사이를 잇는다. 그래서 펜은 점 간격과 상관없이 길이에 비례해 움직인다
    function penAt(d) {
        const i = findSeg(d);
        if (i >= TRACE.length - 1) {
            return { i: i, x: TRACE[i].x, y: TRACE[i].y };
        }
        const seg = CUM[i + 1] - CUM[i];
        const f = seg > 0 ? clamp((d - CUM[i]) / seg, 0, 1) : 0;
        return { i: i, x: lerp(TRACE[i].x, TRACE[i + 1].x, f), y: lerp(TRACE[i].y, TRACE[i + 1].y, f) };
    }

02로고 모양 안에 물이 차오르며 수면이 출렁이는 로더

수면을 48개 기둥으로 나눠 기둥마다 기준 수위로 당기는 힘과 감쇠 0.985 를 먼저 적용하고, 이웃과의 높이 차에 0.22 를 곱해 주고받는 일을 두 번 돌려 물결을 옆으로 번지게 한 뒤, 전체를 마크 모양으로 잘라 별 구멍만 비워 둔다. 수위가 오른 만큼 물줄기 자리 기둥을 아래로 밀어 출렁임을 새로 만들기 때문에 앱 설치나 업데이트 대기처럼 차오름 자체가 상황 설명이 되는 화면에 어울린다. 측정 결과는 누적면적 15.5%, 강도 114.8, 22/23 프레임이었다.

로고 모양 안에 물이 차오르며 수면이 출렁이는 로더
새 창에서 열기

02 · 로고 모양 안에 물이 차오르며 수면이 출렁이는 로더 — 측정 누적면적 15.474% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_SPREADWAVE_DAMPFILL_RISE_RATE
    // 기둥 파동 한 스텝 — ① 기준 수위로 당기고 감쇠를 곱한 뒤 ② 이웃과 높이 차를 WAVE_SPREAD 만큼 주고받는다(두 번 돌려 옆으로 빨리 번지게)
    function waveStep() {
        for (let i = 0; i < WAVE_COLS; i += 1) {
            vs[i] += -WAVE_TENSION * hs[i];
            vs[i] *= WAVE_DAMP;
            hs[i] += vs[i];
        }
        for (let pass = 0; pass < 2; pass += 1) {
            for (let i = 0; i < WAVE_COLS; i += 1) {
                if (i > 0) {
                    ld[i] = WAVE_SPREAD * (hs[i] - hs[i - 1]);
                    vs[i - 1] += ld[i];
                }
                if (i < WAVE_COLS - 1) {
                    rd[i] = WAVE_SPREAD * (hs[i] - hs[i + 1]);
                    vs[i + 1] += rd[i];
                }
            }
            for (let i = 0; i < WAVE_COLS; i += 1) {
                if (i > 0) {
                    hs[i - 1] += ld[i];
                }
                if (i < WAVE_COLS - 1) {
                    hs[i + 1] += rd[i];
                }
            }
        }
    }

03로고 화소에서 뽑은 점들이 스프링으로 제자리를 찾는 로더

화면 밖 캔버스에 마크를 한 번 칠해 getImageData 로 읽고 6px 격자에서 알파가 127 을 넘는 칸만 목표점으로 뽑으면 브라우저 렌더 기준 718개가 되며, 시드 4693 으로 섞은 순번이 p × 718 보다 작은 점만 바깥 고리에서 출발해 가속 −90(x − 목표) − 11·v 의 스프링으로 붙는다. 감쇠비가 0.58 이라 120px 떨어진 점은 9.1% 지나쳤다 돌아와 272ms 에 처음 1.5px 안에 들고 624ms 부터 그 안에 머무니, 점이 모여 마크가 되는 순간을 시작 신호로 쓰는 서비스 스플래시에 맞다. 렌더에서는 아홉 장면 가운데 가장 넓은 누적면적 39.6% 에 강도 79.0, 23/23 프레임이 나왔다.

로고 화소에서 뽑은 점들이 스프링으로 제자리를 찾는 로더
새 창에서 열기

03 · 로고 화소에서 뽑은 점들이 스프링으로 제자리를 찾는 로더 — 측정 누적면적 39.592% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SAMPLE_STEP_PXSPRING_KSPRING_DAMP
    // 감쇠 스프링 — 가속 = −k(x − 목표) − d·v, 속도를 먼저 고치는 반암시 오일러(dt 0.016초)
    function step() {
        const need = progress * N;
        const dt = FIXED_DT / 1000;
        for (let i = 0; i < DOTS.length; i += 1) {
            const d = DOTS[i];
            const g = goal(d, need);
            const ax = -SPRING_K * (d.x - g[0]) - SPRING_DAMP * d.vx;
            const ay = -SPRING_K * (d.y - g[1]) - SPRING_DAMP * d.vy;
            d.vx += ax * dt;
            d.vy += ay * dt;
            d.x += d.vx * dt;
            d.y += d.vy * dt;
        }
        countArrived();
    }

04로고를 타일로 쪼개 대각선 파면 순서로 뒤집어 채우는 로더

216px 정사각형을 24px 타일로 나눠 마크에 걸치는 64장만 남기고, 열 + 행으로 정한 대각 단계(13단계)마다 60ms 씩 늦게 출발해 400ms 동안 뒤집히는 1,120ms 시간표를 진행률이 처음부터 끝까지 훑는다. 뒤집히는 타일은 가로 폭이 |cos(πf)| 로 접혔다 펴지고 옆으로 선 순간 명암이 0.45 까지 내려가며 살짝 들려 그림자를 떨어뜨리므로, 칸이 차례로 켜지는 느낌이 잘 맞는 대시보드 첫 로딩에 쓴다. 측정한 값은 누적면적 22.2%, 강도 80.7, 17/23 프레임이다.

로고를 타일로 쪼개 대각선 파면 순서로 뒤집어 채우는 로더
새 창에서 열기

04 · 로고를 타일로 쪼개 대각선 파면 순서로 뒤집어 채우는 로더 — 측정 누적면적 22.244% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TILE_SIZE_PXSWEEP_DELAY_MSFLIP_SHADE
    // 파면 시간표 — 대각 단계 d 의 타일은 (d − DMIN) × SWEEP_DELAY_MS 에 출발해 FLIP_MS 동안 뒤집힌다. 진행률이 이 시간표 전체를 0 → 끝까지 훑는다
    const WAVE_MS = (DMAX - DMIN) * SWEEP_DELAY_MS + FLIP_MS;
    function flipAt(t, p) {
        return clamp((p * WAVE_MS - (t.d - DMIN) * SWEEP_DELAY_MS) / FLIP_MS, 0, 1);
    }
    function shown() { return progress; }
    function step() { }
    function settle() { }
    function onRestart() { }
        for (let i = 0; i < TILES.length; i += 1) {
            const t = TILES[i];
            const raw = flipAt(t, progress);
            const f = still ? (raw >= 0.5 ? 1 : 0) : smooth(raw);
            const ang = f * Math.PI;
            // 가로 폭 = |cos| — 0° 와 180° 에서 제 폭, 90° 에서 옆으로 서서 폭 0
            const wide = Math.abs(Math.cos(ang));
            const lift = Math.sin(ang);
            const shade = 1 - FLIP_SHADE * (1 - wide);
            const front = f >= 0.5;

05노이즈 문턱값이 내려가며 로고가 가장자리부터 번져 나오는 로더

마크 안 화소 25,952개마다 4겹 값 노이즈와 가장자리 가까움을 반반 섞은 값을 처음에 한 번만 구해 두고, 프레임마다 1 − p × 1.07 로 내려가는 문턱과 비교해 넘은 화소만 칠하고 문턱 바로 위 0.07 폭은 하얗게 달군 띠로 남긴다. 진행률 20% 에서 먼저 드러난 화소는 가장자리에서 평균 3.3px 떨어져 있어(마크 전체 평균 15.3px) 윤곽부터 떠오르므로, 크리에이티브 포트폴리오의 인트로처럼 형체가 서서히 맺히는 연출에 쓴다. 렌더 수치로는 누적면적 17.7%, 강도 120.1, 17/23 프레임이다.

노이즈 문턱값이 내려가며 로고가 가장자리부터 번져 나오는 로더
새 창에서 열기

05 · 노이즈 문턱값이 내려가며 로고가 가장자리부터 번져 나오는 로더 — 측정 누적면적 17.708% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NOISE_SCALENOISE_OCTAVESEDGE_BAND
    function paint(thr) {
        if (!img || !offCtx) {
            return;
        }
        const px = img.data;
        let shownPx = 0;
        for (let i = 0; i < FIELD.length; i += 1) {
            const o = i * 4;
            if (!INSIDE[i]) {
                px[o + 3] = 0;
                continue;
            }
            const v = FIELD[i];
            if (v >= thr + EDGE_BAND) {
                // 넘은 화소 — 값이 높을수록(가장자리 쪽) 하늘색, 낮을수록 보라
                const c = mix(PAL[3], PAL[0], v);
                px[o] = c[0];
                px[o + 1] = c[1];
                px[o + 2] = c[2];
                px[o + 3] = 255;
                shownPx += 1;
            }
            else if (v >= thr) {
                // 띠 — 문턱에 가까울수록 더 하얗게 달군다
                const k = (v - thr) / EDGE_BAND;
                const c = mix(PAPER, PAL[4], k);
                px[o] = c[0];
                px[o + 1] = c[1];
                px[o + 2] = c[2];
                px[o + 3] = Math.round(255 * (0.55 + 0.45 * k));
            }
            else {
                px[o + 3] = 0;
            }
        }
        revealed = shownPx;
        offCtx.putImageData(img, 0, 0);
        ctx.drawImage(off, BX0, BY0);
    }

06로고 둘레를 도는 혜성 꼬리가 진행률만큼 길어지는 로더

마크를 감싼 둘레 965.6px 둥근 사각 경로를 머리가 1초에 0.55바퀴씩 돌고, 지난 위치를 90칸 고리 버퍼에 남겨 진행률 0·25·50·100% 에서 꼬리를 4·26·47·90점까지 늘리며 i 번째 점은 0.975^i 밝기로 흐려진다. 진행이 멈춰도 머리는 계속 돌기 때문에 결제 처리처럼 기다림이 길어질 수 있는 화면에서 멈춘 화면으로 오해받지 않는다. 23/23 프레임 내내 움직였고 누적면적은 26.9%, 강도는 64.3 이다.

로고 둘레를 도는 혜성 꼬리가 진행률만큼 길어지는 로더
새 창에서 열기

06 · 로고 둘레를 도는 혜성 꼬리가 진행률만큼 길어지는 로더 — 측정 누적면적 26.93% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ORBIT_SPEEDCOMET_TAIL_LENTAIL_DECAY
    function push(p) {
        ringX[ringHead] = p[0];
        ringY[ringHead] = p[1];
        ringHead = (ringHead + 1) % COMET_TAIL_LEN;
        ringCount = Math.min(ringCount + 1, COMET_TAIL_LEN);
    }
    // i 번째로 최근 위치(0 이 머리)
    function ringAt(i) {
        const k = (ringHead - 1 - i + COMET_TAIL_LEN * 2) % COMET_TAIL_LEN;
        return [ringX[k], ringY[k]];
    }
    function tailCount() {
        return Math.min(ringCount, TAIL_MIN + Math.round(clamp(progress, 0, 1) * (COMET_TAIL_LEN - TAIL_MIN)));
    }

07원 둘레의 점이 로고 외곽선으로 녹아드는 로더

원과 마크 외곽선을 같은 호 길이 간격의 96점으로 다시 뽑은 다음, 원의 시작점을 32개 후보로 돌려 보며 짝지은 거리 제곱 합이 가장 작은 자리(마크 쪽 번호를 27칸 민 자리)를 고르고 느리게-빠르게-느리게 곡선으로 짝끼리 보간한다. 이 정렬 하나로 합이 1,501,470 에서 76,223 으로 19.7배 줄고 가장 멀리 가는 점도 129.1px 에서 32.8px 로 짧아져, 로그인 뒤 원형 스피너가 마크로 녹아드는 전환에 쓸 수 있다. 수치는 누적면적 16.2%, 강도 92.2, 20/23 프레임으로 잡혔다.

원 둘레의 점이 로고 외곽선으로 녹아드는 로더
새 창에서 열기

07 · 원 둘레의 점이 로고 외곽선으로 녹아드는 로더 — 측정 누적면적 16.233% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MORPH_POINTSALIGN_SEARCHMORPH_EASE
    // 짝지은 거리 제곱의 합 — 원의 i 번째 점과 로고의 (i + off) 번째 점
    function alignCost(a, b, off) {
        let s = 0;
        for (let i = 0; i < a.length; i += 1) {
            const q = b[(i + off) % b.length];
            const dx = a[i][0] - q[0];
            const dy = a[i][1] - q[1];
            s += dx * dx + dy * dy;
        }
        return s;
    }
    function bestOffset(a, b) {
        let best = 0;
        let bestCost = Infinity;
        for (let k = 0; k < ALIGN_SEARCH; k += 1) {
            const off = Math.round((k * b.length) / ALIGN_SEARCH) % b.length;
            const cost = alignCost(a, b, off);
            if (cost < bestCost) {
                bestCost = cost;
                best = off;
            }
        }
        return best;
    }

08진행률만큼 층이 쌓여 두께가 생기는 입체 로고 로더

마크를 4px 간격으로 최대 14장 뒤로 쌓되 층 수를 p × 14 의 소수로 받아 새 층이 18px 더 깊은 곳에서 미끄러져 들어오게 하고, 30° 숙인 원근(초점 560)으로 투영해 뒤 층부터 어둡게 칠하는 화가 알고리즘으로 그린다. 다 쌓이면 두께 56px 이 아래 꼭짓점에서 19.5px 벌어져 보이고 좌우 22° 흔들림 끝에서는 옆으로 13.5px 드러나서, 게임이나 이벤트 페이지 진입 화면처럼 입체감이 필요한 자리에 어울린다. 이 장면은 누적면적 12.3%, 강도 73.8 로 작게 움직이지만 23/23 프레임을 채웠다.

진행률만큼 층이 쌓여 두께가 생기는 입체 로고 로더
새 창에서 열기

08 · 진행률만큼 층이 쌓여 두께가 생기는 입체 로고 로더 — 측정 누적면적 12.282% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

EXTRUDE_LAYERSLAYER_GAP_PXTILT_DEG
    // 세로축으로 yaw 만큼 돌린 뒤 가로축으로 −TILT_DEG 숙이고, 화면 배율 s = FOCAL / (FOCAL + z) 로 원근 나눗셈
    function project3(x, y, z, yaw, pitch) {
        const cy = Math.cos(yaw);
        const sy = Math.sin(yaw);
        const x1 = x * cy + z * sy;
        const z1 = -x * sy + z * cy;
        const cp = Math.cos(pitch);
        const sp = Math.sin(pitch);
        const y2 = y * cp - z1 * sp;
        const z2 = y * sp + z1 * cp;
        const s = FOCAL / (FOCAL + z2);
        return [CX + x1 * s, CY + y2 * s, s];
    }

09들쭉날쭉한 실제 진행을 매끄럽게 따라 세고 끝나면 로고가 한 번 부푸는 퍼센트 로더

도착할 때만 뛰는 계단 진행 값을 목표로 두고 표시값이 16ms 마다 남은 차의 6% 씩 따라가며, 새 값이 96ms 넘게 오지 않으면 목표를 조금씩 앞으로 밀되 미는 양에 0.96 을 거듭 곱해 점점 느리게 한다. 시연에서 표시값은 받은 양을 한 번도 넘지 않았고 거꾸로 간 스텝도 0 이며, 불러오기 시작부터 1,536ms 에 100% 에 닿으면 마크가 320ms 동안 한 번 부풀어서 보고서 생성처럼 숫자를 오래 지켜보는 대기 화면에 맞다. 렌더 결과는 누적면적 28.8%, 강도 137.6 으로 아홉 장면 가운데 가장 세고 23/23 프레임이다.

들쭉날쭉한 실제 진행을 매끄럽게 따라 세고 끝나면 로고가 한 번 부푸는 퍼센트 로더
새 창에서 열기

09 · 들쭉날쭉한 실제 진행을 매끄럽게 따라 세고 끝나면 로고가 한 번 부푸는 퍼센트 로더 — 측정 누적면적 28.847% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PROGRESS_SMOOTHSTALL_SLOWDOWNDONE_PULSE_MS
    function step() {
        const r = incoming();
        if (r !== reported) {
            reported = r;
            sameMs = 0;
            creep = 0;
            creepV = CREEP_V0;
        }
        else {
            sameMs += FIXED_DT;
        }
        // 멈춤 — 가상 진행을 따르는 동안 새 값이 STALL_AFTER_MS 넘게 안 오면 목표를 받은 값보다 조금씩 앞으로 밀되, 미는 빠르기에 STALL_SLOWDOWN 을 거듭 곱해 점점 느려진다
        if (sameMs > STALL_AFTER_MS && reported < 1 && manual < 0) {
            creep = Math.min(creep + creepV, (1 - reported) * CREEP_CAP);
            creepV *= STALL_SLOWDOWN;
        }
        const target = reported + creep;
        // 가상 진행을 따를 때는 거꾸로 가지 않는다 — 기어간 만큼보다 작은 값이 와도 표시값은 그 자리에서 기다린다
        if (target >= disp || manual >= 0) {
            disp += (target - disp) * (reported >= 1 ? FINISH_SMOOTH : PROGRESS_SMOOTH);
        }
        if (reported >= 1 && disp > 0.995) {
            disp = 1;
            if (doneAt < 0) {
                doneAt = clock;
            }
        }
        if (disp < 1) {
            doneAt = -1;
        }

진행률이 거꾸로 가거나 한참 멈추거나 로고 획이 가늘 때 — 로고 로딩 화면이 깨지는 순간

이 절의 수치는 vanilla index.html 의 함수를 그대로 불러 다시 돌린 결과이고, 기기마다 다른 프레임 처리 시간은 재지 않았다.

진행률이 뒤로 갈 때 장면마다 다른 반응

01·04·05 는 상태를 들고 있지 않아 진행률을 끌어서 되돌리면 선과 타일과 화소도 같이 되감긴다. 받은 양이 줄어드는 일은 실제로도 생길 수 있으니, 되감기는 연출이 어색한 자리라면 진행 값을 넘기기 전에 최댓값만 남기는 쪽이 낫다. 09 는 가상 진행을 따르는 동안 목표가 표시값보다 작아지면 그 자리에서 기다리고, 손으로 옮길 때만 뒤로 간다.

274ms 멈춤 동안 숫자가 앞질렀다 되돌아가는 일

멈춘 구간에서 목표를 미는 양에 상한과 감속이 없으면 다음 값이 오기 전에 표시값이 받은 양을 넘었다가 뒤로 물러난다. 09 는 미는 양을 남은 양의 40% 로 묶고 0.96 감속을 걸어 실제로 민 양이 최대 2.44%p 에 그쳤다. 감속을 1 로 풀면 3.68%p 까지 밀리고, 따라가는 빠르기를 0.15 로 올리면 민 목표까지 따라붙어 표시값이 받은 양보다 최대 1.82%p 앞선다. 위에서 말한 「넘지 않는다」 는 기본값 0.06 에서의 결과다. 끝 신호를 받은 뒤에도 6% 로만 좁히면 99% 근처에서 한참 머무르므로 그때만 0.25 로 빠르게 닫고, 숫자는 Math.floor 로 내려 써서 99.5% 에서 100 이 먼저 뜨지 않게 했다.

점 간격이 고르지 않은 외곽선과 이음새의 직선

점 번호로 펜을 옮기면 같은 시간에 0.22px 를 가는 칸과 12.85px 를 가는 칸이 섞여 모서리마다 펜이 멈칫한다. 외곽선 두 개를 한 표로 이을 때 이음새에도 거리를 더하면 바깥 끝에서 별 구멍으로 건너가는 직선이 한 줄 그어지는데, arcTable 은 그 자리에 0 을 더해 펜을 들고 건너뛴다.

획이 가늘면 화소 표본이 비고, 간격을 줄이면 점이 불어난다

03 의 표본은 3px 에서 시작해 6px 마다 칸 하나를 보는 격자라, 6px 보다 가는 획은 칸 중심을 비켜 가면 점을 하나도 받지 못할 수 있다. 간격을 줄이면 점이 빠르게 늘어난다. 글에 적은 718개는 브라우저가 안티에일리어싱까지 칠한 화면에서 센 값이고, 노드에서 같은 판독을 짝홀 판정으로 흉내 내면 721개가 나오며 그 기준으로 4px 간격은 1,621개(약 2.25배), 8px 간격은 400개다. 알파 문턱을 0 보다 큰 값으로 낮추면 반투명 가장자리 화소까지 점이 되고, 판독을 매 프레임 되풀이하면 화소 수만큼 일이 불어나므로 초기화 때 한 번만 읽는다.

물결이 터지는 세기와 멈추지 않는 감쇠

02 의 주고받는 세기를 0.6 으로 올리면 600스텝 안에 수면 높이가 4.3×10^307 까지 치솟는다. 소스 주석이 경고하는 대로 0.5 를 넘기면 받은 것보다 더 돌려주기 때문이고, 감쇠를 1 로 두면 출렁임이 줄지 않는다. 물결을 프레임마다 한 번씩 풀면 주사율이 높은 화면에서 더 빨리 출렁이므로 이 소스는 16ms 고정 스텝으로 푼다.

시간표 끝과 폭 0 의 깜박임

04 의 시간표 길이에 마지막 타일의 뒤집기 시간 400ms 를 넣지 않으면 진행률 100% 에서 마지막 대각 줄이 반쯤 선 채로 끝난다. 가로 폭이 0 이 되는 프레임에는 타일이 사라져 깜박이므로 폭은 0.02 아래로 내리지 않는다. 출발 간격을 30ms 로만 줄여도 시간표가 760ms 로 짧아져 13줄이 한꺼번에 뒤집히고, 진행률 50% 에서 64장이 모두 반쯤 서서 파면이 보이지 않는다.

시작점과 감는 방향이 어긋난 모핑

07 에서 시작점을 맞추지 않으면 점이 최대 129.1px 를 가로질러 모양이 꼬였다가 풀린다. 원은 각도로, 마크는 꼭짓점 번호로 뽑으면 모서리 쪽에 점이 몰려 짝이 어긋나므로 둘 다 호 길이로 다시 뽑았고, 두 외곽선이 반대 방향으로 감기면 어떤 시작점에서도 비용이 줄지 않아 둘 다 시계 방향으로 맞췄다.

미리 구하지 않은 노이즈, 0 에서 멈춘 문턱

05 가 노이즈를 프레임마다 다시 계산하면 화소 25,952개에 4겹씩을 매번 되풀이하게 되니, 값은 처음에 한 번 구하고 프레임에서는 비교만 한다. 문턱의 끝을 0 에 두면 100% 에서도 띠가 남아 가장자리가 달군 채 끝나므로 −0.07 까지 내린다.

꼬리 감쇠가 세면 길이를 늘려도 안 보인다

06 의 감쇠를 0.9 로 세게 하면 밝기가 29점째에 5% 아래로 떨어져, 진행률이 올라 꼬리를 90점까지 늘려도 보이는 길이는 그대로다. 0.975 에서는 그 경계가 119점째라 90점 꼬리가 끝까지 보인다. 지난 위치를 배열 앞에 끼워 넣는 식으로 버퍼를 다루면 스텝마다 90칸을 옮기게 되고, 고리 버퍼는 머리 칸 번호만 돌린다.

층을 칠하는 순서와 기울기

08 에서 앞 층부터 칠하면 뒤 층이 앞면을 덮어 버린다. 기울기를 10° 로 줄이면 아래 꼭짓점 벌어짐이 −0.7px 가 되어 층을 다 쌓아도 두께가 앞면 뒤에 숨고, 층 수를 정수로만 늘리면 열네 번 툭툭 끊겨 쌓인다.

아홉 폴더의 vanilla 소스를 내 진행 값과 내 마크로 바꿔 돌려 보고 싶다면, 내려받은 zip 을 열 때 5t93rr73 을 넣으면 되고 같은 계산을 옮긴 리액트 판도 함께 들어 있다.

물결 감쇠·타일 출발 간격·숫자 따라가는 빠르기를 옮겨 보는 변형과 움직임을 줄인 화면의 접근성

이름 값 느낌
물결 감쇠 키우기 02 WAVE_DAMP 0.985 → 0.94 기둥 하나를 밀었을 때 출렁임이 처음 최고점의 10% 아래로 내려가 그대로 머물기까지 182스텝(2,912ms) 이던 것이 52스텝(832ms)으로 짧아진다. 시연 진행에서 수면이 기준 수위를 벗어난 가장 큰 높이도 49.3px 에서 40.0px 로 낮아져 물이 짧게 흔들리고 곧 잔잔해진다.
타일 출발 간격 늘리기 04 SWEEP_DELAY_MS 60 → 120 시간표가 1,120ms 에서 1,840ms 로 길어지고 한 순간에 함께 뒤집히는 대각 줄이 7개에서 4개로, 진행률 50% 에서 반쯤 선 타일이 42장에서 18장으로 준다. 넓고 부드러운 파면이 좁고 또렷한 사선 물결로 바뀐다. 거꾸로 30ms 로 줄이면 50% 에서 64장이 모두 반쯤 서서 물결이 보이지 않는다.
숫자 따라가는 빠르기 올리기 09 PROGRESS_SMOOTH 0.06 → 0.15 끌어서 0 에서 50% 로 한 번에 옮겼을 때 남은 차가 10% 로 줄기까지 38스텝(608ms) 걸리던 것이 15스텝(240ms)이 된다. 숫자가 받은 값에 거의 바로 붙는 대신 멈춤 구간에서는 표시값이 받은 양보다 최대 1.82%p 앞선다.

표에 없는 값도 한 줄씩 바꿔 볼 만하다. 01 의 TRACE_EASE_POW 를 1 로 두면 25·50·75% 에서 정확히 그만큼(215·431·646px)을 그어 곡선 없는 고른 속도가 되고, 1 아래로 내리면 앞이 느리고 끝에서 몰아 닫는다. 03 의 SPRING_DAMP 를 20 으로 올리면 감쇠비가 1.054 가 되어 지나침이 사라지는 대신 1.5px 안에 머물기까지 800ms 가 걸린다.

아래 진행 띠는 role="progressbar"·aria-valuemin="0"·aria-valuemax="100" 과 「로고 불러오기 진행률」 이라는 이름을 갖고, aria-valuenow 에는 진행률이 아니라 그 순간 화면에 그린 값을 싣는다. 그래서 02 는 수위를, 03 은 도착한 점의 비율을, 09 는 표시값을 알려 듣는 숫자와 보이는 그림이 갈라지지 않는다.

키보드만으로도 아홉 장면을 다 다룬다. 캔버스마다 tabindex="0" 과 한국어 설명이 붙어 있어 포커스가 들어가면 Enter 로 처음부터 다시, Space 로 일시정지와 이어 가기, →·↑ 로 5% 앞으로, ←·↓ 로 5% 뒤로, Home 으로 0%, End 로 100% 를 고르고, Esc 를 누르면 손으로 옮긴 값을 놓고 가상 진행으로 돌아간다. 처리하는 키에만 기본 동작을 막아 페이지 스크롤은 그대로 살아 있고, 「다시 시작」·「일시정지」 는 진짜 <button> 이며 일시정지 버튼은 aria-pressed 로 눌림 상태를 알린다. 왼쪽 아래 상태 줄은 자동 시연이 도는 동안 aria-live="off" 로 조용하다가, 사람이 처음 끌거나 키를 누르거나 버튼을 건드리는 순간 polite 로 바뀌어 그 뒤의 상태만 읽어 준다.

prefers-reduced-motion 에서 reduce 를 고른 화면에서는 requestAnimationFrame 루프를 아예 시작하지 않는다. 대신 400ms 마다 setTimeout 이 가상 진행을 한 칸 옮기고, 물결·스프링·뒤집기 같은 중간 과정 없이 그 진행률의 끝 모습과 숫자만 계단식으로 바꿔 그린다. 04 의 타일은 반쯤 선 모습 없이 앞면 아니면 뒷면이고, 09 는 부풀기 없이 받은 값을 그대로 보이며, 06 은 머리를 세워 둔 채 꼬리를 한 번에 채우고, 시간 값 clock 으로 움직이던 03 의 맴돌기와 07 의 하이라이트, 08 의 흔들림도 멈춘다. 이 설정은 matchMedia 의 change 리스너가 지켜보고 있어서, 페이지를 연 채 설정을 켜면 루프를 멈추고 지금 진행률의 끝 모습으로 바꾸며 끄면 루프가 다시 돈다.

회사 로고 애니메이션을 영상 파일 대신 코드로 두면 무엇이 달라지는가?

영상은 정해진 길이로 재생되는 그림이라 불러오기가 멈추든 거꾸로 가든 화면은 제 길대로 흘러간다. 코드로 둔 마크는 진행 값을 받아 그 값의 모습을 그리므로 멈추면 멈추고, 끝나는 순간에 맞춰 09 처럼 한 번 부풀 수도 있다. 진행 띠의 aria-valuenow 와 동작 줄이기 설정 대응, 키보드 조작도 같은 소스 안에 함께 들어간다. 색은 팔레트 상수, 빠르기와 감쇠는 이름 붙은 상수 한 줄이라 브랜드 색이나 분위기에 맞추려고 영상을 다시 뽑을 일도 없다.

시간에 묶어 조각을 맞추는 쪽이 필요하면 인트로 애니메이션 9종, 마크 없이 물리 계산으로 도는 로더는 캔버스 물리 로더 9종, CSS 만으로 꾸미는 로딩 화면은 로딩 화면 디자인 9종 에서 이어 볼 수 있다. 03 처럼 화소를 점으로 뜯는 기법은 글자 화소 표본 타이포그래피, 화소 단위 계산으로 장면을 만드는 쪽은 캔버스 특수효과 9종 이 가깝다.

로고 로딩 화면을 내 사이트 첫 화면에 붙일 때 자주 나오는 질문

실제 불러오기 진행률은 코드의 어느 자리에 넣나?

데모의 진행 값은 makeLoad(469) 가 만든 가상 표이고 네트워크 요청은 하나도 없다. 01~08 은 targetProgress() 가 돌려주는 loadAt(loadMs) 를 내 진행 값(0~1)으로 바꾸면 되고, 09 는 incoming() 안의 reportedAt(loadMs) 자리에 띄엄띄엄 들어오는 값을 그대로 넣는다. 09 의 평활과 멈춤 감속은 바로 그런 계단 값을 다듬으려고 있는 계산이다.

로딩 중 로고를 내 마크 모양으로 바꿀 수 있나?

마크는 LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6) 과 LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3) 두 점 배열이라, 이 두 줄을 내 외곽선 좌표로 바꾸면 아홉 장면이 그 모양을 쓴다. 01 은 호 길이 표 덕분에 어떤 외곽선이든 같은 빠르기로 긋고, 03 은 칠해진 화소를 읽으므로 모양을 가리지 않는다. 07 은 두 외곽선이 원과 같은 시계 방향으로 감겨 있어야 정렬 비용이 제대로 줄어든다.

끝을 알 수 없는 기다림에도 쓸 수 있나?

아홉 장면은 0 에서 1 까지의 진행 값이 있다는 전제로 그린다. 진행 값이 없는 대기라면 06 이 가장 가깝다. 06 의 머리는 진행과 상관없이 스텝마다 STEP_LEN 만큼 돌고 꼬리 길이만 진행 값을 따르므로, 꼬리 쪽에 내가 아는 만큼의 값을 넣고 나머지는 도는 머리에 맡기면 된다.

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

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