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

플립북 애니메이션 9종 — 쪽 넘기는 속도부터 양파 껍질과 중간 쪽까지

플립북은 그림 몇 장으로 어떻게 움직임을 만들까 — 02번은 앞뒤 쪽을 옅게 겹쳐 점의 길을 보이고, 05번은 넘기는 속도가 어디서 한 줄로 합쳐지는지 재며, 09번은 두 장만 그려도 중간 쪽을 계산으로 채운다.

플립북은 종이 한 장마다 그림을 그리는 일이 아니라, 쪽 번호를 시간에 맞춰 올리고 앞뒤 쪽과의 위치 차이를 눈에 맡기는 계산이다 — 플립북 만들기는 쪽수와 넘기는 속도를 먼저 정하는 데서 시작한다

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

아홉 장면이 같이 쓰는 설계는 하나다. 쪽 그림은 PAGES 배열에 든 열두 장이고, 한 쪽에는 공 하나의 가로·세로·눌림 값만 있으며 공은 열두 쪽에 걸쳐 두 번 튄다.

    function makePages(n) {
        const out = [];
        for (let i = 0; i < n; i += 1) {
            // 두 번 튀므로 한 번 튀는 사이는 반 바퀴 — b 는 한 번 튀는 안의 위치 0~1, h 는 높이(포물선)
            const b = (i * 2 / n) % 1;
            const h = 4 * b * (1 - b);
            const sy = 0.8 + 0.2 * Math.min(1, h * 3);
            out.push({ x: 0.14 + 0.72 * (i / (n - 1)), y: FLOOR_Y - h * HOP_H, sx: 2 - sy, sy: sy });
        }
        return out;
    }
    const PAGES = makePages(FRAME_COUNT);

쪽 번호는 소수 위상에서 정수 부분만 떼어 쓴다

wrapIdx 는 위상에서 정수 부분을 떼어 쪽 번호로 돌려주고, 01·02·04·07 이 이 함수를 쓴다. 시계는 장면 시계 하나이고 아홉 소스 어디에서도 Date 와 Math.random 을 부르지 않으며 외부 이미지도 쓰지 않는다.

쪽 수와 넘기는 속도를 먼저 정한다

플립북이란 쪽을 넘기면 그림이 움직여 보이게 만든 책이고, 코드에서는 쪽 번호가 시간을 따라 오르며 배열을 읽는 일이다. 쪽 수는 FRAME_COUNT 12 이고 속도는 01 의 FLIP_HZ 12 처럼 초당 쪽 수로 적는다.

플립북 애니메이션 9종 — 엄지로 쪽 넘기는 속도·양파 껍질 겹침·책등에 묶여 휘는 부채꼴·쪽마다 다른 간격 타이밍·깜박임이 합쳐지는 속도·모서리 끌어 넘기기·두께와 옆면 줄무늬·되감기와 왕복 번호·중간 쪽 자동 채우기

직접 만들어 볼 순서는 속도에서 시작해 겹침·휨·간격을 지나 눈의 잔상과 손으로 끌기, 책의 두께, 번호 계산, 중간 쪽 채우기로 간다. 01~05 는 시간표가 쪽을 넘기고 06 은 손으로 끌며 07~09 는 쪽 수와 번호와 그림 자체를 만진다.

마지막 전체 렌더로 잰 아홉 장면의 면적·프레임·강도

표의 값은 측정.json 에서 옮겼다. 누적면적이 가장 큰 것은 06 의 32.8% 와 03 의 32.6% 이고 가장 작은 것은 05 의 6.8% 이며, 움직임이 23/23 인 장면은 01 과 05 뿐이다.

장면 누적면적 움직인 프레임 강도 평균
01 넘기는 속도 15.6% 23/23 86.4
02 양파 껍질 7.3% 7/23 69.1
03 책등 부채 32.6% 12/23 52.7
04 간격 타이밍 표 25.8% 17/23 80.2
05 잔상 융합 6.8% 23/23 68.5
06 모서리 끌기 32.8% 18/23 58.6
07 옆면 줄무늬 10.6% 20/23 84.5
08 되풀이·왕복 번호 17.4% 16/23 99.5
09 중간 쪽 채우기 12.2% 9/23 85.9

