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

스크롤 인터랙션 9종 — 선 긋기·길 따라가기·꼬리·나선

굽은 길 위의 스크롤 인터랙션은 빠르기부터 어긋난다. 스크롤 진행률을 그대로 곡선 변수에 넣으면 01번 펜 끝의 빠르기가 굽이마다 6.2배까지 벌어진다.

무대 안에서 굴려 보는 스크롤 진행률 아홉 장면

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

손대지 않은 칸은 시드를 고정한 왕복 시간표를 따라 저절로 굴러간다. 한 바퀴 5,400ms 는 200ms 기다림, 2,600ms 앞으로 가기, 500ms 끝 대기, 1,800ms 되돌아가기, 300ms 쉼으로 짜였고, 앞으로 가는 구간은 네다섯 토막으로 나뉘어 토막마다 나아가는 몫과 걸리는 시간이 달라서 빠르기가 계속 바뀐다. 칸 위에서 휠을 굴리거나 칸을 누르거나 Tab 으로 초점을 옮기면 시연이 그 자리에서 멈추고, 그때의 진행률에서부터 사람이 이어 굴린다.

순서는 길이 표를 만드는 01 에서 시작한다. 02 는 그 표에서 접선을, 03 은 이정표의 길이를, 04 는 법선과 빠르기를 꺼내 쓰고, 05 는 표 없이 3차원 나선으로 한 번 비켜 간다. 06 은 다시 표 위의 머리에 꼬리를 매달고, 07 은 곡선 대신 두 외곽선의 둘레를 재며, 08 은 점 160개가 한 표를 나눠 쓰고, 09 는 01·02·03 을 배송 경로 하나에 묶는다.

스크롤 인터랙션 9종 — 고른 선 긋기·접선 따라 도는 비행기·이정표 핀·빠르기 출렁임·나선 투영·끌려오는 꼬리·모양 바꾸기·흐르는 점·배송 추적 여정

아홉 장면은 전부 캔버스 2D 위에 그려지고, CSS 쪽 @keyframes 는 0개다. 요소가 화면에 들어오는 순간 나타나게 하는 스크롤 애니메이션 효과와 달리, 여기서는 진행률이라는 숫자 하나를 곡선 위 길이로 바꾸고 그 길이에서 점·접선·법선을 꺼내 움직임을 만든다. 움직임은 requestAnimationFrame 이 넘겨 주는 시간을 16ms 고정 스텝으로 쪼개 한 프레임에 최대 6번 계산하고, 프레임 사이가 길어도 100ms 까지만 따라잡는다.

캔버스 위에 투명한 스크롤 상자를 겹친 구조

아홉 칸의 생김새는 같다. 그림은 aria-hidden="true" 캔버스가 맡고, 그 위에 .sp__scroller 상자가 position: absolute; inset: 0 으로 투명하게 겹쳐 휠과 손가락과 키보드를 받는다. 상자 안의 빈 .sp__track 은 height: 600% 라서 상자 높이의 다섯 배를 굴리면 진행률이 0 에서 1 로 간다. 진행률은 아래처럼 상자 자신의 scrollTop 만 읽는다.

    // 상자 진행률 — 굴린 거리 / 굴릴 수 있는 거리. 페이지가 아니라 무대 안 상자의 scrollTop 만 읽는다
    function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
    function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
    function placeScroll(p) {
        scroller.scrollTop = p * maxScroll();
        expectTop = scroller.scrollTop;
    }

480×300 창에서 무대는 418.6×261.6px 이고 굴릴 수 있는 거리는 그 높이의 다섯 배인 1,307px 였다. 휠을 300px 한 번 굴리면 진행률이 약 23%p 오르고, 그동안 window.scrollY 는 0 그대로였다. 시연 중에는 코드가 상자도 같은 진행률로 굴려 두어 스크롤 막대가 함께 움직이고, 코드가 굴린 자리는 expectTop 에 적어 두어 사람의 입력으로 착각하지 않는다.

2초 동안 바뀐 화면을 23번 세어 본 결과

캡처 24장 사이의 변화 23번 가운데 화면이 바뀐 횟수를 프레임 칸에, 바뀐 화소가 무대에서 차지한 넓이를 누적면적 칸에 적었다.

장면 누적면적 움직인 프레임 강도 평균
01 고른 선 긋기 18.6% 21/23 82.7
02 접선 따라 도는 비행기 11.3% 23/23 112.1
03 이정표 핀 15.1% 23/23 97.0
04 빠르기 출렁임 17.9% 23/23 80.3
05 나선 투영 47.7% 23/23 119.1
06 끌려오는 꼬리 22.1% 23/23 142.7
07 모양 바꾸기 25.6% 23/23 106.7
08 흐르는 점 24.6% 23/23 127.8
09 배송 추적 여정 12.9% 23/23 120.4

