BLOG.HIRO UENO.ART
← index
2026.01.21

サイドパネル

MONET(自作の会話AIで、中心に立って他のエージェントの指揮をする役)のUIに、右から出てくるサイドパネルがある。

チャットで「今日のタスク見せて」と言うと、右側にスッとパネルが開いてタスク一覧が出てくる。閉じると画面いっぱいにチャットが戻る。Claude Desktopの右サイドバーに近い挙動を、自分のアプリでもやりたかった。

見た目は単純。でもこの「スッと開いて、スッと閉じる」が、実装してみるとぜんぜん気持ちよく動かなかった・・・。


最初の実装はFlexboxで組んでた。

左にサイドバー、真ん中にチャット、右にパネル。パネルは position: absolute で上からかぶせて、transform: translateX() でスライドインさせる。よくあるやり方。

で、これだと何が起きるか。パネルが開いたとき、チャットエリアの右端がパネルの下に潜る。潜ったままだと文章が見切れるから、チャット側も幅を縮めたい。flex: 1 の値を動かして縮めようとする。

ここで引っかかる。Flexboxの flex: 1 はトランジションで滑らかに動かない。width みたいに0.25秒かけて縮んでくれない。パネルのスライドインは滑らかなのに、チャット側はガクッと幅が変わる。境目に一瞬隙間ができたり、チャット側の要素が急に消えたりする。

見た目が気持ち悪い。

途中で requestAnimationFrame の手動アニメーションも考えたし、transform: scale() でごまかすことも考えた。でもどれも本質的じゃない。パネルが450px開いたら、チャットはきっちり450px縮まないとダメ。両者が同じ時間、同じイージングで動かないと、どこかがズレる。


答えはCSS Gridの grid-template-columns だった。

コーディングしてくれている Claude Code(以降 Code)が「grid-template-columns 自体をアニメーションできる」と返してきて、初めて知った。

.container {
  display: grid;
  grid-template-columns: 260px 1fr 0px;
  transition: grid-template-columns 0.25s ease-out;
}

.container.with-panel {
  grid-template-columns: 260px 1fr 450px;
}

3カラム構成。左がサイドバー(固定260px)、真ん中がチャット(残り全部)、右がパネル(閉じてるとき0px、開くと450px)。

with-panel クラスがつくと、右のカラムが0pxから450pxに0.25秒かけて伸びる。伸びるぶんだけ真ん中の 1fr が自動で縮む。1行のCSS宣言で、3エリアが連動して動く。


ただ、Gridに切り替えると、Flexbox前提で書いてた子要素の指定がいろいろ壊れる。

サイドバーには width: 260px と書いてあった。これを残すとGridの 260px 1fr 450px と二重指定になって、中身がはみ出したときにGridが勝手に広がる。Gridの原則は親が幅を決めること、子に幅を指定しない、とCode。サイドバーの width は削った。

チャットエリアの内部も grid-template-rows: auto 1fr auto に書き換えてもらって、ヘッダー・チャット本体・入力エリアを縦に並べた。ここで重要なのが min-width: 0 らしい。Gridの子要素はデフォルトで min-width: auto になっていて、中身の最小幅で親を押し広げようとする。これを 0 にしないと、長いコードブロックが出たときにレイアウトが崩れる、とのこと。それと、全Grid子要素に overflow: hidden。忘れると子要素の中身がはみ出してGridが広がって、アニメーション中にカラム幅がブレる。


もう一つ、実装して初めて気づいた罠。

パネルが閉じてるとき、右カラムは0pxだからDOM上は存在しなくていい、と思ってReact側で return null を返してた。するとGridのカラムが3つから2つになって、開けたときのアニメーションが機能しない。

if (!panel) {
  return <div className="agent-panel closed" />;
}

空のdivを返すようにCodeに直してもらった。中身は空っぽでも、Gridのカラムを1つ占有していてくれればいい。これで grid-template-columns のトランジションが成立する。

DOMを「消す」のとCSSで「0pxにする」のは違う。アニメーションさせたいなら、要素は残す。


パネルとチャットの連携は、チャットがパネルを「開く」側、パネルがチャットに「完了報告する」側、という非対称な設計に落ち着いた。双方向というより、イベントの向きが役割で決まってる感じ。


で、全部通ってやっと気持ちよく動き出した。

Flexbox時代は「パネルを開く」操作そのものが、一瞬の不快感を伴ってた。ガクッと動く画面。一瞬だけ見える隙間。操作するたびに小さいストレスが積み重なってた。Gridに切り替えたあとは、意味もなくパネルを何度も開け閉めするようになった。動きそのものが気持ちいいから。UIってこういうところで人を繋ぎとめるんだな、とちょっと思った。


仕組みとしては grid-template-columns にtransitionを効かせるだけの、ほぼ1行の話。でもそこに辿り着くまでに、Flexboxの限界を認めて、レイアウトのパラダイムをひとつ乗り換える必要があった。

アニメーションの滑らかさって、イージング関数とか時間調整の話だと思ってた。でも実際は、そもそもどのプロパティをアニメーションさせるかで勝負が決まるwidth でも flex でもなく、grid-template-columns を動かす。聞いて納得した。

Flexboxは要素を並べることには向いてるけど、レイアウト全体の比率を滑らかに変えることには向いてない。Gridはその逆。パラダイムをひとつ乗り換えて、やっと1行にたどり着いた。