01엄지로 쪽을 넘기는 속도

phase 에 속도 rate 곱하기 시간을 쌓고 그 정수 부분이 pageIndex 이며, rate 는 목표 속도를 지수로 따라가 갑자기 서지 않는다. 쪽 너비 170px 기준으로 12쪽 한 바퀴는 FLIP_HZ 12 에서 1.0초, 3 에서 4.0초이고 쪽 사이 공은 평균 33.5px(최대 55.9px)를 옮겨 간다. 누적면적 15.6% 에 23/23 프레임에서 움직임이 잡혔다.

엄지로 쪽을 넘기는 속도
새 창에서 열기

01 · 엄지로 쪽을 넘기는 속도 — 측정 누적면적 15.619% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLIP_HZpageIndexframeCount
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        rate += (rateTgt - rate) * (1 - Math.exp(-6 * dt));
        if (!manual) {
            phase += rate * dt;
        }
        pageIndex = wrapIdx(phase, frameCount);
    }

02앞뒤 쪽이 비쳐 보이는 양파 껍질

지금 쪽 위에 앞 쪽의 공은 푸른 기, 뒤 쪽의 공은 붉은 기로 겹치고 거리 한 칸마다 알파가 반으로 줄어 0.25·0.125·0.0625 가 된다. 쪽 번호가 범위 밖이면 그리지 않아 한 바퀴 이음새에서는 처음 쪽이 끝 쪽 뒤에 비치지 않고, 오른쪽 판은 겹침 없이 같은 쪽을 보여 준다. 누적면적 7.3% 에 7/23 프레임에서 움직임이 잡혔다.

앞뒤 쪽이 비쳐 보이는 양파 껍질
새 창에서 열기

02 · 앞뒤 쪽이 비쳐 보이는 양파 껍질 — 측정 누적면적 7.299% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GHOST_ALPHAghostSpantint
    // 겹침 — 거리 d(1 부터)의 쪽은 알파 GHOST_ALPHA × 0.5^(d-1). 한 바퀴 끝에서 처음으로 돌아가는 이음새는 겹치지 않는다
    function ghosts(R, amount) {
        for (let d = ghostSpan; d >= 1; d -= 1) {
            const a = GHOST_ALPHA * Math.pow(0.5, d - 1) * amount;
            for (let s = 0; s < 2; s += 1) {
                const j = s === 0 ? pageIndex - d : pageIndex + d;
                if (j < 0 || j >= FRAME_COUNT) {
                    continue;
                }
                pageBall(R, PAGES[j], tint[s], a, tint[s], false);
            }
        }
    }

03책등에 묶인 쪽이 휘어 넘어가는 부채꼴

edgeProject 는 쪽 단면을 SEG 8조각으로 이어 조각의 방향이 끝으로 갈수록 bendRad × sin(a) 만큼 뒤처지게 하고, 가까운 쪽은 1/(1 - z × PERSP) 로 위아래를 키운다. 왼쪽 판은 휨 0 이라 판판하게 서고 오른쪽 판은 BEND_K × BEND_SPAN 만큼 가장자리가 따라와 부채처럼 휜다. 누적면적 32.6% 로 둘째로 크고 12/23 프레임에서 움직임이 잡혔다.

책등에 묶인 쪽이 휘어 넘어가는 부채꼴
새 창에서 열기

03 · 책등에 묶인 쪽이 휘어 넘어가는 부채꼴 — 측정 누적면적 32.601% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BEND_KspineXedgeProject
    function edgeProject(spineX, a, len, bendRad) {
        const out = [{ x: spineX, z: 0, s: 1 }];
        let x = spineX;
        let z = 0;
        const dl = len / SEG;
        for (let k = 1; k <= SEG; k += 1) {
            const ph = a - bendRad * Math.sin(a) * (k / SEG);
            x += dl * Math.cos(ph);
            z += dl * Math.sin(ph);
            out.push({ x: x, z: z, s: 1 / (1 - z * PERSP) });
        }
        return out;
    }