05 의 47.7% 가 유난히 큰 것은 계단 판이 무대 가운데를 크게 채운 채 카메라가 공을 따라 내려가 판 전체가 위로 밀려 올라가기 때문으로 보인다. 02 와 09 는 선 하나와 비행기, 또는 상자 하나가 지나가는 자리만 바뀌어 11~13% 에 머무는 것으로 보인다. 01 만 21/23 인 것은 선이 진행 6% 를 넘어야 긋기 시작하는데 그 앞에 200ms 기다림까지 겹쳐 처음 몇 장이 같은 화면이었던 탓으로 보인다.

서비스 소개 페이지부터 배송 조회까지, 이런 화면에 붙인다

장면마다 실제 페이지의 자리를 하나씩 맡겼다. 01 은 서비스 소개 페이지를 따라 내려가는 흐름선, 02 는 문의하기 섹션으로 시선을 데려가는 장식, 03 은 회사 연혁 타임라인, 04 는 랜딩 페이지의 구간 구분선이다. 05 는 앱 기능을 단계별로 보여 주는 소개, 06 은 브랜드 마스코트가 길을 안내하는 장면, 07 은 요금제 비교 구간의 아이콘 전환, 08 은 데이터가 오가는 흐름을 설명하는 도식이다.

09 는 주문 배송 조회 화면을 흉내 낸 가상 장면이다. 지명이나 주소는 없고 정거장은 「출발지·정거장 1·정거장 2·도착지」 네 개, 지도 구역은 시드로 크기만 달리한 네모 블록 40개다. 실제 배송 조회 화면에 옮긴다면 진행률 자리에 스크롤 대신 배송 단계에서 계산한 값을 넣는 쪽이 맞아 보이고, 그 경우에도 남은 거리를 길이 비율로 세는 계산은 그대로 쓸 수 있다.

01굽은 길도 같은 빠르기로 그어지는 선

지나는 점 9개를 일부러 고르지 않은 간격으로 놓은 길을 진행률 6%~94% 구간에서 긋는데, 진행률을 곡선 변수 u 로 곧장 쓰지 않고 PATH_SAMPLES 200 토막으로 잰 누적 길이 표에서 그 길이에 해당하는 u 를 이분 탐색으로 찾는다. 진행률을 같은 걸음으로 400번 올려 보면 펜 끝은 걸음마다 3.047~3.267px 씩 나아가 1.07배 안에 들고, 같은 길이마다 찍은 눈금 24개가 그 고름을 눈으로 확인시켜 주니 서비스 소개 페이지의 흐름선처럼 선이 일정하게 자라야 하는 곳에 맞는다.

굽은 길도 같은 빠르기로 그어지는 선
새 창에서 열기

01 · 굽은 길도 같은 빠르기로 그어지는 선 — 측정 누적면적 18.596% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PATH_SAMPLESDRAW_STARTDRAW_END
    function buildLengthTable(curve, samples) {
        const us = [0];
        const lens = [0];
        let prev = curve(0);
        let acc = 0;
        for (let i = 1; i <= samples; i += 1) {
            const u = i / samples;
            const p = curve(u);
            acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
            us.push(u);
            lens.push(acc);
            prev = p;
        }
        return { curve: curve, us: us, lens: lens, total: acc };
    }
    // 길이 → u — 누적 길이 표에서 s 가 드는 토막을 이분 탐색으로 찾고, 토막 안은 길이에 비례해 나눈다
    function uAtLength(tab, s) {
        const L = clamp(s, 0, tab.total);
        let lo = 0;
        let hi = tab.lens.length - 1;
        while (hi - lo > 1) {
            const mid = (lo + hi) >> 1;
            if (tab.lens[mid] < L) {
                lo = mid;
            }
            else {
                hi = mid;
            }
        }
        const span = tab.lens[hi] - tab.lens[lo];
        const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
        return lerp(tab.us[lo], tab.us[hi], k);
    }

02길의 접선 방향으로 고개를 돌리며 나는 종이비행기

비행기는 지금 길이에서 18px 앞선 점과의 차로 접선 각을 구하고, 머리와의 각도 차를 -180°~180° 로 접은 뒤 TURN_SMOOTH 0.14 만큼만 틱마다 돈다. 거꾸로 굴리면 마지막으로 움직인 방향 dir 이 -1 로 바뀌어 뒤쪽 점을 보므로 되돌아가기 시작한 순간 목표가 177.6° 뒤집히고, 비행기는 그 반 바퀴를 640ms 에 걸쳐 돌아 5° 안으로 들어온다. 분홍 점선이 목표 방향을 먼저 가리키고 비행기가 그 뒤를 쫓는 모습이라 문의하기 섹션으로 눈길을 끄는 장식에 어울린다.

길의 접선 방향으로 고개를 돌리며 나는 종이비행기
새 창에서 열기

