BLK-103Blocks
ボトムシート(つまみで半分・全画面・閉じるの3段に切り替え)
スマホで詳細を見せるときに、画面を切り替えずに下から重ねるボトムシート(ハーフモーダル)です。上端のつまみをドラッグすると、半分・全画面・閉じるの3段のうち近い段に吸着します。軽く弾けば次の段へ進みます。段ごとに振る舞いが変わり、半分の高さでは背面を操作できるので、見本では項目を選び直すとシートの合計がすぐに変わります。全画面では背面を inert にし、フォーカスをシートの中に留めます。開くとシート内の最初の要素へ、閉じると開いたボタンへフォーカスが戻ります。ドラッグしなくても「広げる」「縮める」ボタンで高さを変えられ、つまみにフォーカスして上下キーでも段を変えられます。Esc ではどの段からでも閉じます。動きは transform と opacity だけで、prefers-reduced-motion では段の位置へ置き直すだけになります。角丸のない hairline 罫線でまとめ、新規の依存はありません。金額は架空です。
- 追加日:
- 2026-10-22
- 依存:
- なし
- tags
- #block #bottom-sheet #half-modal #drawer #mobile #drag #snap-points #dialog #inert #focus-trap #slider #a11y #hairline #no-image #no-dependency
プレビュー
つまみで高さを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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/bottom-sheet-drawer.json