04쪽마다 간격을 달리하는 타이밍 표

frameY(i, p) 는 (i/11) 의 p 제곱이고 spacing 은 이웃 쪽과의 높이 차다. 지수 2.2 에서 첫 간격 0.0051 과 마지막 간격 0.1892 는 37배 벌어지고, 같은 간격 판(지수 1)은 0.0909 로 일정하며 지수 0.5 는 0.3015 에서 0.0465 로 뒤집힌다. 누적면적 25.8% 에 17/23 프레임에서 움직임이 잡혔다.

쪽마다 간격을 달리하는 타이밍 표
새 창에서 열기

04 · 쪽마다 간격을 달리하는 타이밍 표 — 측정 누적면적 25.848% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

EASE_POWspacingframeY
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        powNow += (powTgt - powNow) * (1 - Math.exp(-5 * dt));
        if (!manual) {
            phase += TRACK_HZ * dt;
        }
        pageIndex = wrapIdx(phase, FRAME_COUNT);
    }
    function frameY(i, p) { return Math.pow(i / (FRAME_COUNT - 1), p); }
    function spacing(i, p) { return i <= 0 ? 0 : frameY(i, p) - frameY(i - 1, p); }

05깜박임이 하나의 움직임으로 합쳐지는 속도

공은 초당 190px 로 일정하게 가고 쪽이 넘어가는 순간의 자리만 hist 에 쌓으며, 한 쪽 건너 움직인 거리 stepPx 는 BALL_V / h 라서 초당 2쪽에서 95px, 24쪽에서 7.9px 이다. 간격이 공 지름 18px 과 같아지는 fuseLine 은 초당 10.56쪽(3.61초 지점)이고 이 속도를 넘으면 눈에 남는 잔상 판의 점이 한 줄로 이어진다. 누적면적 6.8% 로 가장 작지만 23/23 프레임에서 움직임이 잡혔다.

깜박임이 하나의 움직임으로 합쳐지는 속도
새 창에서 열기

05 · 깜박임이 하나의 움직임으로 합쳐지는 속도 — 측정 누적면적 6.82% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HZ_MAXstepPxfuseLine
    function ballX(t, R) { return R.x + 18 + (BALL_V * t) % (R.w - 36); }
    function ballY(x, R) { return R.y + R.h / 2 + 30 * Math.sin((x - R.x) * 0.035); }
    // 한 쪽 건너 공이 움직인 거리(화면 픽셀)
    function stepPx(h) { return BALL_V / h; }
    function hzAt(t) { return lerp(HZ_MIN, HZ_MAX, ease((t - 0.8) / 6.6)); }
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        hz = manHz > 0 ? manHz : hzAt(sceneT);
        phase += hz * dt;
        while (flips < Math.floor(phase)) {
            flips += 1;
            hist.push({ t: sceneT - (phase - flips) / hz, x: 0 });
        }
        while (hist.length > 0 && hist[0].t < sceneT - 0.6) {
            hist.shift();
        }
    }

06모서리를 끌다 놓으면 한 쪽씩 안착

놓을 때 각도가 SNAP_ANGLE(90도)을 넘었으면 목표가 180도이고 아니면 0도이며, 스프링은 가속도 -K(ang - tgtAng) - C × vel 을 1/240초 조각으로 적분한다. K 140·C 16 은 감쇠비 0.676 이라 2.7라디안에서 놓으면 90% 도달 0.217초, 목표를 5.1% 넘쳤다가 0.5도 안에 서는 데 0.496초가 걸린다. 누적면적 32.8% 로 가장 크고 18/23 프레임에서 움직임이 잡혔으며, 직접 끌 때는 pointerdown 에서 setPointerCapture 를 걸어 포인터가 캔버스를 벗어나도 pointermove 를 받는다.

