GODRICH

画面分割 css レイアウト9選 — ドラッグで幅が変わる

画面分割 css とは、1つの画面を2つ以上の枠に区切り、その境界線をつかんで引くと各枠の大きさが変わる組み方です。9つのうち8つは境界線1本を動かすだけで隣の枠が縮み、1つは箱が狭くなった瞬間に左右が上下へ入れ替わります。

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

9つは人気順ではなく、境界線が覚えていく順に並べました。最初の3つは基本で、左右に動く境界線(01)、同じ考え方を縦軸に回したもの(02)、その2本を1画面に入れてサイドバー・本文・コンソールにしたもの(03)です。4つ目からは境界線が値を覚え始めます。2回押すごとに折りたたみと展開が切り替わり(04)、きりのよい比率の近くでは磁石のように吸い付き(05)、手が近づくと見た目を変え(06)、タブを閉じて開き直しても最後の比率を覚えています(07)。8つ目は境界線が線ではなく角の1点になったもの(08)で、最後の1つ(09)は境界線の向き自体が箱の幅によって変わります。9つとも pointerdownpointermovepointerup の1組と CSS カスタムプロパティ1つだけで動き、ライブラリは使っていません。9つとも矢印キーで同じ値を動かせます。

01縦スプリッター

中央のハンドルを左右に引くと、左の枠の幅がポインターにそのまま追従します。幅は grid-template-columns が読むカスタムプロパティ --split 1つにだけ入っていて、どちらかの枠が最小幅 64px に達すると、値はそこで止まります。

setPointerCapturegrid-template-columnsaria-valuenow
function set(pct) {
  var w = box.clientWidth;
  var lo = MIN / w * 100, hi = (w - MIN - BAR) / w * 100;
  var v = Math.max(lo, Math.min(pct, hi));
  root.style.setProperty('--split', v.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(v)));
}
bar.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  set((e.clientX - box.getBoundingClientRect().left) / box.clientWidth * 100);
});

02横スプリッター

ハンドルを上下に引くと、上の編集領域と下の結果領域が高さを互いにやり取りします。同じ計算を Y 軸に回しただけで、上下の矢印キーでも一度に 5% ずつ同じ値が動きます。

pointermoveclamparia-orientation
function clamp(v, lo, hi) { return Math.max(lo, Math.min(v, hi)); }
function set(pct) {
  var h = box.clientHeight;
  var v = clamp(pct, MIN / h * 100, (h - MIN - BAR) / h * 100);
  root.style.setProperty('--splitY', v.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(v)));
}
bar.addEventListener('keydown', function (e) {
  var d = e.key === 'ArrowUp' ? -5 : e.key === 'ArrowDown' ? 5 : 0;
  if (!d) return;
  stop();
  set(Number(bar.getAttribute('aria-valuenow')) + d);
  bar.focus({ preventScroll: true });
  e.preventDefault();
});

033ペインエディタ配置

ファイル一覧・エディタ・コンソールが2本のハンドルでつながっていて、片方を動かしても隣の枠だけが縮みます。外側のグリッドが列を、内側のグリッドが行を分けているので、2本のハンドルが互いの値に触れることはありません。

grid-template-columnsgrid-template-rowssetPointerCapture
.tp__box {
  display: grid;
  grid-template-columns: var(--col) 8px 1fr;
  height: 128px;
  overflow: hidden;
}
.tp__main {
  display: grid;
  grid-template-rows: var(--row) 8px 1fr;
  overflow: hidden;
}

04ダブルクリック折りたたみ

ハンドルを2回続けて押すと隣のパネルが細い帯に折りたたまれ、もう一度2回押すと折りたたむ直前の幅に戻ります。折りたたむ前の値を変数1つに控えておくので、開いたときに出るのは初期値ではなく利用者が最後に合わせた幅です。

dblclickaria-expandedtransition
bar.addEventListener('dblclick', function () {
  stop();
  folded = !folded;
  if (folded) set(RAIL, true);
  else set(last, false);
});
function set(pct, fold) {
  root.style.setProperty('--split', pct.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(pct)));
  bar.setAttribute('aria-expanded', String(!fold));
  root.classList.toggle('is-folded', fold);
}

05スナップガイド線