02 · 길의 접선 방향으로 고개를 돌리며 나는 종이비행기 — 측정 누적면적 11.272% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOLLOW_LOOKAHEADTURN_SMOOTHPLANE_SIZE
    function aimAt(s) {
        const a = pointAtLength(TABLE, s);
        const b = pointAtLength(TABLE, s + dir * FOLLOW_LOOKAHEAD);
        const dx = b[0] - a[0];
        const dy = b[1] - a[1];
        if (Math.hypot(dx, dy) < 0.5) {
            return target;
        }
        return Math.atan2(dy, dx);
    }
    function step(dp) {
        if (dp > 1e-6) {
            dir = 1;
        }
        else if (dp < -1e-6) {
            dir = -1;
        }
        target = aimAt(travel());
        // 가장 짧은 방향 — 각도 차를 -180°~180° 로 접은 뒤 그 비율만큼만 돈다
        turn = wrapAngle(target - heading) * TURN_SMOOTH;
        heading = wrapAngle(heading + turn);
    }

03지나는 순간 스프링으로 솟는 길 위 이정표

이정표 5개는 곡선 변수 u 를 0.1·0.3·0.5·0.7·0.9 로 고르게 나눈 자리에 세우지만 길이로 재면 길 543.2px 의 8.0·27.1·48.0·69.3·90.3% 지점이라서, lengthAtU 로 자리마다 길이를 미리 재 두고 진행 길이가 그 값을 넘는 순간 깃발의 목표를 1 로 바꾼다. 강성 0.12·감쇠 0.8 의 스프링이라 깃발은 곧게 선 자리를 29.2° 넘어 젖혀졌다가 544ms 에 자리를 잡고, 되돌려 굴리면 같은 544ms 동안 반대쪽으로 출렁이며 눕는다. 연도를 지날 때마다 표식이 일어서는 회사 연혁 타임라인으로 옮기기 좋다.

지나는 순간 스프링으로 솟는 길 위 이정표
새 창에서 열기

03 · 지나는 순간 스프링으로 솟는 길 위 이정표 — 측정 누적면적 15.065% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MILESTONE_COUNTPIN_STIFFNESSPIN_DAMPING
    function step(dp) {
        const s = travel();
        for (let i = 0; i < PINS.length; i += 1) {
            const pin = PINS[i];
            pin.up = s >= pin.s;
            const goal = pin.up ? 1 : 0;
            pin.v += (goal - pin.x) * PIN_STIFFNESS;
            pin.v *= PIN_DAMPING;
            pin.x += pin.v;
        }
    }

04빨리 굴릴수록 굵어지고 출렁이는 선

틱마다 진행률 차의 절댓값을 VELOCITY_SMOOTH 0.08 저역 필터로 고른 빠르기가 진행률/틱 0.008 에서 상한에 닿고, 그 값에 비례해 표시점 둘레의 선이 굵기 3px 에서 16px 까지 부풀며 법선 방향 사인 물결이 최대 14px 비킨다. 표시점에서 멀어질수록 물결과 굵기는 150px 폭의 종 모양으로 잦아들고, 손을 떼면 필터 값은 464ms 만에 8% 아래로, 물결 폭은 480ms 만에 1px 아래로 가라앉아 랜딩 페이지 구분선이 굴린 기세를 잠깐 머금었다 놓는 모습이 된다.

빨리 굴릴수록 굵어지고 출렁이는 선
새 창에서 열기

04 · 빨리 굴릴수록 굵어지고 출렁이는 선 — 측정 누적면적 17.865% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

VELOCITY_SMOOTHWOBBLE_AMP_MAXSTROKE_WIDTH_MAX
    function step(dp) { vel += (Math.abs(dp) - vel) * VELOCITY_SMOOTH; }
    // 길이 s 자리의 물결 — 법선 방향 사인 비킴 × 종 모양 감쇠
    function waveAt(s, s0, amp) {
        const base = pointAtLength(TABLE, s);
        const n = normalAt(TABLE, s);
        const d = (s - s0) / WAVE_REACH;
        const env = Math.exp(-d * d);
        const off = Math.sin(s * WAVE_K - prog * WAVE_DRIFT) * amp * env;
        return [[base[0] + n[0] * off, base[1] + n[1] * off], env];
    }

05나선 계단을 돌아 내려가는 공과 원근

진행률 하나가 나선의 각도(2.5바퀴)와 높이(300)를 함께 정비례로 정하므로 이 장면은 길이 표 없이도 같은 빠르기로 내려가고, 계단 점은 카메라 높이를 빼고 0.5 rad 기울인 뒤 360 ÷ (360 + 깊이) 배율로 화면에 옮긴다. 그 배율이 가장 먼 판 끝 0.751 에서 가장 가까운 쪽 1.496 까지 벌어져 공 지름은 21.5px 에서 34.3px 사이를 오가고, 판·기둥 토막·공 그림자·공을 깊이 큰 것부터 다시 정렬해 그리며 가장 먼 쪽은 바탕색으로 55% 섞어 흐린다. 앱 기능을 한 단계씩 내려가며 소개하는 장면에 쓴다.

