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

숫자 롤링 애니메이션 9종 — 굴러서 멈추는 계수기부터 올림이 이어지는 전광판까지

숫자 롤링 애니메이션은 자리마다 0~9 가 적힌 띠를 값만큼 밀어 올려 숫자가 굴러서 멈추게 하는 움직임이다. 숫자는 왜 한 번에 바뀌기보다 굴러 멈출 때 믿음이 갈까?

숫자는 바뀌는 값이 아니라 굴러가는 띠다 — 숫자 롤링 애니메이션은 자리마다 0~9 띠를 값만큼 밀어 올리는 데서 시작한다

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

열두 칸 띠는 9에서 0으로 넘어가도 끊기지 않는다

01~05·08·09 는 자리 하나가 창 하나이고, 창 안에는 0~9 뒤에 0·1 을 다시 붙인 열두 칸 띠가 들어 있다. 띠는 translateY(-(pos mod 10)/12·100%) 로 밀리므로 9 에서 0 으로 넘어가는 경계 뒤에 0 이 이어져 있어 띠가 되감기지 않고 계속 위로 간다. 아홉 장면은 전부 tabular-nums 를 써서 숫자마다 폭이 달라 자리가 흔들리는 일이 없다.

굴릴 칸 수를 먼저 정하고 마지막 칸만 목표에 맞춘다

숫자를 값으로 보지 않고 몇 칸 굴릴지로 센다. 01 은 10 + mod(b−a, 10) 으로 한 바퀴를 더 돌고, 03 은 30 + 6i 칸에 목표 숫자에 맞는 0~9칸을 더해 30~63칸을 굴리며, 07 의 시 바퀴는 12~23칸을 돈다. 굴리는 거리가 길어야 속도가 붙고, 속도가 붙어야 번짐이나 스프링이 의미를 가진다.

이웃한 편과 다른 점

css 숫자 증가 애니메이션 편은 css 로 숫자가 올라가는 코드를 다루고, 이 편은 JS 시간표로 자리별 속도·번짐·올림을 계산한다는 점이 다르다. 값을 손으로 올리고 내리는 입력 컨트롤은 숫자 스텝퍼 UI 편에, 줄어드는 남은 시간은 css countdown timer 편에 있다. 마찰로 멈추는 08 과 같은 계산 계열은 룰렛 돌리기 편의 돌림판이다.

숫자 롤링 애니메이션 9종 — 자리별로 굴러 멈추는 계수기·목표를 지나쳤다 돌아오는 스프링 숫자·빠르면 번지는 릴·올림이 이어지는 연쇄 계수기·방향에 따라 번쩍이는 시세·반으로 접히는 플립 보드·원통 드럼 원근 숫자·마찰로 감속하는 다이얼·통계 한 줄 히어로

01~05 는 같은 세로 띠에 자리 지연·스프링·속도 번짐·올림·방향이라는 다른 규칙을 얹는다. 06 은 띠를 버리고 잎 두 장을 접으며, 07 은 원통의 각도로 숫자를 눌러 그린다. 08 은 마찰로 눈금에 붙고 09 는 앞의 자리 지연을 한 줄 히어로로 묶는다.

아홉 장면의 면적·움직임·강도 실측

장면 누적면적 움직인 프레임 강도 평균
01 자리별 숫자띠 13.2% 13/23 164.6
02 스프링 안착 15.7% 14/23 148.9
03 속도 번짐 릴 12.3% 21/23 84.7
04 올림 연쇄 11.1% 18/23 132.3
05 방향 시세 22.9% 15/23 120.9
06 잎 플립 10.6% 15/23 117.7
07 드럼 원근 13.9% 16/23 88.5
08 마찰 다이얼 6.2% 10/23 105.2
09 통계 한 줄 21.6% 16/23 143.3