ドラッグ中は 25・50・75% の位置にガイド線が出ていて、ハンドルが前後 6% 以内に入ると値がガイド線にぴたりと吸い付きます。判定は距離だけを見ればよいので、Math.abs の比較1つで済みます。

snapopacityMath.abs
var STOPS = [25, 50, 75], NEAR = 6;
function snap(pct) {
  var best = pct;
  for (var i = 0; i < STOPS.length; i++) {
    if (Math.abs(pct - STOPS[i]) < NEAR) best = STOPS[i];
  }
  return Math.max(10, Math.min(best, 90));
}
function set(pct) {
  var v = snap(pct);
  root.style.setProperty('--split', v + '%');
  root.classList.toggle('is-snapped', STOPS.indexOf(v) >= 0);
}

06ハンドルの視覚フィードバック

普段は髪の毛のような1本線だった境界が、マウスを乗せると広がって3つの点が現れ、引いている間は色が濃いまま保たれます。広がるのは実際の幅ではなく scaleX なので、その間に隣の枠がずれることはありません。

scaleXcubic-beziercursor
.hg__hair {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 20px;
  border-radius: $r-pill;
  background: $subject-ink;
  transform: translateX(-10px) scaleX(.1);
  transition: transform $duration $easing, background $duration $easing;
}
.hg__bar:hover .hg__hair { transform: translateX(-10px) scaleX(1); }
.hg.is-drag .hg__hair { background: $subject-blue; }

07比率の保存とプリセット

ドラッグで合わせた比率はその場でブラウザに保存され、次に開いたときは保存値から始まります。1:2・1:1・2:1 のプリセットボタンは同じ値を一度に指定し、aria-pressed で今どの比率なのかを伝えます。

localStorageJSON.parsearia-pressed
function set(pct, mark) {
  var v = Math.max(20, Math.min(pct, 80));
  root.style.setProperty('--split', v.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(v)));
  btns.forEach(function (b) {
    var on = mark && b.dataset.v === String(Math.round(v));
    b.classList.toggle('is-on', !!on);
    b.setAttribute('aria-pressed', String(!!on));
  });
  try { localStorage.setItem(KEY, JSON.stringify({ split: Math.round(v) })); } catch (err) { return; }
}

08カード隅のグリップ

カード右下の角をつかんで引くと横と縦が一緒に伸び、ロックボタンがオンなら高さは常に幅 × 最初の比率で決まります。カードの中の数字も同じ値を読むので、箱の大きさと食い違うことがありません。

pointerdownaspectMath.round
var RATIO = 96 / 176, locked = true;
function set(w, h) {
  var W = Math.max(120, Math.min(Math.round(w), 220));
  var H = locked ? Math.round(W * RATIO) : Math.max(64, Math.min(Math.round(h), 132));
  root.style.setProperty('--w', W + 'px');
  root.style.setProperty('--h', H + 'px');
  root.style.setProperty('--wn', String(W));
  root.style.setProperty('--hn', String(H));
  grip.setAttribute('aria-valuenow', String(W));
}

09レスポンシブ自動スタック

箱の幅が 200px を下回った瞬間、左右に分かれていた2つの枠が上下の並びに切り替わり、ハンドルも縦から横へ一緒に向きを変えます。ウィンドウをメディアクエリで測るのではなく ResizeObserver で自分の箱を測るため、同じ部品を狭いサイドバーの中に入れても正しく切り替わります。

ResizeObserveraria-orientationgrid-template-rows
var ro = new ResizeObserver(function (entries) {
  var w = entries[0].contentRect.width;
  var next = w < FLIP;
  if (next === stacked) return;
  stacked = next;
  root.classList.toggle('is-stack', stacked);
  bar.setAttribute('aria-orientation', stacked ? 'horizontal' : 'vertical');
});
ro.observe(box);

どこで壊れるか — 落とし穴

9つに共通する落とし穴は、比率を入れたカスタムプロパティが登録されていないと中間の値が作られないことです。--split のような名前は既定ではただの文字列なので、ブラウザは 50% と 26% の間に何があるのかを知りません。01番のデモから @property --split { syntax: "<percentage>" } の宣言だけを消して同じ2秒のループを測り直したところ、動きが出たフレームが24枚中19枚から7枚へ、変化した画素の平均差が 121.9 から 64.6 へ下がりました。幅がなめらかに流れず、キーフレームの地点ごとにぱっと飛んでいたということです。宣言を戻すと同じコードがそのまま19枚に戻りました。マウスで引いている間はスクリプトが毎フレーム値を書くのでこの差は見えず、折りたたみの動きやプリセット移動のように CSS 自身が間を埋める場面でだけ表に出ます。