나선 계단을 돌아 내려가는 공과 원근
새 창에서 열기

05 · 나선 계단을 돌아 내려가는 공과 원근 — 측정 누적면적 47.66% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HELIX_TURNSHELIX_RADIUSCAMERA_DISTANCE
    function project(x, y, z) {
        const yy = y - camY;
        const c = Math.cos(CAMERA_TILT);
        const sn = Math.sin(CAMERA_TILT);
        const y2 = yy * c - z * sn;
        const z2 = yy * sn + z * c;
        const k = CAMERA_DISTANCE / (CAMERA_DISTANCE + z2);
        return { x: CX + x * k, y: CY + y2 * k, z: z2, k: k };
    }

06마디 길이를 지키며 길을 따라 끌려오는 꼬리

머리는 길 위 진행 길이 자리에 붙이고, 마디 12개는 지난 틱에 간 만큼에 0.9 를 곱해 이번 틱에도 간 뒤 이웃 거리를 15px 로 6번 되맞춘다. 시연 한 바퀴에서 마디 길이 오차는 최대 18.3% 에 머물지만 되맞춤을 1번으로 줄이면 88.1% 까지 늘어나 꼬리가 고무줄처럼 늘어지니, 브랜드 마스코트처럼 꼬리 모양이 캐릭터의 일부인 장면에서는 반복 횟수를 아끼지 않는 편이 낫다.

마디 길이를 지키며 길을 따라 끌려오는 꼬리
새 창에서 열기

06 · 마디 길이를 지키며 길을 따라 끌려오는 꼬리 — 측정 누적면적 22.145% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TAIL_SEGMENTSSEGMENT_LENGTHCONSTRAINT_ITERATIONS
        // 이전 위치와의 차로 움직이기 — 지난 틱에 간 만큼(끌림을 곱해) 이번 틱에도 간다
        for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
            const vx = (xs[i] - pxs[i]) * TAIL_DRAG;
            const vy = (ys[i] - pys[i]) * TAIL_DRAG;
            pxs[i] = xs[i];
            pys[i] = ys[i];
            xs[i] += vx;
            ys[i] += vy;
        }
        // 마디 거리 되맞춤 — 머리(0)는 길 위에 고정, 1 번은 혼자 다 움직이고 나머지 이웃 쌍은 반씩 나눠 움직인다
        for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
            for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
                const dx = xs[i] - xs[i - 1];
                const dy = ys[i] - ys[i - 1];
                const d = Math.hypot(dx, dy) || 1e-6;
                const diff = (d - SEGMENT_LENGTH) / d;
                if (i === 1) {
                    xs[i] -= dx * diff;
                    ys[i] -= dy * diff;
                }
                else {
                    xs[i] -= dx * diff * 0.5;
                    ys[i] -= dy * diff * 0.5;
                    xs[i - 1] += dx * diff * 0.5;
                    ys[i - 1] += dy * diff * 0.5;
                }
            }
        }

07같은 길이 간격으로 점을 다시 찍어 모양을 바꾸는 선

별 10점과 하트 40점은 점 사이 간격이 제각각이라(하트는 2.07~20.71px) 둘레 길이를 따라 96점씩 같은 간격으로 다시 찍고, 96가지 돌림 가운데 짝지은 점의 거리 제곱 합이 가장 작은 48칸을 골라 시작점을 맞춘다. 맞추지 않으면 점 하나가 평균 137.6px 를 가로질러 모양 안에서 엇갈리지만 48칸을 돌리면 34.4px 로 줄고, 진행률 0.25~0.75 동안 모양은 무대 가운데 붙박인 채 섞여 요금제 비교 구간의 아이콘이 바뀌는 자리에 어울린다.

같은 길이 간격으로 점을 다시 찍어 모양을 바꾸는 선
새 창에서 열기

07 · 같은 길이 간격으로 점을 다시 찍어 모양을 바꾸는 선 — 측정 누적면적 25.627% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MORPH_POINTSPIN_RANGE_STARTPIN_RANGE_END
    // 시작점 맞춤 — b 를 k 칸 돌려 짝지었을 때 거리 제곱 합이 가장 작은 k 를 고른다
    function bestShift(a, b) {
        const n = a.length;
        let best = 0;
        let bestCost = Infinity;
        for (let k = 0; k < n; k += 1) {
            let cost = 0;
            for (let i = 0; i < n; i += 1) {
                const q = b[(i + k) % n];
                cost += (a[i][0] - q[0]) * (a[i][0] - q[0]) + (a[i][1] - q[1]) * (a[i][1] - q[1]);
            }
            if (cost < bestCost) {
                bestCost = cost;
                best = k;
            }
        }
        return best;
    }

