カートに追加しました
このままお会計に進むか、買い物を続けられます。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約31 KB)。
# GlassBottomSheet をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: GlassBottomSheet
- 説明: GlassBottomTabBar と同じトンマナのモバイル用ボトムシートモーダル — すりガラスとパステルの発光、スプリングでせり上がり、下スワイプ・背景タップ・Escape で閉じる。フォーカストラップ付き。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/glass-bottom-sheet/index.tsx`, `components/glass-bottom-sheet/GlassBottomSheet.module.css`
## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。
## 手順
### 1. 依存パッケージを追加する
pnpm / yarn / bun を使っているプロジェクトでは、そのパッケージマネージャーのコマンドに置き換えてください。
```bash
npm install framer-motion lucide-react
```
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/glass-bottom-sheet/index.tsx`
```tsx
"use client";
import {
useEffect,
useId,
useRef,
useSyncExternalStore,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
AnimatePresence,
MotionConfig,
motion,
useDragControls,
type PanInfo,
} from "framer-motion";
import { X } from "lucide-react";
import styles from "./GlassBottomSheet.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
/** A footer button. The sheet closes after `onClick` unless `keepOpen`. */
export type GlassSheetAction = {
label: string;
onClick?: () => void;
keepOpen?: boolean;
};
export type GlassBottomSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
children?: ReactNode;
/** Red pill button (same red as the tab bar's CTA band). */
primaryAction?: GlassSheetAction;
/** Glass pill button under the primary one. */
secondaryAction?: GlassSheetAction;
/** Grabber at the top of the sheet. Dragging works with or without it. */
showHandle?: boolean;
/** Round glass × button in the header. */
showCloseButton?: boolean;
/**
* Allow closing by tapping the scrim, pressing Escape or swiping down.
* Off: only the close button and actions close the sheet.
*/
dismissible?: boolean;
/** Glow color A — pink light behind the sheet and its outer light. */
glowColorA?: string;
/** Glow color B — lavender light behind the sheet. */
glowColorB?: string;
/** Backdrop blur of the sheet's glass in px. */
blur?: number;
/** Spring stiffness of the slide in / snap back. */
springStiffness?: number;
/** Spring damping of the slide in / snap back. */
springDamping?: number;
/**
* "fixed" (default) covers the viewport, is portaled to <body> and locks
* page scroll. "absolute" fills the nearest positioned ancestor, renders
* in place and leaves scroll alone — for phone mockups and previews.
*/
placement?: "fixed" | "absolute";
className?: string;
};
const DEFAULT_GLOW_A = "#ffaac8";
const DEFAULT_GLOW_B = "#beafff";
const DEFAULT_BLUR = 30;
const DEFAULT_SPRING_STIFFNESS = 380;
const DEFAULT_SPRING_DAMPING = 34;
/** Swipe-down distance (px) or speed (px/s) that closes the sheet. */
const DISMISS_OFFSET = 110;
const DISMISS_VELOCITY = 600;
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/** Softer tint of a color for the derived glows: `amount` of color, rest white. */
const tint = (color: string, amount: number) =>
`color-mix(in srgb, ${color} ${amount}%, white)`;
const noopSubscribe = () => () => {};
/** false during SSR and hydration, true afterwards — gates the portal. */
function useIsClient() {
return useSyncExternalStore(
noopSubscribe,
() => true,
() => false,
);
}
/**
* Floating frosted-glass bottom sheet — the modal counterpart of
* GlassBottomTabBar. Slides up with a spring over a blurred scrim, closes
* on swipe-down / scrim tap / Escape, traps focus while open and hands it
* back to the opener on close.
*/
export function GlassBottomSheet({
open,
onOpenChange,
title,
description,
children,
primaryAction,
secondaryAction,
showHandle = true,
showCloseButton = true,
dismissible = true,
glowColorA,
glowColorB,
blur,
springStiffness = DEFAULT_SPRING_STIFFNESS,
springDamping = DEFAULT_SPRING_DAMPING,
placement = "fixed",
className,
}: GlassBottomSheetProps) {
const isClient = useIsClient();
const titleId = useId();
const descriptionId = useId();
const sheetRef = useRef<HTMLDivElement>(null);
const dragControls = useDragControls();
const fixed = placement === "fixed";
// Focus moves into the sheet on a closed → open change and back to the
// opener on close. A sheet mounted already open (e.g. a preview) leaves
// page focus alone. No effect cleanup: StrictMode's re-run would hand
// focus back to the opener right after moving it in.
const wasOpenRef = useRef(open);
const openerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const wasOpen = wasOpenRef.current;
wasOpenRef.current = open;
if (open && !wasOpen) {
const active = document.activeElement as HTMLElement | null;
openerRef.current =
active && !sheetRef.current?.contains(active) ? active : null;
sheetRef.current?.focus({ preventScroll: true });
} else if (!open && wasOpen) {
openerRef.current?.focus?.({ preventScroll: true });
openerRef.current = null;
}
}, [open]);
// Page scroll lock — only when the sheet actually covers the page.
useEffect(() => {
if (!open || !fixed) return;
const root = document.documentElement;
const previous = root.style.overflow;
root.style.overflow = "hidden";
return () => {
root.style.overflow = previous;
};
}, [open, fixed]);
const close = () => onOpenChange(false);
const requestDismiss = () => {
if (dismissible) close();
};
const runAction = (action: GlassSheetAction) => {
action.onClick?.();
if (!action.keepOpen) close();
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.stopPropagation();
requestDismiss();
return;
}
if (event.key !== "Tab" || !sheetRef.current) return;
// Keep Tab inside the dialog.
const items = Array.from(
sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE),
);
if (items.length === 0) {
event.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || active === sheetRef.current)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
const onDragEnd = (_: unknown, info: PanInfo) => {
if (info.offset.y > DISMISS_OFFSET || info.velocity.y > DISMISS_VELOCITY) {
close();
}
};
const spring = {
type: "spring",
stiffness: springStiffness,
damping: springDamping,
mass: 0.9,
} as const;
// The CSS module carries the defaults; custom properties are only set
// for non-default values so the default render matches the stylesheet.
const vars: CSSVars = {};
if (glowColorA && glowColorA.toLowerCase() !== DEFAULT_GLOW_A) {
vars["--gbs-glow-a"] = glowColorA;
vars["--gbs-glow-a-rim"] = glowColorA;
vars["--gbs-glow-a-soft"] = tint(glowColorA, 60);
vars["--gbs-glow-a-bottom"] = tint(glowColorA, 75);
}
if (glowColorB && glowColorB.toLowerCase() !== DEFAULT_GLOW_B) {
vars["--gbs-glow-b"] = glowColorB;
}
if (blur !== undefined && blur !== DEFAULT_BLUR) {
vars["--gbs-blur"] = `${blur}px`;
}
const content = (
// Follows the OS "reduce motion" setting: the sheet then fades in place.
<MotionConfig reducedMotion="user">
<AnimatePresence>
{open ? (
<div
key="sheet"
className={className ? `${styles.root} ${className}` : styles.root}
data-glass-bottom-sheet
data-placement={placement}
style={vars}
>
<motion.div
className={styles.scrim}
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
onClick={requestDismiss}
/>
<motion.div
ref={sheetRef}
className={styles.frame}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
onKeyDown={onKeyDown}
initial={{ y: "115%" }}
animate={{ y: 0 }}
exit={{ y: "115%" }}
transition={spring}
drag={dismissible ? "y" : false}
dragListener={false}
dragControls={dragControls}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0.04, bottom: 0.7 }}
onDragEnd={onDragEnd}
>
<div className={styles.sheet}>
{/* Drag starts from the header only, so the body can scroll. */}
<div
className={styles.header}
data-draggable={dismissible || undefined}
onPointerDown={(e) => dismissible && dragControls.start(e)}
>
{showHandle ? <span className={styles.handle} aria-hidden /> : null}
<div className={styles.heading}>
<h2 id={titleId} className={styles.title}>
{title}
</h2>
{description ? (
<p id={descriptionId} className={styles.description}>
{description}
</p>
) : null}
</div>
{showCloseButton ? (
<button
type="button"
className={styles.close}
aria-label="閉じる"
onPointerDown={(e) => e.stopPropagation()}
onClick={close}
>
<X size={18} strokeWidth={1.8} aria-hidden />
</button>
) : null}
</div>
{children ? <div className={styles.body}>{children}</div> : null}
{primaryAction || secondaryAction ? (
<div className={styles.actions}>
{primaryAction ? (
<button
type="button"
className={styles.primary}
onClick={() => runAction(primaryAction)}
>
{primaryAction.label}
</button>
) : null}
{secondaryAction ? (
<button
type="button"
className={styles.secondary}
onClick={() => runAction(secondaryAction)}
>
{secondaryAction.label}
</button>
) : null}
</div>
) : null}
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
</MotionConfig>
);
if (!fixed) return content;
// Portaled so a transformed / overflow:hidden ancestor can't trap the
// fixed overlay. Nothing renders until hydration is done.
return isClient ? createPortal(content, document.body) : null;
}
export default GlassBottomSheet;
```
#### `components/glass-bottom-sheet/GlassBottomSheet.module.css`
```css
/* ==========================================================================
Floating glass bottom sheet (modal) — GlassBottomTabBar's sibling
Same grammar as the tab bar: white frosted glass over a dark page + a
soft pastel (pink → lavender) glow. No hard 1px dark borders, no heavy
drop shadows — depth comes from light and layers
(scrim → glow → glass → lens buttons).
========================================================================== */
.root {
/* Spec colors. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--gbs-glow-a: #ffaac8; /* pink: back glow */
--gbs-glow-b: #beafff; /* lavender: back glow */
--gbs-glow-a-rim: #ffaacd; /* pink: outer light of the sheet */
--gbs-glow-a-soft: #ffc8dc; /* lighter pink: bottom glow */
--gbs-glow-a-bottom: #ffbed7; /* pink pooled along the bottom edge */
--gbs-blur: 30px;
position: fixed;
inset: 0;
z-index: 60; /* above GlassBottomTabBar (50) */
isolation: isolate;
display: flex;
flex-direction: column;
justify-content: flex-end;
font-family:
-apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
color: #fff;
}
/* Phone mockups / previews: fill a positioned ancestor. */
.root[data-placement="absolute"] {
position: absolute;
}
/* --- Layer 0: scrim. Dims and slightly blurs the page behind. --- */
.scrim {
position: absolute;
inset: 0;
background: rgba(12, 9, 20, 0.45);
/* Unprefixed only: the build adds -webkit-, and writing both makes
Lightning CSS drop the unprefixed one (no blur in Chrome). */
backdrop-filter: blur(4px);
-webkit-tap-highlight-color: transparent;
}
/* --- Frame: positions the sheet and carries the glow. Moved by framer-
motion (slide + drag), so the glow travels with the sheet. --- */
.frame {
position: relative;
/* Sized against the container, not the viewport, so the 10px gutters
also hold inside a phone mockup on a desktop page. */
width: calc(100% - 20px);
max-width: 480px;
max-height: calc(100% - 56px);
margin: 0 auto calc(10px + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
outline: none;
will-change: transform;
}
/* Layer 1: pastel light behind the sheet — two offset blurred ellipses. */
.frame::before {
content: "";
position: absolute;
z-index: -1;
inset: -24px -12px -28px;
border-radius: 48px;
pointer-events: none;
background:
radial-gradient(
60% 55% at 20% 85%,
color-mix(in srgb, var(--gbs-glow-a) 45%, transparent),
transparent 70%
),
radial-gradient(
55% 50% at 82% 15%,
color-mix(in srgb, var(--gbs-glow-b) 40%, transparent),
transparent 70%
),
radial-gradient(
70% 40% at 50% 100%,
color-mix(in srgb, var(--gbs-glow-a-soft) 25%, transparent),
transparent 70%
);
filter: blur(26px);
}
/* --- Layer 2: the sheet (white frosted glass) ---
Rim = inset white box-shadow, not a border. Slightly denser glass than
the tab bar since it carries body text. */
.sheet {
position: relative;
isolation: isolate;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 34px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.19) 0%,
rgba(255, 255, 255, 0.1) 100%
);
backdrop-filter: blur(var(--gbs-blur)) saturate(170%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.45),
inset 0 0 0 1px rgba(255, 255, 255, 0.2),
0 10px 40px color-mix(in srgb, var(--gbs-glow-a-rim) 16%, transparent),
0 24px 60px rgba(0, 0, 0, 0.36);
}
/* Reflection on the upper part */
.sheet::before {
content: "";
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0.05) 22%,
rgba(255, 255, 255, 0) 40%
);
}
/* A little pink pooled along the bottom edge */
.sheet::after {
content: "";
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--gbs-glow-a-bottom) 0%, transparent) 60%,
color-mix(in srgb, var(--gbs-glow-a-bottom) 12%, transparent) 100%
);
}
/* Without backdrop-filter, trade transparency for legible text */
@supports not (backdrop-filter: blur(1px)) {
.sheet {
background: linear-gradient(
180deg,
rgba(70, 60, 78, 0.96) 0%,
rgba(40, 34, 48, 0.96) 100%
);
}
}
/* --- Header: grabber + title + close. The drag handle area. --- */
.header {
position: relative;
flex: none;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 22px 18px 14px 22px;
user-select: none;
}
.header[data-draggable] {
touch-action: none; /* let the swipe drive the sheet, not the page */
cursor: grab;
}
.handle {
position: absolute;
top: 8px;
left: 50%;
width: 38px;
height: 5px;
margin-left: -19px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.38);
}
.heading {
flex: 1;
min-width: 0;
padding-top: 4px;
}
.title {
margin: 0;
font-size: 19px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
}
.description {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.55;
color: rgba(255, 255, 255, 0.66);
}
/* Close = a small lens, same recipe as the tab bar's active pill */
.close {
flex: none;
display: grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 9999px;
color: rgba(255, 255, 255, 0.85);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.05) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
inset 0 0 0 1px rgba(255, 255, 255, 0.3);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition:
transform 0.12s ease,
background-color 0.2s ease;
}
/* --- Body: scrolls on its own when the content is tall. --- */
.body {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 2px 22px 8px;
font-size: 15px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.86);
}
/* --- Actions --- */
.actions {
flex: none;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px 16px;
}
.primary,
.secondary {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
padding: 0 20px;
border: 0;
border-radius: 9999px;
font: inherit;
font-size: 17px;
font-weight: 500;
color: #fff;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition: transform 0.12s ease;
}
/* Same red as the tab bar's CTA band, with a soft top highlight */
.primary {
background: #e5322d;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 8px 24px rgba(229, 50, 45, 0.32);
}
/* Glass lens button */
.secondary {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.04) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
inset 0 0 0 1px rgba(255, 255, 255, 0.34),
0 4px 16px color-mix(in srgb, var(--gbs-glow-a-rim) 16%, transparent);
}
.close:active,
.primary:active,
.secondary:active {
transform: scale(0.96);
}
.close:focus-visible,
.primary:focus-visible,
.secondary:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: 2px;
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/glass-bottom-sheet.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { GlassBottomSheet } from "@/components/glass-bottom-sheet";
import { useState } from "react";
// TODO: "use client" のコンポーネント内で const [open, setOpen] = useState(false); を用意し、開くボタンの onClick で setOpen(true) してください
// TODO: primaryAction / secondaryAction に onClick を渡して処理をつなぎ、本文があれば children として渡してください
<GlassBottomSheet
title="カートに追加しました"
description="このままお会計に進むか、買い物を続けられます。"
showHandle={true}
showCloseButton={true}
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
dismissible={true}
springStiffness={380}
springDamping={34}
open={open}
onOpenChange={setOpen}
primaryAction={{ label: "お会計に進む" }}
secondaryAction={{ label: "買い物を続ける" }}
/>
```
### 4. 組み込み手順
1. シートを開くコンポーネント(`"use client"`)で `const [open, setOpen] = useState(false)` を持ち、`<GlassBottomSheet open={open} onOpenChange={setOpen} title="…" />` を置く。開くボタンの `onClick` で `setOpen(true)`。
2. `app/layout.tsx` で viewport に `viewportFit: "cover"` を指定する(GlassBottomTabBar を入れていれば設定済み)。これが無いと `env(safe-area-inset-bottom)` が 0 になり、iPhone のホームバーにシートの下端が重なる。
3. 既定(`placement="fixed"`)では `<body>` へポータルし、開いている間はページのスクロールを止める。親に `transform` や `overflow: hidden` があっても影響しない。
4. `primaryAction` / `secondaryAction` の `onClick` に処理をつなぐ。押すと処理のあと自動で閉じる。閉じたくない場合(送信中など)は `keepOpen: true`。
5. 本文は `children` に渡す。長い本文は本文エリアだけがスクロールし、ヘッダー(グラバー・タイトル)とボタンは固定される。
6. 「必ず選ばせたい」確認には `dismissible={false}`。下スワイプ・背景タップ・Escape が無効になり、× ボタンとアクションでのみ閉じる(× も消すなら `showCloseButton={false}`)。
7. GlassBottomTabBar と併用する場合、シートは `z-index: 60` でバー(50)より上に出る。追加の設定は不要。
8. `npm run build` が通ることを確認し、実機(iPhone)で「ホームバー領域と重ならない」「下スワイプで閉じる」「背後がぼけて透ける」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| Chrome でぼかしが一切効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| 本文をスクロールするとシートごと動く | ドラッグを本文でも拾っている | ドラッグはヘッダーの `onPointerDown` → `dragControls.start()` からだけ開始(`dragListener={false}`) |
| 閉じるアニメーションが出ずに消える | `AnimatePresence` の外で条件分岐している | `open` の分岐は `AnimatePresence` の内側で行う。コンポーネント自体は常にマウントしておく |
| SSR でハイドレーションエラー | サーバーで `document.body` にポータルしようとしている | 同梱の `useSyncExternalStore` によるクライアント判定を外さない |
| 視差効果オフでもスライドする | framer-motion は JS で動くので CSS の reduced-motion が効かない | `<MotionConfig reducedMotion="user">` を外さない |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)、`lucide-react`
- ファイル: `components/glass-bottom-sheet/index.tsx`(`"use client"`)+ `GlassBottomSheet.module.css`
- 制御コンポーネント: `open` / `onOpenChange(open)`、`title`(必須)、`description`、`children`、`primaryAction` / `secondaryAction`(`{ label, onClick?, keepOpen? }`)
- ルート: `position: fixed; inset: 0; z-index: 60`、`display: flex; flex-direction: column; justify-content: flex-end`。`createPortal(…, document.body)`、SSR 中は描画しない
- 開いている間 `document.documentElement.style.overflow = "hidden"`、閉じたら元に戻す
### 見た目 = GlassBottomTabBar と同じグラスモーフィズム
層構成は「スクリム → 発光 → ガラス → レンズ(ボタン)」。硬い 1px の黒縁・濃い落ち影は使わない。
- **層 0 スクリム**: `rgba(12,9,20,.45)` + `backdrop-filter: blur(4px)`、opacity 0 → 1、250ms easeOut。タップで閉じる
- **フレーム(.frame)**: `width: calc(100% - 20px)`(左右 10px)・下 `calc(10px + env(safe-area-inset-bottom))` の浮遊配置、`max-width: 480px` で中央寄せ(ビューポートではなく親基準なのでモックアップ内でも余白が保たれる)、`max-height: calc(100% - 56px)`。framer-motion でスライドとドラッグを担当し、発光ごと動く
- **層 1 発光(.frame::before)**: `inset: -24px -12px -28px`、ピンク `rgba(255,170,200,.45)`(`60% 55% at 20% 85%`)とラベンダー `rgba(190,175,255,.40)`(`55% 50% at 82% 15%`)、下辺に `rgba(255,200,220,.25)`(`70% 40% at 50% 100%`)。`filter: blur(26px)`、`z-index: -1`
- **層 2 シート(.sheet)**: `border-radius: 34px`、`overflow: hidden`、`isolation: isolate`、縦 flex
- background: `linear-gradient(180deg, rgba(255,255,255,.19), rgba(255,255,255,.10))`
- `backdrop-filter: blur(30px) saturate(170%)`(unprefixed のみ)
- box-shadow: `inset 0 1px 0 白 .45` / `inset 0 0 0 1px 白 .20` / `0 10px 40px rgba(255,170,205,.16)` / `0 24px 60px rgba(0,0,0,.36)`
- `::before` 上部の反射(白 .20 → .05 at 22% → 0 at 40%)、`::after` 下辺にピンク `rgba(255,190,215,.12)`(60% から)
- `@supports not (backdrop-filter: blur(1px))` では `rgba(70,60,78,.96) → rgba(40,34,48,.96)`
- **ヘッダー**: padding `22px 18px 14px 22px`。上中央にグラバー(38×5px、白 .38、top 8px)。タイトル 19px / 600、説明 13px / 行間 1.55 / 白 .66。右に × ボタン(32px 円、lucide `X` 18px、タブバーのアクティブピルと同じレンズ: 白 .12 → .05 + `inset 0 1px 0 白 .5` / `inset 0 0 0 1px 白 .3`)
- **本文**: `flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain`、15px / 行間 1.6 / 白 .86、padding `2px 22px 8px`
- **アクション**: 縦並び gap 10px、padding `14px 16px 16px`。高さ 50px のピル、17px / 500
- メイン: `#e5322d`(タブバーの CTA と同じ赤)+ `inset 0 1px 0 白 .28` / `0 8px 24px rgba(229,50,45,.32)`
- サブ: ガラスのレンズ(白 .10 → .04、`inset 0 1px 0 白 .5` / `inset 0 0 0 1px 白 .34` / `0 4px 16px rgba(255,170,205,.16)`)
- 押下時 `scale(.96)` 120ms、`:focus-visible` は白 2px アウトライン
- フォント: `-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto, sans-serif`
### モーション
- `AnimatePresence` で開閉。シートは `y: "115%" → 0`、スプリング `stiffness: 380, damping: 34, mass: 0.9`
- 下スワイプで閉じる: `drag="y"`、`dragConstraints={{ top: 0, bottom: 0 }}`、`dragElastic={{ top: .04, bottom: .7 }}`。`offset.y > 110px` または `velocity.y > 600px/s` で閉じ、未満はスプリングで戻る
- ドラッグはヘッダーからのみ開始(`useDragControls` + `dragListener={false}`、ヘッダーに `touch-action: none`)。× ボタンは `pointerdown` の伝播を止める
- `<MotionConfig reducedMotion="user">` で包む
### アクセシビリティ
- `role="dialog" aria-modal="true" aria-labelledby={title の id} aria-describedby={説明の id}`、`tabIndex={-1}`
- 閉 → 開 の変化でシートにフォーカスし、閉じたら開く前のフォーカス要素に戻す(最初から open でマウントされた場合はフォーカスを動かさない)。Tab / Shift+Tab はシート内で循環
- Escape で閉じる(`dismissible={false}` では無効)。× は `aria-label="閉じる"`
### 受け入れ条件
- ボタンでシートがせり上がり、背景がぼけて暗くなる。シート越しに背後の色がガラスに乗る
- 下スワイプ・背景タップ・Escape・× で閉じ、閉じるアニメーションが出る
- 長い本文は本文だけスクロールし、ページはスクロールしない
- iPhone のホームバー領域と重ならないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/glass-bottom-sheet.json生成コード
import { GlassBottomSheet } from "@/components/glass-bottom-sheet";
import { useState } from "react";
// TODO: "use client" のコンポーネント内で const [open, setOpen] = useState(false); を用意し、開くボタンの onClick で setOpen(true) してください
// TODO: primaryAction / secondaryAction に onClick を渡して処理をつなぎ、本文があれば children として渡してください
<GlassBottomSheet
title="カートに追加しました"
description="このままお会計に進むか、買い物を続けられます。"
showHandle={true}
showCloseButton={true}
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
dismissible={true}
springStiffness={380}
springDamping={34}
open={open}
onOpenChange={setOpen}
primaryAction={{ label: "お会計に進む" }}
secondaryAction={{ label: "買い物を続ける" }}
/>