모서리를 끌다 놓으면 한 쪽씩 안착
새 창에서 열기

06 · 모서리를 끌다 놓으면 한 쪽씩 안착 — 측정 누적면적 32.812% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SNAP_ANGLEKC
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        if (held) {
            ang += (dragTo - ang) * (1 - Math.exp(-7 * dt));
            vel = 0;
            return;
        }
        if (still) {
            ang = tgtAng;
            vel = 0;
            return;
        }
        let left = dt;
        while (left > 1e-9) {
            const h = Math.min(SUB, left);
            vel += (-K * (ang - tgtAng) - C * vel) * h;
            ang += vel * h;
            left -= h;
        }
    }

07쪽수가 두께와 옆면 줄무늬로 보이는 책 옆

쪽수 pageCount 를 두께로 바꿔 쪽마다 STRIPE_W 3.2px 줄무늬 한 줄을 세우고, 지금 쪽 currentBand 는 솟아오르며 왼쪽은 읽은 쪽, 오른쪽은 남은 쪽이다. 쪽수 12·24·36·60 에서 띠 너비는 38.4·76.8·115.2·192px 로 쪽수에 비례해 늘고, shownN 이 목표 쪽수를 지수로 따라가 숫자가 갑자기 바뀌지 않는다. 누적면적 10.6% 에 20/23 프레임에서 움직임이 잡혔다.

쪽수가 두께와 옆면 줄무늬로 보이는 책 옆
새 창에서 열기

07 · 쪽수가 두께와 옆면 줄무늬로 보이는 책 옆 — 측정 누적면적 10.568% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pageCountedgeStripecurrentBand
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        shownN += (pageCount - shownN) * (1 - Math.exp(-5 * dt));
        if (!manual) {
            phase += PAGE_HZ * dt;
        }
        pageNow = wrapIdx(phase, Math.max(1, Math.round(shownN)));
    }
    // 쪽 i 의 옆면 줄무늬 왼쪽 끝 — 쪽마다 STRIPE_W 씩 옆으로 선다
    function edgeStripe(i) { return i * STRIPE_W; }

08되감기와 왕복에서 쪽 번호 고르기

쪽 번호는 되풀이하는 걸음 수 n 에서 나오고, loopMode 가 loop 이면 12 로 나눈 나머지이며 pingpong 이면 triWave 로 접는다. 왼쪽 판은 주기 24 식이라 끝 쪽(lastPage, 11번)과 첫 쪽이 한 주기에 같은 번호로 이어지는 곳이 2곳이고, 오른쪽 판은 주기 22 식이라 0곳이다. 누적면적 17.4% 에 16/23 프레임이고 강도 평균 99.5 는 아홉 장면 가운데 가장 크다.

되감기와 왕복에서 쪽 번호 고르기
새 창에서 열기

08 · 되감기와 왕복에서 쪽 번호 고르기 — 측정 누적면적 17.37% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

loopModetriWavelastPage
    // 삼각파 — dup 이면 주기 2N(끝 쪽이 연달아 두 번), 아니면 주기 2N - 2
    function triWave(n, count, dup) {
        const per = dup ? 2 * count : 2 * count - 2;
        const m = ((n % per) + per) % per;
        if (m < count) {
            return m;
        }
        return dup ? 2 * count - 1 - m : 2 * count - 2 - m;
    }
    function indexOf(n, dup) {
        if (loopMode === 'loop') {
            return ((n % FRAME_COUNT) + FRAME_COUNT) % FRAME_COUNT;
        }
        return triWave(n, FRAME_COUNT, dup);
    }

09두 장 사이의 중간 쪽을 자동으로 채우기

처음과 끝 열쇠 쪽에 점 여섯 개 도형만 두고 사이 쪽은 같은 pointId 끼리 lerpPoint 로 만든다. MID_COUNT 0 이면 한 쪽에 점이 최대 97px 뛰고 4 면 쪽마다 19px 씩 옮겨 가며, 번호를 어긋나게 이으면 쪽 194×168px 기준 중간 쪽 넓이가 6896 에서 5344·3869·2470·1148 로 쪼그라들다 마지막 쪽에서 6857 로 튄다. 누적면적 12.2% 에 9/23 프레임에서 움직임이 잡혔다.