08스크롤 빠르기만큼 길 위를 흘러가는 점 무리

점 160개는 저마다 길 위 길이와 옆으로 비킨 거리를 따로 갖고, 굴려서 길 위로 움직인 px 를 0.2 비율로 고른 흐름 값에 이득 2.4 를 곱해 기본 흐름 0.5px 에 더한 만큼 매 틱 나아간다. 옆 거리는 시드 256칸 값 노이즈로 ±40px 안에서 흔들려 물살처럼 보이고, 시연 중 한 틱 전진이 최대 20.01px(기본의 40배)까지 올랐다가 되돌아갈 때는 −18.71px 로 거꾸로 흘러 데이터가 어느 쪽으로 오가는지 보여 주는 도식에 맞는다.

스크롤 빠르기만큼 길 위를 흘러가는 점 무리
새 창에서 열기

08 · 스크롤 빠르기만큼 길 위를 흘러가는 점 무리 — 측정 누적면적 24.613% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLOW_DOTSFLOW_SPEED_GAINFLOW_SPREAD
    function step(dp) {
        flow += (dp * TABLE.total - flow) * FLOW_SMOOTH;
        age += 1;
        const adv = advance();
        for (let i = 0; i < dots.length; i += 1) {
            const d = dots[i];
            d.s = ((d.s + adv * d.pace) % TABLE.total + TABLE.total) % TABLE.total;
        }
    }
    function sideOf(d, s) {
        return clamp(d.lane * FLOW_SPREAD * 0.75 + noise1(s * JITTER_SCALE + age * JITTER_TIME + d.seed) * FLOW_SPREAD * JITTER_AMP, -FLOW_SPREAD, FLOW_SPREAD);
    }

09지도 길을 따라 달리며 남은 거리를 세는 배송 추적 여정

01 의 선 긋기, 02 의 접선 쫓기, 03 의 스프링 핀을 길이 695.1px 인 경로 하나에 묶는다. 상자는 접선 방향으로 몸을 돌리지 않고, 감쇠로 쫓는 접선 각이 이번 틱에 돈 만큼에 8 을 곱해 굽이 안쪽으로 기울 뿐이며 그 기울기는 ±0.35 rad(20.1°)를 넘지 않는다. 남은 거리는 12.6km × (1 − 간 길이 ÷ 전체 길이)로 세기 때문에 u 를 고르게 나눠 세운 정거장 넷이 남은 12.6·8.66·4.10·0km 처럼 고르지 않은 간격으로 지나가고, 상태 줄은 「출발 준비」에서 「출발지 지남」·「정거장 1 지남」처럼 지난 정거장 이름 뒤에 「지남」이 붙은 글자를 거쳐 「도착했어요」로 바뀐다.

지도 길을 따라 달리며 남은 거리를 세는 배송 추적 여정
새 창에서 열기

09 · 지도 길을 따라 달리며 남은 거리를 세는 배송 추적 여정 — 측정 누적면적 12.894% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ROUTE_TOTAL_KMSTOP_COUNTBOX_TILT_MAX
    function step(dp) {
        if (dp > 1e-6) {
            dir = 1;
        }
        else if (dp < -1e-6) {
            dir = -1;
        }
        // 02 — 접선 각을 가장 짧은 방향으로 감쇠해 쫓고, 이번 틱에 돈 각에 비례해 상자를 굽이 안쪽으로 기울인다
        target = aimAt(travel());
        turn = wrapAngle(target - heading) * ROUTE_TURN_SMOOTH;
        heading = wrapAngle(heading + turn);
        tilt += (clamp(turn * TILT_GAIN, -BOX_TILT_MAX, BOX_TILT_MAX) - tilt) * 0.2;
        // 03 — 정거장 길이를 넘으면 핀이 스프링으로 솟고, 되돌리면 다시 눌린다
        const s = travel();
        for (let i = 0; i < STOPS.length; i += 1) {
            const st = STOPS[i];
            st.up = s >= st.s;
            st.v += ((st.up ? 1 : 0) - st.x) * STOP_STIFFNESS;
            st.v *= STOP_DAMPING;
            st.x += st.v;
        }
    }

진행률을 그대로 곡선 매개변수에 넣으면 왜 굽은 곳에서 빨라지고 느려지는가?

토막마다 같은 몫의 u 를 받는 곡선

이 편의 길은 makeCurve 가 지나는 점을 이어 만든 3차 곡선이다. 점과 점 사이 한 토막의 모양은 이웃 네 점으로 정하고, 곡선 변수 u 는 토막의 길이와 상관없이 토막마다 같은 몫을 나눠 받는다. 그래서 긴 토막에서는 u 가 조금만 움직여도 펜 끝이 멀리 가고, 짧은 토막이나 급히 휘는 곳에서는 같은 u 변화가 몇 px 에 그친다. 진행률이 일정하게 늘어도 화면 위 빠르기가 갈라지는 까닭이 여기에 있다.