가장 넓게 바뀌는 것은 시세 판 전체가 번쩍이는 05 의 22.9% 이고, 가장 좁은 것은 다이얼이 눈금에 붙으며 곧 멈추는 08 의 6.2% 이다. 움직인 프레임은 릴 다섯 개가 오래 도는 03 이 21/23 으로 가장 많고 08 이 10/23 으로 가장 적다. 강도 평균은 01 이 164.6 으로 가장 높고 03 이 84.7 로 가장 낮다.

01자리별 숫자띠가 굴러 멈추는 계수기

대시보드의 누적 방문자 수 카드에 쓰면 다섯 자리가 0.25초에 일의 자리부터 출발해 오른쪽이 먼저 멈추고, 1.67초에 왼쪽이 마저 멈춘다. placeU(k) 가 자리 k(일의 자리가 0)의 진행률을 PLACE_DELAY_MS 80ms 씩 늦춰 주고, 굴리는 칸은 10 + mod(b−a, 10) 이라 한 바퀴를 더 돈다. 아래 작은 줄은 숫자 값을 그대로 띠 위치로 쓰는 posBad 라서 작은 수로 갈 때 거꾸로 훑어 간다.

자리별 숫자띠가 굴러 멈추는 계수기
새 창에서 열기

01 · 자리별 숫자띠가 굴러 멈추는 계수기 — 측정 누적면적 13.194% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PLACE_DELAY_MSROLL_SplaceU
    function placeU(k) {
        return (sceneT - START_S - k * PLACE_DELAY_MS / 1000) / ROLL_S;
    }
    function draw() {
        const a = digitsOf(fromV, PLACES);
        const b = digitsOf(toV, PLACES);
        for (let i = 0; i < PLACES; i += 1) {
            const k = PLACES - 1 - i;
            const u = clamp(placeU(k), 0, 1);
            // 한 바퀴(10칸)를 더 돌고 목표 숫자에 멈춘다
            const adv = 10 + mod(b[i] - a[i], 10);
            const pos = a[i] + adv * easeOut3(u);
            const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
            moveStrip(strips[i], pos, speed);
            glowStrip(strips[i], speed / BLUR_REF * 0.9);
            // 되감기는 쪽 — 숫자 값을 그대로 띠 위치로 쓰니 작은 수로 가면 거꾸로 훑고 간다
            const posBad = lerp(a[i], b[i], easeOut3(u));
            moveStrip(naive[i], posBad, 0);
            glowStrip(naive[i], u > 0 && u < 1 && b[i] < a[i] ? 0.8 : 0);
        }
    }

02목표를 지나쳤다 스프링으로 돌아와 앉는 숫자

결제 완료 화면의 합계 금액에 쓰면 숫자가 목표를 조금 지나쳤다가 돌아와 앉는다. update 가 한 프레임을 ceil(dt·240) 조각으로 쪼개 acc = K(goal−x) − C·v 를 쌓으므로 프레임이 길어도 스프링이 터지지 않는다. SPRING_K 120 과 SPRING_C 12 에서 감쇠비는 0.548 이라 목표를 넘는 폭은 이동 거리의 12.8%, 첫 도달은 출발 0.23초 뒤다.

목표를 지나쳤다 스프링으로 돌아와 앉는 숫자
새 창에서 열기

02 · 목표를 지나쳤다 스프링으로 돌아와 앉는 숫자 — 측정 누적면적 15.678% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPRING_KSPRING_CPLACE_DELAY_S
    function update(dt) {
        // 240Hz 로 잘게 나눠 쌓는다 — 프레임이 길어도 스프링이 터지지 않는다
        const n = Math.max(1, Math.ceil(dt * 240));
        const h = dt / n;
        for (let i = 0; i < springs.length; i += 1) {
            const sp = springs[i];
            if (sceneT < sp.start) {
                continue;
            }
            for (let j = 0; j < n; j += 1) {
                const acc = SPRING_K * (sp.goal - sp.x) - SPRING_C * sp.v;
                sp.v += acc * h;
                sp.x += sp.v * h;
            }
        }
        const one = springs[PLACES - 1];
        if (sceneT >= one.start) {
            peakOver = Math.max(peakOver, one.x - one.goal);
        }
    }

