ドラッグ式ボトムシート
画面下からせり出すシート。ハンドルをドラッグして「半分/全開/閉じる」の3段階にスナップする。iOS/AndroidのモバイルUI定番。
プレビュー
操作ドラッグ/スワイプで操作できます(PC・スマホ対応)。
仕様書
- 向く場面
- 共有・アクションメニューのモバイル表示
- 指で開き具合を直接操作させたいパネル
- 半分/全開の両方が要る詳細シート
- 避ける場面
- マウス操作が主の PC 画面
- ボタン数個の軽いメニュー
- 中身が高さを超えて縦スクロールが要る一覧
- 構成要素
- 開くボタン
- バックドロップ
- シート
- ハンドル
- 項目リスト
- 持っている状態
- 通常
- フォーカス
- 押下中
- キーボード
「シートを開く」で半分まで開きハンドルへフォーカス。ハンドルは Enter/Space で半分→全開→閉じるを巡回、↑↓で 1 段ずつ、Esc・背景クリックで閉じて開くボタンへ戻る
- 支援技術
role=dialog+aria-modal+aria-labelledby、閉じている間は inert+aria-hidden。ハンドルは button で aria-label に現在の段名を持つ。項目は button。reduced-motion で吸い付きを即時に
- 動き
吸い付き 280ms cubic-bezier(.32,.72,0,1)、ドラッグ中は追従、背景 250ms。3 点(0/120/240px)へスナップ。reduced-motion で停止
デザインの要点
- 止め位置は 3 点(全開 0・半分 120・閉じ 240px)に固定し、離した位置に最も近い段へ吸い付かせる。中途半端な高さで止めない
- ドラッグ中は transition を切って指に張り付かせ、離した瞬間だけ 280ms の吸い付きを効かせる。常時 transition だと指に遅れる
- 背景の暗さは 1 − translateY/高さ で開き具合に連動させる。開閉の途中でも「どれだけ開いているか」が背景から分かる
- ハンドルは button にして Enter で段を巡回・↑↓で 1 段ずつ動かす。ドラッグしか無いシートはキーボードで閉じられない
ハンドルの pointerdown でドラッグを開始し、move/up は document で受けて(指がハンドルから外れても追従)シートの translateY を直接更新、離した瞬間に最寄りのスナップ点(全開0 / 半分120 / 閉じ240px)へ transition で吸着させます。ドラッグ中は transition を切って指に追従、スナップ時だけ効かせるのがなめらかさの要点。ハンドルとシートに touch-action:none を付け、縦ドラッグが枠のスクロールと競合しないようにしています。背景の暗さはシートの開き具合に連動します。ハンドルは button なので、Enter で段を巡回・↑↓で 1 段ずつ・Esc で閉じるキーボード操作も同じ見本で動きます。
使いどころ
共有シート・アクションメニュー・フィルタなど、モバイルで片手操作を優先したい場面に向く。指でドラッグして開閉度合いを直接コントロールできるため、「今どれくらい開いているか」が視覚的にも触覚的にも明確なのが強み。全開・半分・閉じるの3段階スナップは、地図アプリの詳細パネルのように「少し見たい」「全部見たい」の両方が必要な画面と相性がよい。逆に、PCのマウス操作が主で指でのドラッグが使えない環境では強みが活きないため、デスクトップではモーダルやポップオーバーに切り替える設計が定番になる。ボタン数個程度の軽いアクションリストなら、この可変ドラッグの複雑さは不要でポップオーバーで十分な場合が多い。
仕組み
シートの高さ(H=240)に対し SNAPS=[0,120,H] の3点(全開・半分・閉じ)を定義し、ハンドルの pointerdown で dragging を開始する。setPointerCapture でそのポインタIDの以降の move/up イベントをハンドル自身に固定するため、指がハンドルからシート本体や画面外へ外れても追従が途切れない。ドラッグ中は setT() が startT(開始時のtranslateY)に移動量を足した値へ直接 style.transform を書き換えて指に張り付かせ、このとき .dragging クラスで transition を無効化して遅延なく追従させる。指を離すと nearest() が3つのスナップ点のうち最も近いものを選び、transition を戻した snapTo() で吸着させる。背景の暗さ(backdrop の opacity)は openness = 1 - curT/H の計算でシートの開き具合に連動し、ドラッグ中もリアルタイムに更新される。snapTo() は同時に inert・aria-hidden・ハンドルの aria-label(現在の段名)も更新し、閉じ段では is-closed クラスで visibility:hidden にする。
実装の注意
最も見落としやすいのは .bs-sheet に touch-action: none を付けている点で、これはハンドルのドラッグとスクロールの取り合いを防ぐには必要だが、同じ値がシート内部の .bs-inner にも及ぶため、リスト項目が増えて中身がシートの高さ(240px)を超えたときに、タッチ操作でリストを縦スクロールできなくなる。デモの5項目はたまたま収まっているが、項目を増やすなら .bs-inner に touch-action: pan-y を明示して上書きし、ハンドルのドラッグとリストのスクロールを両立させる必要がある。また、ドラッグの吸着先は SNAPS の3点固定なので、シートの高さ H や項目数を変えたら SNAPS の数値も連動して書き換えないと、中途半端な位置にスナップするようになる。pointerId を個別に保存せずマルチタッチを考慮していない点も、タブレットでの実運用では問題になりうる。ハンドルは button なので、pointerdown でドラッグを始めたあとの click(指を離したときに発火)を「段の巡回」と取り違えないよう、6px 以上動いたドラッグの直後は click を無視している。
アクセシビリティ
ドラッグは「あれば嬉しい追加操作」と位置づけ、土台はモーダルと同じ Dialog パターンにしている。シートに role="dialog" aria-modal="true" と見出しへの aria-labelledby を付け、閉じている間は inert と aria-hidden="true" で Tab 順と読み上げから外す。「シートを開く」を押すと半分まで開いてハンドルへフォーカスが移り、ハンドルは button なので Enter/Space で半分→全開→閉じるを巡回、↑↓で1段ずつ動かせる。aria-label には「現在: 半分」のように段名を含めて、今どの高さにいるかが読み上げで分かる。Escape と背景クリックで閉じたときは「シートを開く」へフォーカスを戻し、項目(button)を選んだときも同様に閉じて戻す。prefers-reduced-motion: reduce では吸い付きと背景のトランジションを止め、段の切り替えは瞬時に終状態へ移る。
調整して使う
触る値は SNAPS の位置とトランジションのイージングの2つが中心。デモは [全開0, 半分120, 閉じる240] だが、全開を使わず半分/閉じるの2段階だけにすればシンプルな開閉シートになる。cubic-bezier(0.32,0.72,0,1) は「勢いよく動いて最後で減速する」定番の動きの再現を狙ったもので、もっと控えめな印象にしたい画面では standard easing に寄せる方が馴染む。ハンドルの見た目(幅40px・高さ5px・角丸)はつまみやすさと視認性のバランスなので、タップ領域そのものは .bs-handle の padding で44px相当を確保しておくと指の大きさに対して現実的になる。背景の最大暗さ(openness*0.9)を1.0にすると全開時に完全な黒帯になり圧迫感が増すため、0.85〜0.9程度に抑えるのが無難。
コード(コピーして使えます)
<div class="bs-screen" id="bs-screen">
<div class="bs-content">
<button class="bs-open" id="bs-open" type="button" aria-haspopup="dialog" aria-expanded="false">シートを開く</button>
</div>
<div class="bs-backdrop" id="bs-backdrop"></div>
<div class="bs-sheet is-closed" id="bs-sheet" role="dialog" aria-modal="true" aria-labelledby="bs-title" aria-hidden="true" inert>
<button class="bs-handle" id="bs-handle" type="button" aria-label="シートの高さを切り替える(現在: 閉じる)"><span></span></button>
<div class="bs-inner">
<h3 id="bs-title">共有する</h3>
<ul class="bs-list">
<li><button type="button" class="bs-item"><span class="bs-ic" aria-hidden="true">🔗</span>リンクをコピー</button></li>
<li><button type="button" class="bs-item"><span class="bs-ic" aria-hidden="true">✉️</span>メールで送る</button></li>
<li><button type="button" class="bs-item"><span class="bs-ic" aria-hidden="true">💬</span>メッセージ</button></li>
<li><button type="button" class="bs-item"><span class="bs-ic" aria-hidden="true">⭐</span>お気に入りに追加</button></li>
<li><button type="button" class="bs-item"><span class="bs-ic" aria-hidden="true">⬇️</span>ダウンロード</button></li>
</ul>
</div>
</div>
</div>