걸음 400번으로 잰 펜 끝 빠르기

01 의 길에서 진행률을 같은 간격으로 400번 올리고, 4,000 토막으로 잰 참 길이와 비교해 펜 끝이 한 걸음에 나아간 거리를 모았다. 길 전체는 1,261.8px 이고 200 토막 표가 잰 값은 1,261.1px 이다.

진행률을 위치로 바꾸는 법 걸음당 최소 걸음당 최대 최대 ÷ 최소 변동계수
u 에 그대로 넣기 0.842px 5.191px 6.16배 36.49%
24 토막 길이 표 1.252px 5.365px 4.29배 25.24%
200 토막 길이 표 3.047px 3.267px 1.07배 1.12%

u 를 그대로 넣어도 u 0.25·0.5·0.75 는 길 전체의 23.3%·47.7%·76.6% 지점이라 뭉뚱그려 보면 엇비슷하다. 문제는 그 사이의 걸음 하나하나가 6배 넘게 벌어진다는 데 있고, 눈은 평균보다 그 덜컹거림을 먼저 알아챈다. 페이지 전체를 미끄러지게 하는 부드러운 스크롤 효과를 켜 두어도 이 덜컹거림은 남는다. 진행률이 아무리 매끄럽게 들어와도 그것을 u 로 옮기는 단계에서 빠르기가 갈라지기 때문이다. SVG <path> 라면 getTotalLength·getPointAtLength 가 길이 재기를 대신 해 주지만(SVG 경로 길이 재기 설명), 캔버스에는 그런 함수가 없어서 표를 직접 만든다.

곡선을 토막 내 누적 길이 표를 만들고, 진행률을 길이로 바꿔 점과 접선을 찾는 계산

표는 처음에 한 번, 찾기는 매 틱 이분 탐색

buildLengthTable 은 u 를 200 토막으로 잘라 토막 끝 점까지의 직선 거리를 차례로 더해 둔다. 장면이 시작할 때 한 번만 하는 일이고, 그 뒤로 매 틱 하는 일은 uAtLength 가 길이 s 가 들어가는 토막을 이분 탐색으로 찾는 것뿐이다. 201칸짜리 표라 비교는 8번 안에 끝나고, 찾은 토막 안에서는 길이에 비례해 u 를 나눈다. 01 은 진행률을 DRAW_START·DRAW_END 사이로 잘라 0~1 로 다시 늘린 값에 전체 길이를 곱해 그을 길이를 정하고, 그릴 때는 tracePath 가 u 를 600 걸음으로 잘게 따라가므로 표의 토막 수가 줄어도 선 자체는 매끈하게 남는다.

같은 표에서 접선과 법선을 꺼내는 법

표가 있으면 길이 s 의 점을 바로 얻으니 방향도 길이로 잰다. 02 와 09 는 s 와 s + 18px(09 는 16px) 두 점의 차를 atan2 에 넣어 접선 각을 얻고, 두 점이 0.5px 안으로 붙는 길 끝에서는 이전 각을 그대로 둔다. 04 의 normalAt 은 s 앞뒤 1px 두 점의 차를 90° 돌려 법선을 만들고, 물결은 그 방향으로 비킨다. 03 과 09 는 거꾸로 u → 길이 방향의 lengthAtU 를 써서 이정표와 정거장이 길 위 몇 px 에 있는지 미리 재 둔다.

길이 표를 쓰지 않는 두 장면

05 의 나선은 각도와 높이가 둘 다 진행률에 정비례하는 고른 나선이라 길이를 잴 필요가 없다. 07 은 곡선이 아니라 점을 이은 다각형 외곽선이므로 resample 이 변 길이를 직접 더해 둘레를 재고 같은 간격마다 점을 다시 찍는다. 나머지 일곱 장면, 01~04·06·08·09 가 같은 표 만들기와 찾기를 함께 쓴다.

토막 수를 줄이고 출렁임을 키우고 꼬리를 늘린 세 가지 변형

이름 값 느낌
길이 표 토막 줄이기 01 PATH_SAMPLES 200 → 24 200 이면 진행률을 같은 걸음으로 올릴 때 펜 끝이 걸음마다 3.05~3.27px(1.07배 안)로 고르게 나아간다. 24 로 줄이면 표 토막 하나가 길어져 걸음마다 1.25~5.37px(4.3배)로 벌어지고, 급한 굽이에서 선이 잠깐 앞질러 가다 멈칫한다
출렁임 폭 키우기 04 WOBBLE_AMP_MAX 14 → 36 14 면 가장 빠를 때 표시점 둘레의 물결이 법선 방향으로 14px 비킨다. 36 으로 키우면 36px 로 크게 굽이치고, 멈춘 뒤 빠르기 필터가 잦아드는 시간(464ms)은 같아도 물결이 1px 아래로 가라앉기까지 480ms 에서 656ms 로 늘어난다
꼬리 마디 늘리기 06 TAIL_SEGMENTS 12 → 30 12 면 180px 꼬리가 머리 뒤를 따라오고 시연 중 마디 길이 오차는 최대 18.3% 다. 30 으로 늘리면 450px 로 길어져 꼬리 끝이 길에서 최대 447.5px 벗어나며 굽이 안쪽을 크게 가로지르고, 같은 6번 되맞춤으로는 마디 오차가 최대 36.4% 로 커진다