두 장 사이의 중간 쪽을 자동으로 채우기
새 창에서 열기

09 · 두 장 사이의 중간 쪽을 자동으로 채우기 — 측정 누적면적 12.199% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MID_COUNTlerpPointpointId
    function lerpPoint(a, b, t) {
        return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) };
    }
    // 쪽 번호 k(0 = A, MID_COUNT + 1 = B)의 그림 — match 가 거짓이면 중간 쪽에서 A 의 점 i 를 B 의 점 PAIR[i] 와 짝짓는다
    function figAt(k, match) {
        // 끝 쪽(B)은 열쇠 쪽 그대로 — 어긋난 번호는 중간 쪽에서만 꼬인다
        if (k === MID_COUNT + 1) {
            return KEY_B.map(function (p) { return { x: p.x, y: p.y }; });
        }
        const t = k / (MID_COUNT + 1);
        const out = [];
        for (let pointId = 0; pointId < KEY_A.length; pointId += 1) {
            out.push(lerpPoint(KEY_A[pointId], KEY_B[match ? pointId : PAIR[pointId]], t));
        }
        return out;
    }

코드로 만들면 열두 쪽은 어떤 값 몇 개로 하나의 움직임이 될까?

값 네 개가 열두 쪽을 하나의 움직임으로 묶는다

움직임을 정하는 것은 쪽 수, 넘기는 속도, 겹침 알파, 책등 휨이다. 쪽 수 12 에 속도 12 를 넣으면 한 바퀴가 1.0초이고 속도를 3 으로 내리면 4.0초가 되어, 같은 열두 쪽이 속도 하나만으로 다른 움직임이 된다.

쪽 하나는 위치 네 칸짜리 값이다

makePages 가 만드는 쪽은 공의 가로·세로와 눌림 두 값이 든 객체이고, 그림을 바꿔도 쪽 번호 계산은 건드리지 않는다. 이 분리가 있어서 02 의 겹침과 03 의 휨과 09 의 중간 쪽이 같은 배열 위에서 돈다.

쪽마다 점의 위치를 표로 두고, 경과 시간으로 쪽 번호를 정하고, 앞뒤 쪽을 옅게 겹쳐 그리는 — 플립북 한 장면이 그려지는 순서

경과 시간에서 쪽 번호를 구한다

01 은 시간을 쌓아 위상을 만들고 정수 부분을 쪽 번호로 삼는다. 05 는 여기에 쪽이 넘어간 순간의 시각을 따로 적어 잔상으로 쓴다.

앞뒤 쪽은 번호 차이로 찾는다

02 는 pageIndex - d 와 pageIndex + d 를 거리 d 마다 꺼내 알파를 반씩 줄여 겹친다. 같은 장면에서 겹침이 먼저 그려지고 지금 쪽 공이 그 위에 불투명하게 얹힌다.

휨과 간격은 쪽 번호가 정해진 뒤에 얹는다

03 의 휨과 04 의 간격은 번호를 건드리지 않고 그리는 모양과 높이만 바꾼다. 그래서 같은 쪽 번호에서 휨 0 과 휨 0.3 을 나란히 놓고 비교할 수 있다.

실제 화면에 붙일 때 — 라이브러리 없이 만들기·쪽 그림을 배열로 적는 법·장면이 바뀌는 시점

어디에 붙이나

손그림 애니메이션 연습에서는 02 의 겹침이 앞뒤 쪽 위치를 눈으로 맞추는 보조가 되고, 04 의 간격표가 쪽마다 얼마나 움직일지 정하는 기준표가 된다. 스토리보드에서는 쪽 수와 속도로 장면 길이를 먼저 가늠하고 05 의 융합선으로 초당 몇 쪽이면 이어져 보이는지 감을 잡는다. 플립북 사이트의 쪽 넘기기 UI 를 만든다면 03 의 휨과 06 의 모서리 끌기가 그대로 부품이 된다.

