自分で作っている web 版を一周見ていて気になった動きを、AI に原因まで測ってもらって直した記録です。修正済み。
何が起きていたか
写真を1冊のフォトブックとして本の形でめくれる画面があります。棚に並んだ背をクリックすると表紙が開いて、中の見開きが読める、というもの。
そこを触っていて、気付きメモにこう書いた。
ページを開く際のモーションの時に、開いた時の下の表現の方の表示が早いから、本を開くモーションの時に本が瞬間移動して突然現れるように見える
言い方が回りくどいのは、見えているものをそのまま書こうとしたからだ。表紙がめくれ始めた瞬間に、本全体がパッと横に飛ぶ。しかも本の下にあるはずの紙の束が、表紙がまだ開ききっていないのに先に出ている。だから「開いた」というより「別の絵に切り替わった」ように見えていた。
原因は2つあって、どちらも同じ形をしていた
AI に調べてもらったら、飛んでいた原因は2つあった。
ひとつ目は本の位置。 本が閉じているときは、見えているページが1枚しかありません。見開き2ページ分の幅を前提に置いているので、表紙は右半分にだけ乗り、そのままだと画面の右に寄って見えてしまいます。だから閉じている間だけ半ページ分(195px)左にずらして、中央に見えるようにしていた。そのコードがこれ。
// turned = 今までに何枚めくったか。0 = 表紙が閉じている / maxTurn = めくり切って裏表紙が閉じた
// PAGE_W = ページ1枚の幅(390px)。その半分が195px
// 「閉じているか」を真偽値で判定して、閉じているときだけ半ページ分ずらす
const closed = (turned === 0 || turned === maxTurn) && !animation;
const shift =
turned === 0 && !animation ? -PAGE_W / 2 : // 表紙が閉じている=左へ195px
turned === maxTurn && !animation ? PAGE_W / 2 : // 裏表紙が閉じている=右へ195px
0; // それ以外=ずらさない条件に !animation(アニメ中ではない)が入っています。ということは、表紙をめくり始めてアニメが動き出した最初の1コマで、この条件が一斉に false になります。-195px だった shift が、次のコマで 0 になります。1コマで195px移動する=瞬間移動の正体だった。
しかもこのとき、位置の変化をなめらかにする CSS の設定(transition)はアニメ中だけオフにしています。transition は「A から B に変わるとき、間を自動で埋めて動かす」機能で、この"間を埋める"ことを補間といいます。毎コマ自分で書き込む方式と、CSS に補間させる方式が二重にかかると動きが濁るからです。結果として、この195pxの移動を緩和してくれるものが何も無かった。
ふたつ目は中身の出現。 本の下に敷いてある紙の束と、ページの小口(本の側面に見える紙の断面)も、同じ closed という真偽値で出し分けていた。
{/* {条件 && <部品/>} は「条件が本当のときだけ、その部品を描く」という React の書き方 */}
{!closed && <div className={s.bookBlock} />} {/* 閉じてない=出す。以上 */}closed が false になった瞬間、つまり表紙がまだ1度も傾いていない時点で、紙の束が完成形で出ます。メモに書いた「開いた時の下の表現の方の表示が早い」はこれだった。
なぜこうなりやすいか
自分でこの画面を作っていたら、たぶん「表紙のめくりアニメ」だけを作って満足していたと思う。飛びが出たのは、時間軸がある側と無い側の境目でした。
直し方
shift と紙の束の出現を、q から作る連続量に置き換えてもらった。
// 表紙側の「閉じ具合」。1 = 完全に閉じている、0 = 開ききっている
// turning = めくっている最中の情報(どの紙を、どこまで進めたか)。めくっていなければ空
function frontClosed(turned, turning) {
if (!turning) return turned === 0 ? 1 : 0; // 静止時は今までどおりの2択
// leaf は今めくっている紙の番号で、0 が表紙。表紙をめくる間は 1→0 へ連続に薄れる
return turning.leaf === 0 ? 1 - turning.q : 0;
}
// backClosed は裏表紙側の同じ形の関数(最後の紙をめくり切ると 1 = 閉じる)
// 位置も、紙の束の見え方も、この2つの連続量から導く。どちらも画面の外に出した関数にした
export function coverShift(turned, maxTurn, turning) {
// 195px が滑って0へ
return (PAGE_W / 2) * (backClosed(turned, maxTurn, turning) - frontClosed(turned, turning));
}
export function openness(turned, maxTurn, turning) {
// Math.min = 2つのうち小さいほう。前と後ろで「まだ開いていない側」に合わせる
// 0 = 閉じていて見えない / 1 = 開いて見えている
return Math.min(1 - frontClosed(turned, turning), 1 - backClosed(turned, maxTurn, turning));
}ここで効いたのが、この計算を画面から切り離してただの関数にしたこと。画面の部品の中に書いてあると、動かして目で見るしか確かめようがありません。関数に出しておくと、両端と途中を数値で固定できます。
const LAST = 7; // 本の leaf の枚数(表紙〜裏表紙)= maxTurn
// { leaf: 0, q: ... } = 表紙を q まで開いた途中
// 表紙を開く間は q に沿って滑る=1コマで飛ばない
expect(coverShift(0, LAST, { leaf: 0, q: 0 })).toBe(-PAGE_W / 2); // 開き始め=左に195px
expect(coverShift(0, LAST, { leaf: 0, q: 0.5 })).toBeCloseTo(-PAGE_W / 4); // 半分開けばずれも半分=途中がある
expect(coverShift(0, LAST, { leaf: 0, q: 1 })).toBe(0); // 開き切り=ずれ0真ん中(q: 0.5)の1行が、今回いちばん意味のあるテストだ。壊れていたときは、この入力に対して 0 が返っていた。
測れなかったところ
正直に書いておくと、動きが実際になめらかになったかは、この時点では確認できていない。
AI が画面を見るときに使っているブラウザは、ウィンドウが表示されていないとアニメの描画そのものを止めます(電池と CPU の節約でブラウザがそうしています)。今回まさにその状態で、めくりの途中のコマを1枚も撮れなかった。
代わりに確認できたのは2つ。アニメが動き出した最初のコマで本が -195px の位置を保っていること(=飛びの発生点が消えたこと)と、上のテストが通ること。「滑って見えるか」は結局こっちが目で見るしかない。ここを「直りました」で済ませるとあとで自分が困るので、レポートにも同じことを書いてもらった。
学んだこと
- 動きを足すときは、その動きに関係する値を全部、同じ進捗(
q)から作り直します。置き換え漏れが1つでもあると、そこだけが飛びます - 自前で毎コマ描く方式と CSS に任せる方式が混ざっている場所は、飛びが出たときに先に疑える候補になります
- 目で見て判断するしかない部分と、数値で確かめられる部分を切り分けておくと、「見た目は直ったけど何が直ったか分からない」になりません
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。