세 값은 서로 다른 것을 판다. 토막 수는 표를 만드는 품을 줄이는 대신 고른 빠르기를 내주고, 출렁임 폭은 굴린 기세를 크게 보여 주는 대신 멈춘 뒤 가라앉는 시간을 늘린다. 꼬리를 늘리면 꼬리 끝이 길에서 멀리 벗어나므로, 마디 수를 30 처럼 키울 때는 CONSTRAINT_ITERATIONS 도 함께 올려 오차를 다시 재 보는 편이 낫다.

날카롭게 꺾인 길·거꾸로 스크롤·한 번에 멀리 튄 휠 — 길 따라가기가 깨지는 곳

꺾인 굽이에서 토막이 모자랄 때

24 토막 표로 줄이면 펜 끝 빠르기가 4.29배까지 벌어질 뿐 아니라 표가 잰 길 전체도 1,242.9px 로 참 길이보다 18.9px 짧아진다. 토막 끝을 잇는 직선은 곡선의 휜 몸통을 질러가기 때문이고, 굽이가 급할수록 한 토막 안에서 u 와 길이가 비례하지 않아 그 안에서 빠르기가 다시 출렁인다.

거꾸로 굴리는 순간 뒤집히는 접선

02 와 09 는 방향이 바뀌는 순간 앞선 점이 뒤쪽으로 넘어가 접선 목표가 177.6° 뒤집힌다. TURN_SMOOTH 를 1 로 두면 비행기는 같은 자리에서 한 틱 16ms 만에 반 바퀴를 휙 돌고, 0.14 면 640ms 에 걸쳐 돈다. 앞선 거리 FOLLOW_LOOKAHEAD 를 4px 로 줄이면 5° 안으로 돌아오기까지 1,680ms 가 걸려 오히려 굼떠졌으니, 앞을 가까이 본다고 방향을 빨리 바로잡는 것은 아니다.

휠 한 번에 진행률이 크게 뛸 때

End 키처럼 진행률이 한 틱에 크게 들어오면 04 는 빠르기 수준이 곧장 상한으로 뛰어 상태 줄에 「빠르게 굴리는 중」이 뜬다. 06 은 머리가 순간이동하듯 옮겨 가 6번 되맞춤으로는 다 못 따라가고 마디가 늘어난 채 끌려온다. 03 은 이정표 여럿을 한 번에 지나면 깃발이 저마다 따로 출렁이는데, 감쇠를 0.8 에서 0.95 로 올리면 넘침이 29.2° 에서 71.7° 로, 정착이 544ms 에서 2,368ms 로 늘어나 깃발들이 한참 동안 흔들린다.

길이 표·접선 쫓기·마디 되맞춤을 그대로 담은 아홉 장면의 index.html 과 같은 계산을 옮긴 리액트 판은 zip 하나에 들어 있고, 압축은 tmvtd6sv 을 넣으면 풀린다.

무대 위에서 굴린 휠은 페이지로 넘어가지 않는다

상자에는 overscroll-behavior: contain 이 걸려 있어 포인터가 무대 위에 있는 동안의 휠은 상자만 굴리고, 상자가 끝에 닿아도 나머지 휠이 페이지로 넘어가지 않는다. 휠을 가로채는 코드는 없지만 무대 위의 휠은 상자 몫이라, 글을 읽다 무대 위에서 휠을 굴린 사람은 페이지가 멈춘 것처럼 느낄 수 있다. 페이지를 계속 내리려면 포인터를 무대 밖으로 옮겨야 하므로, 실제 페이지에 넣을 때는 무대 높이를 화면의 일부로 묶어 두는 편이 안전해 보인다. 진행률은 상자 높이의 다섯 배를 기준으로 재므로 320px 폭처럼 무대가 작아지면 같은 휠 한 칸이 진행률을 더 많이 움직이고, 창 크기가 바뀔 때는 placeScroll 이 진행률을 지킨 채 상자를 다시 굴려 둔다.

카메라에 바짝 붙은 점