03빠를 땐 번지고 느려지며 또렷해지는 릴

이벤트 추첨 번호 공개 화면에서는 돌아가는 동안 읽히지 않던 숫자가 멈출 때 또렷해진다. moveStrip 은 속도를 BLUR_REF 60 으로 나눈 비율 sp 로 본 띠의 불투명도를 1 − 0.5·sp 까지 낮추고, 꼬리 띠 두 장을 sp·BLUR_LAG·g 칸 뒤에 깔아 번짐을 만든다. 릴 다섯 개는 왼쪽부터 1.2초에서 STOP_STEP 0.24초씩 늦게 멈추고, 굴리는 칸 수는 reelDist 가 30 + 6i 에 0~9칸을 더해 정한다.

빠를 땐 번지고 느려지며 또렷해지는 릴
새 창에서 열기

03 · 빠를 땐 번지고 느려지며 또렷해지는 릴 — 측정 누적면적 12.345% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STOP_STEPreelDistBLUR_REF
    // pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
    function moveStrip(s, pos, speed) {
        putCol(s.cols[0], pos);
        if (s.cols.length < 2) {
            return;
        }
        const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
        const dir = speed < 0 ? -1 : 1;
        s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
        for (let g = 1; g < s.cols.length; g += 1) {
            putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
            s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
        }
    }

04올림이 윗자리로 이어지는 연쇄 계수기

실시간 접속자 수 카운터에서 999 가 1000 이 될 때 네 자리가 시간차로 이어서 올라간다. placePos(k, lag) 는 시작 값의 그 자리 몫에 값이 올라 그 자리가 바뀐 틱마다 한 칸을 더하고, 자리 k 의 출발을 k·lag 만큼 늦춘다. 위 줄은 CARRY_LAG_S 0.07초라 일·십·백·천 자리가 1.08·1.15·1.22·1.29초에 출발한다.

올림이 윗자리로 이어지는 연쇄 계수기
새 창에서 열기

04 · 올림이 윗자리로 이어지는 연쇄 계수기 — 측정 누적면적 11.077% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CARRY_LAG_SplacePoschainOf
    function tenPow(k) { return Math.pow(10, k); }
    // 자리 k 의 굴러 오른 칸 수 — 시작 값의 그 자리 몫에, 값이 올라 그 자리가 바뀐 틱마다 한 칸(올림 순서대로 지연)을 더한다
    function placePos(k, lag) {
        let pos = Math.floor(v0 / tenPow(k));
        for (let j = 1; j <= TICKS; j += 1) {
            if (Math.floor((v0 + j) / tenPow(k)) !== Math.floor((v0 + j - 1) / tenPow(k))) {
                const s = FIRST_S + (j - 1) * TICK_S + k * lag;
                pos += easeOut3((sceneT - s) / ROLL_S);
            }
        }
        return pos;
    }

05오르면 위로 내리면 아래로 굴러 색이 번쩍이는 시세

환율·재고 수량 전광판에서는 값이 오르면 띠가 위로, 내리면 아래로 굴러 방향이 눈에 먼저 들어온다. goesUp() 이 참이면 mod(b−a, 10) 칸을 앞으로, 거짓이면 −mod(a−b, 10) 칸을 뒤로 굴리고 판에 is-up(박하)·is-down(분홍) 을 번갈아 건다. 번쩍임의 불투명도는 0.9·exp(−(t−0.2)/0.35) 로 가라앉는다.

오르면 위로 내리면 아래로 굴러 색이 번쩍이는 시세
새 창에서 열기