실제 쪽 그림으로 바꿀 때

실제 플립북은 시작 순간의 performance.now() 를 저장해 두고 매 프레임 경과 시간 곱하기 초당 쪽 수의 정수 부분으로 쪽을 다시 계산해야 한다. 데모는 프레임 시간 차이를 쌓아 위상을 만들기 때문에, 느린 기기에서 쪽을 하나씩 올리는 식으로 옮기면 속도가 달라진다. 쪽 그림은 PAGES[i] 자리를 이미지 배열로 바꾸면 계산은 그대로 쓰인다.

페이지 컬 편과는 무엇이 다른가

종이 넘기는 애니메이션 9가지 편은 종이 모서리가 말리는 한 장의 모양을 다루고, 이 편은 여러 쪽의 번호와 간격과 겹침을 다룬다.

라이브러리 없이 그리기

캔버스 2D 와 requestAnimationFrame 만으로 아홉 장면이 돌고 WebGL 이나 CDN 은 부르지 않는다. 필요한 장면 하나만 잘라 붙여도 쪽 배열과 쪽 번호를 정하는 몇 줄(01·02·04·07 은 wrapIdx)만 있으면 동작한다.

플립북이 깨지는 함정 — 소수 쪽 번호로 쪽 사이가 번지는 일·끝 쪽이 두 번 보여 멈칫하는 이음새·프레임 간격에 기대어 넘기는 속도가 달라지는 일

쪽 번호를 어떻게 정수로 만들고 어디서 끊는지가 이 편의 함정 셋이다.

소수 쪽 번호로 그리면 쪽 사이가 번진다

위상이 3.5 일 때 3쪽과 4쪽을 반씩 섞어 그리면 두 공이 옅게 겹쳐 02 의 양파 껍질과 구분되지 않는다. 이 편은 쪽 번호를 정수로만 쓰고(wrapIdx 등) 겹침은 따로 알파를 거리마다 반으로 줄여 그린다.

끝 쪽이 두 번 보이는 이음새

08 의 주기 24 식은 한 주기에 11 에서 11 로, 0 에서 0 으로 같은 번호가 이어지는 곳이 2곳이라 끝 쪽이 두 박자 머물며 멈칫한다. 주기 22 식은 이런 곳이 0곳이고 되풀이 재생에서는 이 차이가 이음새의 정체다.

프레임 간격에 기대면 속도가 달라진다

프레임이 늦게 오면 쪽 하나씩 올리는 코드는 그만큼 느려진다. 데모는 쌓는 시간 차이를 DT_MAX 0.05초로 자르고 탭이 숨었다 돌아온 첫 프레임 간격을 버려서 한 프레임에 몇 쪽이 몰리지 않게 한다.

함정 셋을 직접 눌러 본 뒤에 소스를 만져 보고 싶다면 아래 카드에서 zip 을 받으면 되고, 암호는 zyrzaf6v 이며 열면 아홉 장면의 소스가 나온다.

넘기는 속도와 겹침 투명도와 책등 휨을 바꾸면 느낌은 얼마나 달라질까 — FLIP_HZ·GHOST_ALPHA·BEND_K 를 바꿔 본 차이

이름 바꾼 값 느낌
넘기는 속도를 느리게 01 FLIP_HZ 12 → 3 한 바퀴가 1초에서 4초로 늘어 쪽이 하나씩 따로 읽히고, 쪽 사이 평균 33.5px(최대 55.9px)를 공이 뛰어 건너는 것처럼 보인다
겹침을 짙게 02 GHOST_ALPHA 0.25 → 0.6 앞뒤 한 칸·두 칸·세 칸 알파가 0.6·0.3·0.15 로 커져 또렷하지만 가장 가까운 겹침이 지금 쪽 공과 거의 같은 짙기여서 윤곽선으로만 구분된다
책등을 더 휘게 03 BEND_K 0.3 → 0.9 90도에서 끝이 책등 오른쪽으로 34.9px 에서 74.5px 나가고 135도에서는 책등 위(0.2px)까지 되접혀 쪽 모양이 뭉개진다

