動くコードと、気持ちよく感じるコードを分けるディテール
3Dキャンバス上にフローティングタブバーを載せる画面を作り直した。「動く」版は半日でできたが、「ちゃんと感じがいい」版にするまでにはずっと時間がかかった。その過程でぶつかった問題と学びを記録します。
WebGLキャンバスの上に3Dビューを常時敷き、その上に下部タブバーを浮かせる画面を作り直した。タブを押すと、3Dの上へパネルがせり上がるか、別のページへ遷移する構成だ。
最初の「とにかく動く」版は、あっという間に作れた。ただし、動くだけだった。押すたびどこか不自然で、少し安っぽく感じる。そこから「ちゃんと感じがいい」版にたどり着くまでには、ずっと長い時間がかかった。
毎回、作るのは早いのに磨き込む作業は何倍もかかる。自動化もしづらい。ハーネスをどれだけ重ねても、できたものが気に入らない。
結局は、一つずつ直して削り出すほうがずっといい。
動かすものは transform で
最初はパネルが上がるとき、スプリングで高さを伸ばしていた。毎フレーム height を変えるやり方だ。滑らかに見えるはずだったのに、押すたびにフレームが落ちた。
理由は単純だった。height を毎フレーム変えると、ブラウザは毎フレーム、レイアウトを再計算する。しかもパネルにはブラーがかかり、背後ではリアルタイムの3Dが動いていた。毎フレームのレイアウト、ブラーの再計算、3D合成が一度に重なる。最悪の組み合わせだった。
ブラウザのレンダリングパイプラインは Layout → Paint → Composite の順に進む。width、height、top、left のようなプロパティは先頭のLayoutからすべてをやり直すが、transform と opacity が触るのは最後のComposite段階だけだ。GPUがすでに描いたレイヤーを移動するだけで済む。
そこで、高さを伸ばすモーフィングをやめ、固定高のシートを translateY でスライドさせる方式に変えた。動くパネルからはブラーも外し、ソリッドな背景に替えた。ライブ3Dの上で毎フレーム、ブラーを再計算するのがもっとも高コストだったからだ。
インタラクション中に変化させるプロパティは、
transformとopacityに絞るのが安全だ。レイアウトプロパティをアニメーションさせると、そのコストは子要素のツリー全体に広がる。
選択インジケーター(押されたタブに追随するボックス)が、何度も見当違いの位置に出た。最初は各ボタンを参照し、offsetLeft と offsetWidth を読んで、インジケーターをその位置へ移していた。
問題は二重だった。一つはタイミング。測定が正確になるのはレイアウト確定後だが、マウント時のタイミングやフォント読み込みで幅が変わり、測る時点がずれ続けた。最初の測定値が0のまま固まることもあった。もう一つは座標系だ。offsetLeft は親のborder box基準なのに、絶対配置したインジケーターの left: 0 はpadding box基準になる。そのため親のpadding分だけ常にずれる。補正のために足したオフセットが、別のケースをまた壊した。
そこで分かったのは、レイアウトから読み取って再び書き込むパターン自体が壊れやすいということだった。読み取る瞬間にレイアウトが確定していなければならないが、その保証は思った以上に難しい。
結局、測定を丸ごとやめた。ボタンを固定サイズの正方形にし、インジケーターは activeIndex だけから純粋な計算で配置した。インデックスにステップ幅を掛ければ位置が出る。DOMを一行も読まないので、タイミングの問題も座標系のずれも消えた。
計算で分かる値を、わざわざ測定しないこと。レイアウトを読んで反応するコードは、本当に最後の手段だ。
ease-in と ease-out を使い分ける
パネルは「上がるときはいいのに、閉じると急に閉じる」と感じた。双方向のトランジションに減速カーブ(ease-out)だけを使っていたのが原因だった。Svelteではよく起きる。
開くときは、減速カーブが滑らかに着地してよい。しかし閉じるときに同じカーブを進行方向だけ反転して再生すると、値がほぼ開いた状態で粘り、最後に急落する。時間的には「ほぼ開いたまま、最後で急に閉じる」動きになる。
登場と退出は、そもそも違う動きを求めている。入るときは減速して着地し、出るときは加速して抜けるほうが自然だ。そこで、入場モーションにはease-out、退場モーションにはease-inを別々に指定した。時間は240msに統一した。
イージングは飾りではなく、動きの意味を伝えるものだ。登場 ≠ 退出。
スプリングは魔法ではない
インジケーターをスプリングで動かしたところ、「黄色いボックスの追従が遅すぎる」というフィードバックが来た。スプリングは物理ベースなので、目標をゆっくり追いかける。滑らかではあるが、タブのハイライトのように「押したらほぼ即座に付くべき」UIでは、その遅れが鈍く見えた。わずかなオーバーシュートも気になり、マウント時に0からアクティブ位置へスライドする不要な初期モーションもあった。
スプリングを外し、普通のCSSトランジションに変えた。200msの速いease-outだ。タブにすぐ付き、連続で押しても進行中のトランジションが現在位置から自然に向きを変える。さらにCSSトランジションは初回レンダーの初期値にはかからないため、マウント時のスライドも消えた。
状態切替のハイライトには、物理スプリングよりも、決定的で中断可能なCSSトランジションのほうが合う。高価で複雑なものが常によいとは限らない。
z-index を正しく使う
3Dの上に浮いていたHTMLオーバーレイ(キャラクターの頭上に出る吹き出しのようなもの)が、タブバーやパネルより上に飛び出してきた。タブバーのz値をいくら上げても勝てない。
ソースを追うと、そのオーバーレイにはキャンバス上で見えるよう、ライブラリ側が意図的に数百万単位の z-index を与えていた。その副作用で、ほかのUIまで全部覆っていた。
ここで本当に重要だったのはスタッキングコンテキストだった。オーバーレイがページのルートで本当に一千万のzを持つなら、タブバーでは絶対に勝てない。だがオーバーレイが低いzのコンテキスト内に閉じ込められているなら、その一千万が意味を持つのはコンテキストの内部だけで、ページ基準ではそのコンテキスト自身のzにすぎない。position と z-index を持つ要素は新しいスタッキングコンテキストを作り、子要素のzは外へ漏れない。
そのため二つを明確にした。一つはライブラリのオーバーレイのz範囲を直接制限し、「キャンバスより上、フローティングUIより下」に閉じ込めること。もう一つは、レイヤー全体の順序を意図的に設計し、コメントに固定することだ。キャンバス < オーバーレイ < バックドロップ < タブバー・ポップアップ < スプラッシュ < トースト、という具合に。
3DやWebGLがDOMと混ざると、z-indexは「大きな数字の競争」ではなく、スタッキングコンテキスト設計の問題になる。ライブラリが一千万単位のzを使っていても、それに追随して数値を上げず、閉じ込めるのが答えだ。
「何か変」の8割は、ディテール不足
「選択状態のタブボタンのデザインが変だ。とくにアニメーションが」と思った。
最大の原因はレイアウトシフトだった。非アクティブ時はアイコンだけ、アクティブ時はアイコンとラベルという構成だったため、タブを押すたびにアイコンが中央から左へ押し出されて再配置された。そこにラベルのフェードとスライドするインジケーターが別々に動き、散漫になっていた。
そこで細かく手を入れた。ラベルを完全に外して選択時にアイコンが跳ねないようにし、外側の角丸からpaddingを引いた値で内側の角丸を合わせ、同心円状に整列させた。押したときには少し縮むフィードバック(0.96程度。0.95より縮めると大げさに見える)を入れ、アクティブなアイコンはわずかに大きくして線を太くした。ハイライトからバウンスを外し、transition: all の代わりに動かすプロパティだけを明記した。ヒット領域は最小推奨値を超えるようにした。
「何か変だ」というフィードバックの大半は、レイアウトシフト、ずれた角丸、フィードバックの欠如といった微細な問題の合計だ。一つひとつは些細でも、重なると「安っぽさ」になる。
ネットワークも、考えよう
「この画面を開くたびに、全部をAPIから取ってくるのが少し気になる」。これはピクセルの問題ではなくデータフローの問題だが、ユーザーが感じる滑らかさに直結していた。
パネルを閉じるとアンマウントされ、開くと再マウントされる構造だったため、開くたびに一覧全体を取り直していた。ローカル保存があったので空画面にはならなかったが、それでも毎回フルペイロードを要求していた。面白いのは、同じ画面の別データはすでにうまくできていたことだ。TTLキャッシュと、変更時の無効化。一か所だけ、そのガードが抜けていた。
そこで、抜けていた場所にだけTTLガードを加えた。一定時間内に同じ条件で成功していれば、ネットワークを省略する方式だ。コンテンツはほぼ日単位でしか変わらないので、短いTTLで十分だった。要点はstale-while-revalidate、つまりキャッシュをすぐ表示し、購入・削除などの変更があればその時点で明示的に無効化することだ。
「開くたびに取り直す」は怠惰な正解だ。本当の正解は、キャッシュと変更時の無効化。そしてすでにうまく動いているコードに手を出さないことも、エンジニアリングだ。
「急に閉じる」「追従が遅い」「何か変だ」。曖昧な不快感を掘り下げると、すべてに具体的な原因があった。結局この仕事は、そのフィードバックループをどれだけ短く回せるかの勝負なのだと思う。Claude Codeと頭を使ってやり取りするのは楽しいが、毎回こうしてディテールを磨くのは疲れる。機能を作るほうが楽しく、磨き込むと楽しさは少し薄れる。
動く機能を作るだけなら半日でできる。気持ちよく感じるコードに至るまでの残りの距離に、時間のほとんどを使う。
コメント
height 매 프레임 바꿔서 프레임 떨어졌다는 거 완전 국룰 실수ㅋㅋ transform으로 갈아탄 게 신의 한 수네
offsetLeft는 border box 기준이고 절대배치 left:0은 padding box 기준이라 어긋난다는 거 오늘 처음 알았다
ease-out만 양방향으로 쓰면 닫을 때 훅 떨어지는 거 완전 공감ㅋㅋ 근데 240ms는 어떻게 정한 숫자야?