Bottom Sheet — Drag Handle with Half, Full and Closed Snap Points
A bottom sheet (half-modal) that slides up over the screen to show details on mobile without navigating away. Drag the handle at the top and the sheet snaps to the nearest of three points: half height, full screen, or closed; a quick flick carries it on to the next point. Behavior changes with the snap point. At half height the content behind stays usable, so in this demo changing the selected options updates the sheet's total immediately. At full screen the background becomes inert and focus stays inside the sheet. Opening moves focus to the first element in the sheet, and closing returns it to the button that opened it. Expand and Shrink buttons change the height without dragging, the focused handle responds to the up and down arrow keys, and Esc closes the sheet from any point. Motion uses only transform and opacity; under prefers-reduced-motion the sheet simply jumps to each point. Grouping uses square hairline rules, with no new dependencies. All prices are fictional.
- Added:
- 2026-10-22
- Dependencies:
- None
- tags
- #block #bottom-sheet #half-modal #drawer #mobile #drag #snap-points #dialog #inert #focus-trap #slider #a11y #hairline #no-image #no-dependency
Preview
つまみで高さを3段に変えるボトムシート
スマホで詳細を見せるとき、画面を切り替えずに下から重ねるシートです。つまみを上下に動かすと、半分・全画面・閉じるのいずれかに吸着します。半分の高さでは背面の項目を選び直せるので、選びながら合計を確かめられます。
- 全画面
- 背面を操作できなくし、フォーカスをシートの中に留めます。内訳と前提を最後まで読み、相談へ進む段です。
- 半分の高さ表示中
- 背面はそのまま操作できます。項目を付け外しすると、シートの合計がすぐに変わります。
- 閉じた状態
- シートを下へ隠し、フォーカスを「内訳を見る」ボタンへ戻します。
キーボードでの操作
- ↑↓
- つまみにフォーカスして、1段ずつ広げる・縮める
- HomeEnd
- つまみから全画面へ・閉じる
- Esc
- どの段からでも閉じる
- Tab
- 全画面の段ではシートの中だけを巡回する
ホームページ制作の見積もり
¥220,000税抜
見積もりの内訳
税込の合計¥242,000
| トップページの制作 | ¥180,000 |
|---|---|
| お問い合わせフォーム | ¥40,000 |
| 消費税(10%) | ¥22,000 |
| 合計 | ¥242,000 |
この金額の前提
- 原稿と写真はご用意いただきます
- デザインの修正は2回まで含みます
- 公開までの目安は着手から6週間です
金額はすべて架空の見本です。
"use client";
import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
/**
* ボトムシート(つまみで半分・全画面・閉じるの3段に吸着)
*
* 差別化: 既存のコマンドパレットは中央に出る全面モーダル、同意バナーと取り消し通知は高さの変わらない下部の帯。
* 本ブロックは高さを3段で切り替え、段ごとにモーダルかどうかが変わる(半分の段では背面の選択を続けられ、合計がその場で変わる)。
* 英字の小見出し・句点で終わる見出し・末尾の矢印リンク・角丸・スクロールでの出現アニメは使わない。
*
* 汎用技法メモ:
* 1. **高さは変えず translateY だけで段を表す**。シートの高さは「全画面」の段で固定し、半分は (シート高 − 見せたい高さ)、
* 閉じるはシート高ぶん下げる。ドラッグ中も transform だけを書き換えるのでレイアウトが起きない。
* 2. **離したときの段は「位置+速さ×120ms」の予測点に最も近い段**。軽く弾くだけで次の段へ進み、ゆっくり離すと近い段へ戻る。
* ポインタの移動量は「見た目の高さ ÷ offsetHeight」で割って戻す(一覧カードのように縮小表示されていても指に付いてくる)。
* 3. **段ごとにモーダル性を切り替える**。半分=aria-modal="false" で背面は操作可。全画面=aria-modal="true"・背面に inert・
* 幕を出し、Tab をシート内で折り返す。本番では fixed で画面下に出し、inert はページ本体(main 等)へ付ける。
* 4. **つまみは role="slider"(縦向き・0〜2)**。↑で広げ、↓で縮め、Home で全画面、End で閉じる。aria-valuetext で段の名前を読ませる。
* ドラッグできない人向けに「広げる」「縮める」ボタンも置く(押せない段では aria-disabled にし、フォーカスを失わせない)。
* 5. 開いたらシート内の最初の要素(つまみ)へ、閉じたら開いたボタンへフォーカスを戻す。Esc はどの段でも閉じる。
* 6. 動きは transform と opacity だけ。prefers-reduced-motion では遷移を切り、段の位置へ置き直すだけで内容はすべて読める。
*
* 文言・金額・配色はすべて First CH のオリジナル(金額は架空)。
*/
type Snap = "full" | "half" | "closed";
const FRAME_H = 600; // 見本の画面の高さ(h-[600px] と揃える)
const TOP_GAP = 44; // 全画面の段でも上に残す余白
const SHEET_H = FRAME_H - TOP_GAP;
const HALF_VISIBLE = 300;
const Y: Record<Snap, number> = { full: 0, half: SHEET_H - HALF_VISIBLE, closed: SHEET_H + 1 };
const ORDER: Snap[] = ["closed", "half", "full"];
const SNAP_LABEL: Record<Snap, string> = { full: "全画面", half: "半分の高さ", closed: "閉じた状態" };
type Option = { id: string; label: string; note: string; price: number };
const BASE = { label: "トップページの制作", price: 180000 };
const OPTIONS: Option[] = [
{ id: "pages", label: "下層ページを5ページ追加", note: "会社概要・サービス紹介など", price: 100000 },
{ id: "form", label: "お問い合わせフォーム", note: "自動返信メールつき", price: 40000 },
{ id: "english", label: "英語ページ", note: "翻訳済みの原稿をいただく前提", price: 80000 },
{ id: "care", label: "公開後の保守(12か月)", note: "更新作業は月2回まで", price: 60000 },
];
const yen = (n: number) => `¥${n.toLocaleString("ja-JP")}`;
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])';
export default function BottomSheetDrawer() {
const headingId = useId();
const sheetTitleId = useId();
const sheetId = useId();
const [snap, setSnap] = useState<Snap>("half");
const [picked, setPicked] = useState<Record<string, boolean>>({ form: true });
const [dragY, setDragY] = useState<number | null>(null);
const [notice, setNotice] = useState("");
const [sent, setSent] = useState(false);
const openerRef = useRef<HTMLButtonElement>(null);
const sheetRef = useRef<HTMLDivElement>(null);
const gripRef = useRef<HTMLDivElement>(null);
const drag = useRef<{ startY: number; base: number; k: number; lastY: number; lastT: number; v: number } | null>(null);
// 直前の操作でどこへフォーカスを移すか(開いた直後=つまみ、閉じた直後=開いたボタン)
const pendingFocus = useRef<"grip" | "opener" | null>(null);
const subtotal = BASE.price + OPTIONS.reduce((s, o) => s + (picked[o.id] ? o.price : 0), 0);
const tax = Math.round(subtotal * 0.1);
const open = snap !== "closed";
const modal = snap === "full";
const moveTo = useCallback((next: Snap) => {
setSnap((prev) => {
if (prev === next) return prev;
if (prev === "closed") pendingFocus.current = "grip";
if (next === "closed") pendingFocus.current = "opener";
return next;
});
setNotice(
next === "full"
? "シートを全画面に広げました。背面の画面は操作できません。"
: next === "half"
? "シートを半分の高さにしました。背面の選択も続けられます。"
: "シートを閉じました。",
);
}, []);
// inert が外れた後でないとフォーカスできないので、描画の次のフレームで移す
useEffect(() => {
const target = pendingFocus.current;
if (!target) return;
pendingFocus.current = null;
const id = requestAnimationFrame(() => {
(target === "grip" ? gripRef.current : openerRef.current)?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(id);
}, [snap]);
const step = (dir: 1 | -1) => {
const i = ORDER.indexOf(snap) + dir;
if (i < 0 || i >= ORDER.length) return;
moveTo(ORDER[i]);
};
// ---- ドラッグ -----------------------------------------------------------------------
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
const sheet = sheetRef.current;
const k = sheet && sheet.offsetHeight ? sheet.getBoundingClientRect().height / sheet.offsetHeight || 1 : 1;
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { startY: e.clientY, base: Y[snap], k, lastY: e.clientY, lastT: e.timeStamp, v: 0 };
setDragY(Y[snap]);
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d) return;
const dt = e.timeStamp - d.lastT;
if (dt > 0) d.v = (e.clientY - d.lastY) / d.k / dt; // px/ms(下向きが正)
d.lastY = e.clientY;
d.lastT = e.timeStamp;
const y = d.base + (e.clientY - d.startY) / d.k;
setDragY(Math.min(Y.closed, Math.max(Y.full, y)));
};
const endDrag = (e: PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d) return;
drag.current = null;
const y = d.base + (e.clientY - d.startY) / d.k;
const moved = Math.abs(e.clientY - d.startY) / d.k > 4;
setDragY(null);
if (!moved) return;
const projected = y + d.v * 120;
let best: Snap = "half";
for (const s of ORDER) if (Math.abs(Y[s] - projected) < Math.abs(Y[best] - projected)) best = s;
moveTo(best);
};
// ---- キーボード ---------------------------------------------------------------------
const onGripKey = (e: KeyboardEvent<HTMLDivElement>) => {
const map: Record<string, () => void> = {
ArrowUp: () => step(1),
ArrowRight: () => step(1),
ArrowDown: () => step(-1),
ArrowLeft: () => step(-1),
Home: () => moveTo("full"),
End: () => moveTo("closed"),
};
const fn = map[e.key];
if (!fn) return;
e.preventDefault();
fn();
};
const onSheetKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
moveTo("closed");
return;
}
// 全画面の段だけ Tab をシート内で折り返す
if (e.key !== "Tab" || !modal || !sheetRef.current) return;
const items = Array.from(sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
const y = dragY ?? Y[snap];
// 幕の濃さは半分→全画面の間で 0→1
const scrim = Math.min(1, Math.max(0, 1 - y / Y.half));
const actionBtn =
"inline-flex items-center gap-1.5 border border-stone-300 bg-white px-3 py-1.5 text-[12px] font-bold text-stone-800 transition-colors hover:border-stone-900 aria-disabled:cursor-not-allowed aria-disabled:opacity-40 aria-disabled:hover:border-stone-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none";
return (
<section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-stone-900">
<div className="grid gap-x-14 gap-y-10 @2xl:grid-cols-[20rem_minmax(0,1fr)] @4xl:grid-cols-[22rem_minmax(0,1fr)]">
{/* 説明 */}
<div className="min-w-0">
<h2 id={headingId} className="text-[clamp(1.45rem,3.2vw,1.95rem)] leading-[1.4] font-bold tracking-tight">
つまみで高さを3段に変える
<span className="whitespace-nowrap">ボトムシート</span>
</h2>
<p className="mt-4 max-w-xl text-[14px] leading-[1.9] text-stone-600">
スマホで詳細を見せるとき、画面を切り替えずに下から重ねるシートです。つまみを上下に動かすと、半分・全画面・閉じるのいずれかに吸着します。半分の高さでは背面の項目を選び直せるので、選びながら合計を確かめられます。
</p>
<dl className="mt-8 border-t border-stone-900">
{(
[
["full", "全画面", "背面を操作できなくし、フォーカスをシートの中に留めます。内訳と前提を最後まで読み、相談へ進む段です。"],
["half", "半分の高さ", "背面はそのまま操作できます。項目を付け外しすると、シートの合計がすぐに変わります。"],
["closed", "閉じた状態", "シートを下へ隠し、フォーカスを「内訳を見る」ボタンへ戻します。"],
] as const
).map(([key, label, body]) => {
const active = snap === key;
return (
<div
key={key}
className={`grid grid-cols-[6.5rem_minmax(0,1fr)] gap-4 border-b border-stone-200 py-4 pl-3 ${active ? "border-l-2 border-l-amber-600 bg-amber-50/60" : "border-l-2 border-l-transparent"}`}
>
<dt className="text-[13px] font-bold">
{label}
{active && <span className="mt-1 block text-[11px] font-bold text-amber-700">表示中</span>}
</dt>
<dd className="text-[13px] leading-[1.85] text-stone-600">{body}</dd>
</div>
);
})}
</dl>
<h3 className="mt-8 text-[13px] font-bold">キーボードでの操作</h3>
<dl className="mt-3 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-[13px] leading-[1.7] text-stone-600">
{(
[
["↑ ↓", "つまみにフォーカスして、1段ずつ広げる・縮める"],
["Home End", "つまみから全画面へ・閉じる"],
["Esc", "どの段からでも閉じる"],
["Tab", "全画面の段ではシートの中だけを巡回する"],
] as const
).map(([k, d]) => (
<div key={k} className="contents">
<dt className="flex items-start gap-1">
{k.split(" ").map((key) => (
<kbd key={key} className="border border-stone-300 px-1.5 font-sans text-[11px] leading-[1.7] font-bold text-stone-800">
{key}
</kbd>
))}
</dt>
<dd>{d}</dd>
</div>
))}
</dl>
<p role="status" aria-live="polite" className="sr-only">
{notice}
</p>
</div>
{/* 見本の画面(スマホ幅) */}
<div
className="relative mx-auto h-[600px] w-full max-w-[22rem] overflow-hidden border border-stone-300 bg-white @2xl:order-first"
>
<div inert={modal} className="flex h-full flex-col">
<div className="flex items-center justify-between gap-3 border-b border-stone-200 px-4 py-3">
<div>
<p className="text-[11px] font-bold text-stone-500">ホームページ制作の見積もり</p>
<p className="font-display text-[18px] leading-tight font-bold tabular-nums">
{yen(subtotal)}
<span className="ml-1 font-sans text-[11px] font-medium text-stone-500">税抜</span>
</p>
</div>
<button
ref={openerRef}
type="button"
aria-expanded={open}
aria-controls={sheetId}
onClick={() => (open ? moveTo("closed") : moveTo("half"))}
className="border border-stone-900 bg-stone-900 px-3 py-2 text-[12px] font-bold text-white transition-colors hover:bg-amber-700 hover:border-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
{open ? "内訳を閉じる" : "内訳を見る"}
</button>
</div>
{/* 半分の段のシートに隠れないよう、下に余白を足して背面だけをスクロールさせる */}
<div className="min-h-0 flex-1 scroll-pb-[316px] overflow-y-auto px-4 pt-4 pb-[316px]">
<fieldset>
<legend className="text-[13px] font-bold">追加する項目を選ぶ</legend>
<p className="mt-1 text-[11px] leading-[1.7] text-stone-500">基本:{BASE.label} {yen(BASE.price)}</p>
<ul className="mt-2 divide-y divide-stone-200 border-y border-stone-200">
{OPTIONS.map((o) => (
<li key={o.id}>
<label className="flex cursor-pointer items-start gap-3 py-2.5">
<input
type="checkbox"
checked={!!picked[o.id]}
onChange={(e) => {
setPicked((p) => ({ ...p, [o.id]: e.target.checked }));
setSent(false);
}}
className="mt-0.5 size-4 shrink-0 accent-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
/>
<span className="min-w-0 flex-1">
<span className="block text-[13px] leading-snug font-bold">{o.label}</span>
<span className="block text-[11px] text-stone-500">{o.note}</span>
</span>
<span className="font-display text-[12px] font-semibold tabular-nums text-stone-700">+{yen(o.price)}</span>
</label>
</li>
))}
</ul>
</fieldset>
</div>
</div>
{/* 全画面の段の幕。押すと半分の高さへ戻す */}
<div
aria-hidden="true"
onClick={() => moveTo("half")}
className={`absolute inset-0 bg-stone-900/30 ${dragY === null ? "transition-opacity duration-300 motion-reduce:transition-none" : ""} ${modal ? "" : "pointer-events-none"}`}
style={{ opacity: open ? scrim : 0 }}
/>
{/* シート本体 */}
<div
ref={sheetRef}
id={sheetId}
role="dialog"
aria-modal={modal}
aria-labelledby={sheetTitleId}
inert={!open}
onKeyDown={onSheetKey}
className={`absolute inset-x-0 bottom-0 flex flex-col border-t border-stone-300 bg-white shadow-[0_-12px_32px_-18px_rgba(28,25,23,0.35)] ${
dragY === null ? "transition-transform duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none" : ""
}`}
style={{ height: SHEET_H, transform: `translateY(${y}px)` }}
>
{/* つまみ(ドラッグ・上下キー) */}
<div
ref={gripRef}
role="slider"
tabIndex={0}
aria-orientation="vertical"
aria-label="シートの高さ"
aria-valuemin={0}
aria-valuemax={2}
aria-valuenow={ORDER.indexOf(snap)}
aria-valuetext={SNAP_LABEL[snap]}
onKeyDown={onGripKey}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className="group flex h-7 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600"
>
<span aria-hidden="true" className="h-1 w-10 bg-stone-300 transition-colors group-hover:bg-amber-600 group-focus-visible:bg-amber-600 motion-reduce:transition-none" />
</div>
<div className="shrink-0 border-b border-stone-200 px-4 pb-3">
<div className="flex items-end justify-between gap-3">
<h3 id={sheetTitleId} className="text-[15px] font-bold">
見積もりの内訳
</h3>
<p className="text-right">
<span className="block text-[10px] text-stone-500">税込の合計</span>
<span className="font-display text-[22px] leading-none font-bold tabular-nums text-amber-700">{yen(subtotal + tax)}</span>
</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" aria-disabled={snap === "full"} onClick={() => snap !== "full" && step(1)} className={actionBtn}>
<svg aria-hidden="true" viewBox="0 0 12 12" className="size-3"><path d="M2 8l4-4 4 4" fill="none" stroke="currentColor" strokeWidth="1.6" /></svg>
広げる
</button>
<button type="button" onClick={() => step(-1)} className={actionBtn}>
<svg aria-hidden="true" viewBox="0 0 12 12" className="size-3"><path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" /></svg>
縮める
</button>
<button type="button" onClick={() => moveTo("closed")} className={`${actionBtn} ml-auto`}>
閉じる
</button>
</div>
</div>
{/* 半分の段では見えている高さまでで止め、中だけをスクロールさせる */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4" style={{ marginBottom: snap === "half" ? Y.half : 0 }}>
<table className="w-full text-[13px]">
<caption className="sr-only">選んだ項目と金額</caption>
<tbody className="divide-y divide-stone-200">
<tr>
<th scope="row" className="py-2 pr-3 text-left font-medium">{BASE.label}</th>
<td className="py-2 text-right font-display tabular-nums">{yen(BASE.price)}</td>
</tr>
{OPTIONS.filter((o) => picked[o.id]).map((o) => (
<tr key={o.id}>
<th scope="row" className="py-2 pr-3 text-left font-medium">{o.label}</th>
<td className="py-2 text-right font-display tabular-nums">{yen(o.price)}</td>
</tr>
))}
<tr className="text-stone-500">
<th scope="row" className="py-2 pr-3 text-left font-medium">消費税(10%)</th>
<td className="py-2 text-right font-display tabular-nums">{yen(tax)}</td>
</tr>
<tr className="border-t border-stone-900">
<th scope="row" className="py-2.5 pr-3 text-left font-bold">合計</th>
<td className="py-2.5 text-right font-display font-bold tabular-nums">{yen(subtotal + tax)}</td>
</tr>
</tbody>
</table>
<h4 className="mt-6 text-[13px] font-bold">この金額の前提</h4>
<ul className="mt-2 space-y-1.5 border-l-2 border-amber-600 pl-3 text-[12px] leading-[1.8] text-stone-600">
<li>原稿と写真はご用意いただきます</li>
<li>デザインの修正は2回まで含みます</li>
<li>公開までの目安は着手から6週間です</li>
</ul>
<button
type="button"
onClick={() => setSent(true)}
className="mt-6 w-full border border-amber-700 bg-amber-600 py-3 text-[13px] font-bold text-white transition-colors hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
この内容で相談する
</button>
<p role="status" className="mt-2 min-h-[1.5em] text-[11px] text-stone-500">
{sent ? "見本のため送信はしません。" : ""}
</p>
<p className="text-[11px] text-stone-400">金額はすべて架空の見本です。</p>
</div>
</div>
</div>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/bottom-sheet-drawer.json