05 · 오르면 위로 내리면 아래로 굴러 색이 번쩍이는 시세 — 측정 누적면적 22.903% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLASH_TAUgoesUpSTAGGER_S
    function goesUp() { return toV > fromV; }
    function fixed2(v) { return (v / 100).toFixed(2); }
    function placeU(k) { return (sceneT - START_S - k * STAGGER_S) / ROLL_S; }
    function draw() {
        const a = digitsOf(fromV, PLACES);
        const b = digitsOf(toV, PLACES);
        const up = goesUp();
        panel.classList.toggle(cls('is-up'), up);
        panel.classList.toggle(cls('is-down'), !up);
        for (let i = 0; i < PLACES; i += 1) {
            const k = PLACES - 1 - i;
            const u = clamp(placeU(k), 0, 1);
            // 올랐으면 앞으로(위로), 내렸으면 뒤로(아래로) 가장 짧은 방향이 아니라 값의 방향대로 굴린다
            const adv = up ? mod(b[i] - a[i], 10) : -mod(a[i] - b[i], 10);
            const pos = a[i] + adv * easeOut3(u);
            const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
            moveStrip(strips[i], pos, speed);
            glowStrip(strips[i], 0);
        }
        const since = sceneT - START_S;
        flash.style.opacity = (since >= 0 ? 0.9 * Math.exp(-since / FLASH_TAU) : 0).toFixed(3);
    }

06반으로 접혀 넘어가는 잎 플립 보드

도착 안내 시각판처럼 칸마다 숫자가 여러 장 넘어가다 목표에 선다. flipsOf(a, b) 가 5 + mod(b−a−5, 10) 으로 5~14장을 넘기고, 장마다 앞 절반에는 위 장이 rotateX(−180u°) 로 접히며 뒤 절반에는 아래 장이 90도에서 0도로 펴진다. 0618 에서 0847 로 가면 칸마다 10·12·13·9장, 합쳐 44장이 넘어가며 칸별 멈춤이 1.25·1.55·1.75·1.45초라 가장 늦은 셋째 칸이 1.75초에 선다.

반으로 접혀 넘어가는 잎 플립 보드
새 창에서 열기

06 · 반으로 접혀 넘어가는 잎 플립 보드 — 측정 누적면적 10.572% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLIP_SflipsOfCELL_DELAY_S
    function draw() {
        const a = digitsOf(fromV, 4);
        const b = digitsOf(toV, 4);
        for (let i = 0; i < 4; i += 1) {
            const total = flipsOf(a[i], b[i]);
            const e = clamp(elapsed(i), 0, total - 0.0001);
            const s = Math.floor(e);
            const u = e - s;
            const cur = mod(a[i] + s, 10);
            const nxt = mod(a[i] + s + 1, 10);
            const c = cards[i];
            setNum(c.top, nxt);
            setNum(c.bot, cur);
            setNum(c.ft, cur);
            setNum(c.fb, nxt);
            // 앞 절반 동안 위 장이 앞으로 -90도까지 접혀 내려오고, 뒤 절반 동안 아래 장이 90도에서 0도로 펴진다
            const front = u < 0.5;
            c.ft.style.opacity = front ? (1 - u * 0.5).toFixed(3) : '0';
            c.ft.style.transform = 'rotateX(' + (front ? (-180 * u).toFixed(2) : '-90') + 'deg)';
            c.fb.style.opacity = front ? '0' : '1';
            c.fb.style.transform = 'rotateX(' + (front ? '90' : (90 - 180 * (u - 0.5)).toFixed(2)) + 'deg)';
            c.shade.style.opacity = (0.45 * Math.sin(Math.PI * u)).toFixed(3);
        }
    }

07원통 드럼의 원근으로 눌려 보이는 숫자

시·분 선택 휠에서는 원통 둘레에 놓인 숫자가 각도로 눌려 가운데 하나만 크고 밝다. 칸마다 θ = (idx−pos)·STEP_DEG 30도 를 구해 translateY 에 R·sinθ, scale 에 (0.7+0.3cosθ, cosθ), 불투명도에 cos²θ 를 주고 cosθ 가 0.05 이하인 뒷면 칸은 그리지 않는다. 5시 52분에서 7시 25분으로 가면 시 바퀴는 14칸을 1.55초에, 분 바퀴는 33칸을 1.95초에 멈춘다.