2つ目は、数字と見た目がずれる問題でした。08番はカードの大きさを文字でも出しますが、その文字をスクリプトからだけ更新していたので、自動再生中はカードが大きくなっても数字は最初の値のまま止まっていました。07番はもっと厄介でした。プリセットボタンの押された表示をクリックの瞬間に点けていたため、比率が移動している途中の場面が代表画像に選ばれ、2:1 を押したのに画面は 1:1 という食い違いが起きました。08番は数字を counter() が読む整数プロパティに変えてカードの大きさと同じ値に結び付け、07番は押された表示を比率が到着した後に回すようにしました。どちらも自動検査には引っかからず、人の目に「たまに変な場面」としてしか映らない類のものです。

症状 本当の原因 直す場所
折りたたみやプリセット移動がぱっと飛ぶ 比率のプロパティが未登録で中間値がない @propertysyntax: "<percentage>"
ハンドルから外れるとドラッグが切れる ポインターがハンドルに結び付いていない pointerdown の中で setPointerCapture
狭い画面でだけ配置が変わらない ウィンドウの方を測っている ResizeObserver で箱自身を測る

表の3行をすべて反映した9本のソースは、パスワード 34ze99gp を入れると開く zip の中にあり、バニラ版と React 版が同じ値を使っています。

アクセシビリティ

9つとも、ハンドルがマウス専用にならないように作ってあります。境界には role="separator"aria-orientation が付いていて、その要素が何で、どちらの向きに動くのかを伝え、aria-valueminaria-valuemaxaria-valuenow が今の比率を数値で読み上げます。01・02・03・05・06・07・09番は左右または上下の矢印キーで、04番は Enter とスペースで折りたたみ、08番は左右の矢印でカードの幅を 8px ずつ動かします。07番はハンドルの矢印キーとは別に、3つのプリセットが本物の button なので、それだけでも比率を選べます。04番のハンドルには aria-expanded も付き、09番は配置が入れ替わるときに aria-orientation の値も一緒に変わります。キーボード操作で focus({ preventScroll: true }) を使うのは、これらのデモが一覧ページでは iframe に入るためで、この指定がないと押すたびに親のページが下へ引っ張られます。prefers-reduced-motion: reduce では自動再生のループとトランジションだけを止め、ドラッグ・キーボード操作と値はそのまま残ります。ドラッグできる境界の役割名は MDN の separator ロールの解説にまとまっています。

これらと組み合わせやすい部品なら、ダッシュボードのカテゴリクリックのカテゴリに近い仲間が集まっています。

FAQ

比率を width ではなくグリッドで持つ理由はありますか

枠が3つになった途端に計算が変わるからです。枠ごとに width を与えると、ハンドルの太さを手で引き算しなければならず、1つを縮めたときにどの枠がその分を吸収するかもコードで決める必要があります。grid-template-columns: var(--split) 8px 1fr と書けばハンドルの幅は固定トラックに収まり、残りは 1fr が引き受けるので、動く数字は --split 1つだけになります。03番はこの書き方を列と行に一度ずつ使い、2本のハンドルを独立させています。

ハンドルからカーソルが外れるとドラッグが止まります

そのポインターをハンドルに結び付けていないためです。既定の動作では、ポインターが要素の境界を越えた瞬間にその要素へイベントが届かなくなるので、速く引くと途中でハンドルが止まります。pointerdown の中で setPointerCapture(e.pointerId) を一度呼べば、手を離すまでイベントがそのハンドルに届き続け、パネルの外やウィンドウの外まで引いても値がつながります。9つともこの1行に頼っています。

保存した比率が戻らないブラウザがあります

プライベートウィンドウなど保存が制限された環境では、localStorage に書いた瞬間に例外が出ます。07番が保存と復元の両方を try で包んでいるのはそのためで、例外が出た場合は値を読めなかっただけなので既定の比率から静かに始まります。この備えを外すと、そうしたブラウザではスクリプトがその行で止まり、ハンドル自体が動かなくなります。

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

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