GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

フリップブック 9種 — めくる速さからオニオンスキンと中間ページまで

フリップブックは数枚の絵からどうやって動きを作るのでしょうか。02番は前後のページを薄く重ねて点の道筋を見せ、05番はめくる速さがどこで一本にまとまるかを測り、09番は二枚だけ描いて中間ページを計算で埋めます。ページの絵はコードで描いた配列で、めくる場面はページ番号を時間から計算して初めて成り立ちます。

フリップブックは紙一枚ごとに絵を描くことではなく、ページ番号を時間に合わせて進め、前後のページとの位置の差を目に任せる計算です — フリップブックを作るには、ページ数とめくる速さを先に決めることから始まります

自動再生 · タイルを押すとその項目へ · すべて1つのzip

九つの場面が共有する設計はひとつです。ページの絵は PAGES 配列に入った12枚で、一枚にはボール一つの横・縦・つぶれの値しかなく、ボールは12ページの間に二回弾みます。

    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 は位相から整数部分を取り出してページ番号として返します。時計は場面の時計ひとつで、九つのソースのどこでも Date と Math.random を呼ばず、外部の画像も使いません。

ページ数とめくる速さを先に決めます

フリップブックとは、ページをめくると絵が動いて見える本のことで、コードではページ番号が時間に沿って増えながら配列を読む作業です。「フリップブック 作り方」で探す人がまず決めるのはページ数と速さで、ページ数は FRAME_COUNT の12、速さは01の FLIP_HZ 12のように毎秒のページ数で書きます。

フリップブック 9種 — 親指でページをはじく速さ・オニオンスキンの重ね・とじ目にしなる扇形・ページごとに違う間隔のタイミング・ちらつきがまとまる速さ・角を引いてめくる・厚みと側面の縞・巻き戻しと往復の番号・中間ページの自動補間

順番は速さから始まり、重ね・しなり・間隔を通って目の残像と手で引く動き、本の厚み、番号の計算、中間ページの補間へ進みます。01〜05は時間表がページをめくり、06は手で引き、07〜09はページ数と番号と絵そのものを触ります。

最後の全体レンダリングで測った九つの場面の面積・フレーム・強さ

表の値は測定結果のファイルから写しました。累積面積が最大なのは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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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番)と最初のページが一周期に二か所で同じ番号のまま続き、右の板は周期22の式で0か所です。累積面積17.4%で16/23のフレームで動きが捉えられ、強さの平均99.5は九つの場面で最大です。

巻き戻しと往復でのページ番号の選び方
新しいタブで開く

08 · 巻き戻しと往復でのページ番号の選び方 — 計測 累積面積 17.37% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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二枚の間の中間ページを自動で埋める

最初と最後のキーページに6点の図形だけを置き、間のページは同じ 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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ページを一つの動きにまとめます

動きを決めるのはページ数、めくる速さ、重ねのアルファ、とじ目のしなりです。ページ数12に速さ12を入れると一巡は1.0秒で、速さを3に下げると4.0秒になり、同じ12ページが速さひとつで別の動きになります。

ページ一枚は位置の値だけを持ちます

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は呼びません。必要な場面ひとつだけを切り取って貼っても、ページの配列と 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はクリックでめくるのを切り、ポインターイベントの setPointerCapture で角を引かせます。

状態の行は読む時を分けます

デモ中の状態の行は aria-live="off" で、触ると polite に変わります。自動で流れているあいだは番号のない文ひとつだけを書き、止めたり自分でめくったりしたときだけ「Nページ中Mページ」のように番号を載せ、一回分が終わると role="status" が一度だけ書きます。

フリップブックのよくある質問 — ライブラリなしで作る・ページの絵はどこで手に入れるか・ウェブに載せるときに必要なこと

フリップブックを無料で作るのにライブラリは必要ですか

キャンバス2Dがあれば足り、九つの場面はライブラリ・WebGL・CDNを呼びません。ページの配列と番号の計算が数行の純粋な関数なので、必要な場面だけ切り取って貼っても動きます。

ページの絵はどこで手に入れますか

この編のページはコードで描いた配列で、手元に絵があれば PAGES[i] の部分を画像の配列に替えれば足ります。絵を二枚だけ作ったなら、09のようにキーページ二つから中間ページを計算で埋められます。

ウェブに載せるときに何を足しますか

ステージに role="application" と説明の文を付け、動きを減らす設定では静止した一枚を描かせ、キーボードでページをめくって一時停止できるようにします。ページ番号は開始時刻を保存して経過時間から計算し直す方が、遅い端末でも速さが変わりません。

参考までに、MDNの Canvas 2D の文書は描画の関数を、requestAnimationFrame の文書は時間差を受け取るループを、Page Visibility API の文書はタブが隠れる時を、prefers-reduced-motion の文書は動きを減らす設定を扱っています。ページをめくる別の表現は カードの裏返し の編で、数字が回って変わる場面は 数字のローリング の編で、時間を測る場面は ストップウォッチ の編で見られます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。