원통 드럼의 원근으로 눌려 보이는 숫자
새 창에서 열기

07 · 원통 드럼의 원근으로 눌려 보이는 숫자 — 측정 누적면적 13.901% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_DEGRADIUSdistOf
            const e = w.items[j + half];
            const c = Math.cos(th);
            if (c <= 0.05) {
                e.style.opacity = '0';
                continue;
            }
            const text = String(mod(idx, w.n) + w.offset).padStart(2, '0');
            if (e.textContent !== text) {
                e.textContent = text;
            }
            // 높이는 cos θ 배로 눌리고 폭도 조금 줄며, 흐려지는 정도는 cos θ 의 제곱에 속도 번짐을 곱한다
            // 가운데에서 떨어진 거리(바퀴 높이 비율)를 칸 자신의 높이 비율(%)로 바꿔 translateY 에 준다
            const y = RADIUS * Math.sin(th) * 100 / (ITEM_H / 100);
            e.style.transform = 'translateY(' + y.toFixed(2) + '%) scale(' + (0.7 + 0.3 * c).toFixed(3) + ',' + c.toFixed(3) + ')';
            e.style.opacity = (c * c * fade).toFixed(3);

08마찰로 감속해 눈금에 붙는 관성 다이얼

수량·나이 입력용 다이얼처럼 처음 속도를 받은 뒤 미끄러지다가 가장 가까운 눈금에 붙는다. step 이 매 프레임 v *= FRICTION^(dt·60) 로 속도를 깎고 SNAP_V 3 아래로 내려가면 Math.round(x) 눈금으로 1 − e^(−14dt) 비율씩 끌려 들어간다. 속도 90칸/초로 출발하면 0.68초에 끌림이 시작되고, 시작 2 에서 18칸을 간 20, 곧 숫자 0 에 약 1.07초에 선다.

마찰로 감속해 눈금에 붙는 관성 다이얼
새 창에서 열기

08 · 마찰로 감속해 눈금에 붙는 관성 다이얼 — 측정 누적면적 6.247% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FRICTIONSNAP_VSPIKE_EXTRA
    function step(d, dt) {
        if (!d.flicked && sceneT >= START_S) {
            d.flicked = true;
            d.v = FLICKS[cycle % FLICKS.length];
        }
        if (!d.flicked) {
            return;
        }
        if (!d.snapping) {
            d.x += d.v * dt;
            d.v *= Math.pow(FRICTION, dt * 60);
            if (Math.abs(d.v) < SNAP_V) {
                d.snapping = true;
                d.snapTo = Math.round(d.x);
                d.v = 0;
            }
        }
        else {
            d.x += (d.snapTo - d.x) * (1 - Math.exp(-SNAP_RATE * dt));
            if (Math.abs(d.snapTo - d.x) < 0.002) {
                d.x = d.snapTo;
            }
        }
    }

09통계 한 줄이 차례로 굴러 오르는 히어로

서비스 소개 랜딩의 첫 화면에서는 방문자·후기·평점이 차례로 들어와 자리마다 굴러 오른다. startOf(i) 가 카드를 0.2·0.5·0.8초에 들이고 statU(i, k) 가 낮은 자리부터 PLACE_DELAY_S 0.05초씩 늦춰 굴리며, 각 자리는 0 에서 10 + 목표 숫자 칸을 굴러 오른다. 평점은 정수 48 을 10 으로 나눠 4.8 로 보여 주고 2.03초에 전부 끝난다.

통계 한 줄이 차례로 굴러 오르는 히어로
새 창에서 열기