값을 바꾸기 전에 볼 것

FLIP_HZ 를 내려도 쪽 사이 이동은 쪽 번호가 정하므로 평균 33.5px 은 그대로이고 바뀌는 것은 한 바퀴 시간이다. BEND_K 는 90도에서 위아래 배율을 1.246 에서 1.088 로 줄이므로 휨이 커질수록 쪽이 덜 커 보이는 쪽으로 간다.

GHOST_ALPHA 를 내리면 지금 쪽은 깨끗해지지만 겹침이 흐려져 앞뒤 위치를 맞추는 도움이 약해진다.

움직임 줄이기에서는 한 쪽만 보이는 플립북 — 장면 이름과 지금 보이는 쪽 번호를 문장으로 읽어 주는 접근성

움직임 줄이기와 탭 숨김

아홉 소스 전부 prefers-reduced-motion 을 읽어 켜져 있으면 루프 없이 REST_S 초 장면의 정지 한 장을 그리고, 03 과 06 은 쪽이 목표 각도에 바로 놓인다. 탭이 숨은 동안은 시계를 세워 두고 돌아오면 첫 프레임 간격을 버린 채 이어 간다.

키보드와 포인터

무대 상자는 role="application" 에 tabindex 0 이고, ← ↑ 는 앞 쪽, → ↓ Space 는 다음 쪽, Enter 와 P 는 일시정지, N 은 한 걸음, R 은 처음부터다. 06 은 클릭 넘기기를 끄고 pointer events 에 setPointerCapture 로 모서리를 끌게 한다.

상태 줄은 읽는 때를 가른다

시연 중 상태 줄은 aria-live="off" 이고 손대면 polite 로 바뀐다. 자동으로 흐르는 동안에는 번호 없는 문장 하나만 쓰고, 멈추거나 직접 넘길 때만 「N쪽 중 M쪽」 처럼 번호를 싣는다. 한 판이 끝나면 role="status" 가 한 번만 쓴다.

플립북 FAQ — 라이브러리 없이 만들기·쪽 그림은 어떻게 얻는지·웹에 올릴 때 필요한 것

플립북 만들기에 라이브러리가 필요한가요

캔버스 2D 만 있으면 되고 아홉 장면은 라이브러리·WebGL·CDN 을 부르지 않는다. 쪽 배열과 번호 계산이 몇 줄짜리 순수 함수라서 필요한 장면만 잘라 붙여도 돈다.

쪽 그림은 어떻게 얻나요

이 편의 쪽은 코드로 그린 배열이고, 가진 그림이 있으면 PAGES[i] 자리를 이미지 배열로 바꾸면 된다. 그림을 두 장만 만들었다면 09 처럼 열쇠 쪽 둘에서 중간 쪽을 계산으로 채운다.

웹에 올릴 때 무엇을 더 챙기나요

무대에 role="application" 과 설명 문장을 달고, 움직임 줄이기 설정에서는 정지 한 장을 그리게 하며, 키보드로 쪽을 넘기고 일시정지할 수 있게 한다. 쪽 번호는 시작 시각을 저장해 경과 시간에서 다시 계산하는 쪽이 느린 기기에서도 속도가 같다.

참고로 MDN 의 Canvas 2D 문서는 그리기 함수를, requestAnimationFrame 문서는 시간 차이를 받는 루프를, Page Visibility API 문서는 탭이 숨는 때를, prefers-reduced-motion 문서는 움직임 줄이기 설정을 다룬다. 쪽을 넘기는 다른 표현은 카드 뒤집기 편에서, 숫자가 돌아가며 바뀌는 장면은 숫자 롤링 편에서, 시간을 재는 장면은 스톱워치 편에서 볼 수 있다.

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

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