05 의 배율은 360 ÷ (360 + 깊이) 라서 깊이가 −360 에 가까워질수록 나누는 값이 0 으로 다가가 판이 터지듯 커진다. 지금 배치에서 가장 가까운 깊이는 −119 라 배율이 1.496 에서 그친다. 판마다 평균 깊이 하나로 정렬하므로 판이 길게 겹치는 배치에서는 앞뒤가 뒤바뀔 수 있어 보이지만, 판이 서로 꿰뚫지 않는 지금 배치에서는 눈에 띄는 어긋남이 없었다.

페이지 스크롤은 가로채지 않는다 — 무대 안 스크롤 상자와 움직임 줄이기 접근성

아홉 장면 모두 window 에 다는 리스너는 resize 하나뿐이고, 스크롤·휠 리스너는 무대 안 상자에만 단다. 휠 리스너는 { passive: true } 로 시연을 멈추는 일만 하고 preventDefault 를 부르지 않으며, position: sticky 로 페이지를 붙잡아 두는 구간도 없다. 휠을 가로채 세로 굴림을 가로 진행으로 바꾸는 방식은 가로 스크롤 9종 이 다뤘고, 이 편은 그 반대 길을 골랐다.

키보드로 굴리는 진행률과 상태 줄

상자는 tabindex="0"·role="region" 에 장면과 조작법을 적은 aria-label 을 갖는다. 초점이 오면 ↓·→ 는 4% 앞으로, ↑·← 는 4% 뒤로, PageDown·PageUp 은 25% 씩, Home 은 처음, End 는 끝으로 진행률을 옮기고, preventDefault 는 이 키들에만 걸려 Space 같은 나머지 키는 상자 안에서 원래대로 동작한다. 상태 줄은 시연 중 aria-live="off" 로 조용히 있다가 첫 입력에서 polite 로 바뀌고, 「진행 30%」 처럼 5% 단위로만 숫자를 고쳐 굴릴 때마다 낭독이 쏟아지지 않게 한다. 스크립트가 초점을 억지로 옮기는 .focus() 호출은 없다.

움직임을 줄인 화면에 남는 한 장

prefers-reduced-motion: reduce 를 켜 두면 시연 시간표와 애니메이션 루프가 돌지 않고, 240 스텝을 그리지 않은 채 한 번에 계산해 자리 잡은 화면 한 장만 남긴다. 01~06·08·09 는 진행 끝(1)의 화면이라 01 은 「선을 다 그었어요」, 03 은 「이정표 5/5 지남」, 09 는 「남은 거리 0.0 km · 도착했어요」 로 시작한다. 07 만 고정 구간 끝 0.75 에 멈추는데, 1 로 두면 모양이 무대 위로 빠져나가 빈 무대만 남기 때문이다. 이 상태에서도 상자를 굴리거나 키를 누르면 그 진행률의 자리 잡은 화면을 바로 다시 그리고, 설정은 change 리스너로 페이지를 연 채 바꿔도 반영된다. 기준은 prefers-reduced-motion 설명 의 미디어 조건을 그대로 따랐다.

스크롤 애니메이션 css 만으로 되는 범위와, 길 따라가기를 페이지에 들일 때 자주 묻는 질문

곡선을 따라 선만 그으면 되는데 CSS 로는 안 되나?

선을 긋는 것까지는 CSS 로 된다. 스크롤 스토리텔링 섹션 의 경로 지도 항목은 스크롤 타임라인과 SVG 경로로 선을 긋고, 그때 길이 재기는 SVG 쪽이 맡는다. 접선을 쫓아 머리를 트는 02, 넘친 뒤 자리 잡는 03 의 스프링, 마디를 되맞추는 06 의 꼬리처럼 지난 틱의 상태를 기억해야 하는 움직임은 진행률만으로 정해지지 않아 스크립트가 필요하다.

스크롤 애니메이션 효과를 페이지 전체 스크롤에 묶으려면 어디를 바꾸나?

진행률을 읽는 곳은 readScroll 한 줄이라, 상자의 scrollTop 대신 페이지 안에서 그 구간이 지나간 비율을 넣으면 나머지 계산은 그대로 쓸 수 있을 것으로 보인다. 다만 이때는 무대 위에 겹친 스크롤 상자와 overscroll-behavior: contain 을 걷어 내야 휠이 상자에 갇히지 않는다. 페이지 깊이를 숫자나 배지로만 보여 주면 될 때는 스크롤 인디케이터 9종 쪽이 더 가볍다.

휠을 빠르게 굴리면 계산이 밀리지 않나?

한 프레임에 계산하는 스텝은 최대 6번이고, 프레임 사이가 길어도 100ms 까지만 따라잡아 탭을 오래 비웠다 돌아와도 한꺼번에 수백 스텝을 몰아 돌리지 않는다. 길이 표는 장면이 시작할 때 한 번 만들고 매 틱에는 이분 탐색만 하며, 캔버스 화소 배율은 Math.min(window.devicePixelRatio || 1, 2) 로 2 에서 막아 두었다.

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

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