09 · 통계 한 줄이 차례로 굴러 오르는 히어로 — 측정 누적면적 21.611% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ENTER_GAP_SPLACE_DELAY_SstatU
    function startOf(i) { return START_S + i * ENTER_GAP_S; }
    function statU(i, k) { return (sceneT - startOf(i) - FADE_S * 0.5 - k * PLACE_DELAY_S) / ROLL_S; }
    function draw() {
        for (let i = 0; i < 3; i += 1) {
            const st = stats[i];
            // 들어올 때 낮은 곳에서 떠오른다 — 처음에도 흐릿하게 보이게 바닥 밝기를 둔다
            const e = easeOut3((sceneT - startOf(i)) / FADE_S);
            st.card.style.opacity = (0.35 + 0.65 * e).toFixed(3);
            st.card.style.transform = 'translateY(' + ((1 - e) * 14).toFixed(2) + '%)';
            const d = digitsOf(target[i], st.digits);
            for (let j = 0; j < st.digits; j += 1) {
                const k = st.digits - 1 - j;
                const u = clamp(statU(i, k), 0, 1);
                const adv = 10 + d[j];
                const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
                moveStrip(st.strips[j], adv * easeOut3(u), speed);
                glowStrip(st.strips[j], speed / BLUR_REF * 0.8);
            }
        }
    }

숫자 롤링 애니메이션은 몇 자리까지, 얼마나 느리게 굴려야 읽힐까?

멈추는 시각은 2.16초 안쪽에 맞췄다

자리 지연이 있어도 모든 자리가 멈추는 시각은 01 이 1.67초, 06 이 1.75초, 07 이 1.95초, 09 가 2.03초, 03 이 2.16초다. 한 자리가 굴러 멈추는 데는 01·09 가 1.0~1.1초, 05 가 0.7초를 쓰고 자리 사이 출발 간격은 80ms·50ms·40ms 로 짧게 둔다. 자리를 다섯 개 넘게 늘릴 때는 간격을 줄여야 왼쪽 자리가 너무 늦게 멈추지 않는다.

값이 한 번 나오고 끝나는 화면

대시보드 누적 카드나 결제 완료 합계처럼 값 하나가 나오고 끝나는 화면은 01·02 가 맞다. 랜딩 첫 화면에서 숫자 올라가는 애니메이션이 필요하면 09 처럼 들어오는 순서를 카드마다 0.3초씩 벌려 한 줄을 한 장면으로 만든다.

값이 계속 바뀌는 화면

접속자 수 카운터(04)와 환율·재고 전광판(05), 도착 시각판(06)은 같은 자리가 계속 바뀐다. 이런 숫자 카운트 애니메이션은 이전 값에서 새 값으로 가는 방향과 올림 순서가 읽혀야 하므로 04 의 chainOf(n) 처럼 같이 넘어가는 자리 수를 상태 줄에 적어 두면 확인하기 쉽다.

사람이 값을 고르는 화면

시·분 선택 휠(07)과 수량·나이 다이얼(08)은 보여 주는 것이 아니라 고르는 컨트롤로 붙는다. 두 장면 모두 눈금이나 홈을 기준으로 끝 위치가 정해지므로 멈춘 자리에서 값을 읽어 폼에 넣으면 된다.

띠를 값만큼 밀고, 자리마다 출발을 늦추고, 스프링으로 멈춘다 — 숫자 하나가 굴러 앉기까지의 순서

위치의 미분이 곧 번짐이다

01·03·05·09 는 위치를 a + adv·easeOut3(u) 로 먼저 계산하고, 속도를 그 미분인 adv·3(1−u)²/ROLL_S 로 구해 같은 식에서 번짐과 불빛을 뽑는다. 처음이 가장 빠르고 멈출 때 0 이라 별도 감속 곡선이 필요 없다. 띠를 미는 일은 translateY 하나가 맡고 시계는 requestAnimationFrame 의 시각으로 센다.

자리 지연은 낮은 자리부터 쌓는다

일의 자리를 k = 0 으로 두고 출발을 k·PLACE_DELAY 만큼 늦추면 오른쪽 자리가 먼저 멈춘다. 02 는 이 지연을 0.06초, 05 는 0.04초로 두어 자리 사이 간격이 장면 성격에 맞게 다르다.

스프링은 프레임이 아니라 240Hz 조각으로 쌓는다

한 프레임 시간이 길어지면 스프링 계산이 터지므로 02 는 프레임을 240Hz 조각으로 쪼개 쌓고, 시계 자체도 한 프레임 시간을 DT_MAX 0.05초로 잘라 넣는다. 위치와 속도를 프레임마다 쌓는 항목은 02 스프링과 08 마찰이고 나머지는 시계만으로 그린다.

숫자 롤링이 깨지는 순간 — 올림 없이 한 자리만 바뀌어 999가 000으로 보이는 숫자·띠 끝에서 튀어 처음으로 되감기는 숫자·숨은 탭 뒤 한꺼번에 몰려 굴러가는 숫자

올림이 늦게 도착하면 값이 틀려 보인다

04 의 아래 줄은 LATE_S 0.3초 간격이라 999 에서 1000 으로 갈 때 일·십·백·천 자리가 1.08·1.38·1.68·1.98초에 출발한다. 낮은 자리가 0 으로 돌아가 있는 동안 윗자리는 아직 9 에 머물러 한동안 값이 틀리게 보이고, 위 줄은 0.07초 간격이라 이 틈이 1.29초에 끝난다.

띠 끝에서 거꾸로 되감기는 숫자

01 의 아래 작은 줄은 lerp(a, b, easeOut3(u)) 로 숫자 값을 띠 위치에 그대로 쓴다. 9 에서 2 로 가면 띠가 2 가 있는 곳까지 거꾸로 훑어 가므로, 위 줄처럼 한 바퀴를 더 돌고 mod 로 나머지를 맞추는 쪽이 같은 방향으로 이어진다.

숨은 탭 뒤 한꺼번에 몰려 굴러가는 숫자

08 의 아래 작은 숫자는 0.5초에 긴 한 프레임을 dt + SPIKE_EXTRA 0.4초로 그대로 받는다. 그 프레임에서 v 가 한 번에 곱해지고 위치가 크게 뛰어 위쪽 다이얼과 다른 숫자에 선다.

    function update(dt) {
        step(good, dt);
        // 잘못된 쪽은 시간 상한 없이 한 번 크게 받는다
        let dtBad = dt;
        if (!bad.spiked && sceneT >= SPIKE_S) {
            bad.spiked = true;
            dtBad = dt + SPIKE_EXTRA;
        }
        step(bad, dtBad);
    }

실제 탭이 숨으면 아홉 장면의 루프는 멈추고, 돌아오면 lastTs 를 −1 로 돌려 숨어 있던 시간이 첫 프레임에 실리지 않게 하며 그래도 긴 프레임이 오면 DT_MAX 가 한 번 더 자른다. 압축 파일은 암호 qyyem5sk 로 열고 01 폴더·04 폴더·08 폴더의 index.html 을 일시정지 버튼으로 세워 두면 아래 작은 줄이 틀리는 지점을 눈으로 볼 수 있다.

스프링을 세게 하면 숫자는 얼마나 흔들릴까 — 스프링 세기·감쇠·자리 지연을 바꿔 본 차이

이름 바꾼 값 느낌
스프링을 세게 02 SPRING_K 120 → 260 감쇠비가 0.55 에서 0.37 로 내려가 목표를 넘는 폭이 거리의 12.8%에서 28.4%로 늘고(8칸이면 1.0칸에서 2.2칸), 떠는 진동수가 1.46Hz 에서 2.38Hz 로 빨라져 첫 도달이 0.23초에서 0.13초로 당겨진다
자리 지연을 길게 01 PLACE_DELAY_MS 80 → 240 가장 왼쪽 자리의 출발이 오른쪽보다 320ms 에서 960ms 늦어지고 모든 자리가 멈추는 시각이 1.67초에서 2.31초로 0.64초 길어진다
마찰을 낮게 08 FRICTION 0.92 → 0.97 한 프레임에 남는 속도가 0.92 에서 0.97 로 커져 1초 뒤 남는 속도가 0.7%에서 16.1%로 늘고, 끌림 시작이 0.68초에서 1.86초로, 미끄러지는 거리가 18칸에서 48칸으로 늘어난다

값을 바꾸기 전에 볼 것

02 에서 SPRING_C 가 12 로 그대로라서 SPRING_K 만 키우면 가라앉는 시간은 0.7~0.9초로 비슷하고 목표를 지나치는 폭만 커진다. 01 은 자리 지연을 240ms 로 늘려도 2.31초에 멈추므로 LOOP_S 3.4초 안에 들어오지만, 자리를 늘리면 그 여유가 줄어든다. 08 은 마찰을 0.97 로 올리면 끌림 시작이 1.86초이므로 LOOP_S 3.0초 안에서 눈금에 붙는 시간이 짧아진다.

움직임 줄이기에서는 최종 숫자 한 장 — 값을 읽어 주는 문장과 일시정지의 접근성

움직임 줄이기와 장면 설명

prefers-reduced-motion: reduce 이면 미디어 쿼리 를 읽은 reduceQuery 가 루프를 세우고 showRest() 가 REST_T 초 뒤 장면, 곧 최종 숫자 한 장만 그린다. 아홉 장면의 무대는 role="img" 와 aria-label 로 무엇이 일어나는지 말하고, 굴러가는 중간 숫자를 읽는 영역으로 보내지 않도록 상태 알약(.vj__status)은 aria-hidden 이다. 굴림이 끝나 isSettled() 가 처음 참이 될 때만 숨은 알림 영역에 finalText() 한 문장이 적히고, 자동 시연 중에는 aria-live="off", 사람이 처음 손대면 polite 로 바뀐다.

키보드와 일시정지

무대 버튼은 클릭·Enter·Space·오른쪽·위 화살표에 nextNow() 로 다음 값을 굴리고, 움직임 줄이기 상태에서는 같은 입력이 곧바로 다음 최종 숫자를 그린다. 일시정지 버튼은 aria-pressed 로 상태를 알리고 탭이 숨으면 visibilitychange 가 루프를 멈춘다.

숫자 롤링 애니메이션 FAQ — css 만으로 굴리기·천 단위 쉼표·값이 자주 바뀔 때의 부하

숫자 카운트 css 만으로도 같은 장면을 만들 수 있나요

값이 하나로 정해져 있고 띠를 translateY 로 한 번 밀기만 하면 css 의 transition 으로 충분하다. 이 편의 스프링 안착·속도 번짐·올림 연쇄는 프레임마다 위치와 속도를 읽어 다음 값을 정해야 해서 JS 로 계산한다. 소스에 스크롤 감지(IntersectionObserver)는 없고 전부 시간표로만 움직인다.

천 단위 쉼표는 어떻게 넣나요

fmtNum 이 문자열 길이에서 세 자리마다 쉼표를 직접 끼워 넣고, 09 는 dd,ddd·d,ddd·d.d 같은 칸 패턴으로 쉼표 칸을 따로 둔다. 나라마다 표기를 바꿔야 하면 Intl.NumberFormat 으로 문자열을 만든 뒤 자리 수에 맞춰 띠에 나누면 된다.

값이 자주 바뀔 때 느려지지 않나요

인라인으로 바꾸는 속성은 transform 과 opacity 가 중심이고, 07 의 홈 선 top 과 08 의 눈금 left 만 좌표 배치다. 값이 한 장면의 굴림 시간(05 는 0.7초)보다 잦게 들어오면 굴림을 이어 붙이기보다 목표만 최신 값으로 바꾸는 편이 단순하다.

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

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