mio2分前
オーバーレイ
GlassFabMenu
Motion
GlassBottomTabBar と同じトンマナの浮遊ガラス FAB メニュー — + が × に回転し、背景を暗くぼかしてアクションを縦積みのピルか扇形にスプリングで展開。矢印キー・Escape 対応の menu ボタン。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約38 KB)。
# GlassFabMenu をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: GlassFabMenu
- 説明: GlassBottomTabBar と同じトンマナの浮遊ガラス FAB メニュー — + が × に回転し、背景を暗くぼかしてアクションを縦積みのピルか扇形にスプリングで展開。矢印キー・Escape 対応の menu ボタン。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/glass-fab-menu/index.tsx`, `components/glass-fab-menu/GlassFabMenu.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-fab-menu/index.tsx`
```tsx
"use client";
import {
useEffect,
useEffectEvent,
useId,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
import { Plus, type LucideIcon } from "lucide-react";
import styles from "./GlassFabMenu.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
/** One menu entry. The menu closes after `onSelect`. */
export type GlassFabAction = {
id: string;
label: string;
icon: LucideIcon;
onSelect?: () => void;
};
export type GlassFabMenuProps = {
actions: GlassFabAction[];
/** Controlled open state. Leave undefined to let the menu own it. */
open?: boolean;
/** Initial open state when uncontrolled. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/**
* "stack": glass pills (icon + label) stacked above the button.
* "radial": round glass buttons fanned out around it, labels underneath.
*/
layout?: "stack" | "radial";
/** "glass" frosted button, or "red" (#e5322d) for a primary create action. */
accent?: "glass" | "red";
position?: "bottom-right" | "bottom-left" | "bottom-center";
/**
* "fixed" (default) pins to the viewport corner (safe-area aware) and is
* portaled to <body>. "absolute" pins to the nearest positioned
* ancestor and renders in place — for phone mockups and previews.
*/
placement?: "fixed" | "absolute";
/** Glow color A — pink light behind the button and the outer light of the glass. */
glowColorA?: string;
/** Glow color B — lavender light behind the button. */
glowColorB?: string;
/** Backdrop blur of the glass in px. */
blur?: number;
/** Spring stiffness of the expand / icon rotation. */
springStiffness?: number;
/** Spring damping of the expand / icon rotation. */
springDamping?: number;
/** Accessible name of the main button. */
"aria-label"?: string;
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 = 30;
/** Seconds between neighbouring items as they spring out. */
const STAGGER = 0.04;
/* Radial geometry. Corners fan over a quarter circle; the centered button
has room on both sides, so it fans over a wider arc above itself. The
radius grows with the item count so neighbours (and their labels) keep
at least RADIAL_GAP px between centers. */
const RADIAL_MIN_RADIUS = 100;
const RADIAL_GAP = 66;
const RADIAL_ARCS: Record<
NonNullable<GlassFabMenuProps["position"]>,
[from: number, to: number]
> = {
"bottom-right": [90, 180], // straight up → left
"bottom-left": [90, 0], // straight up → right
"bottom-center": [160, 20], // upper-left → upper-right
};
const toRad = (deg: number) => (deg * Math.PI) / 180;
/** x / y offsets (px, screen space) of each radial item from the button center. */
function radialOffsets(
count: number,
position: NonNullable<GlassFabMenuProps["position"]>,
) {
const [from, to] = RADIAL_ARCS[position];
const span = Math.abs(toRad(to - from));
const radius =
count > 1
? Math.max(RADIAL_MIN_RADIUS, (RADIAL_GAP * (count - 1)) / span)
: RADIAL_MIN_RADIUS;
return Array.from({ length: count }, (_, i) => {
const angle = toRad(count > 1 ? from + ((to - from) * i) / (count - 1) : from);
return {
x: Math.round(Math.cos(angle) * radius),
y: Math.round(-Math.sin(angle) * radius),
};
});
}
/** 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)`;
/** The menu's items in DOM order. */
const itemsIn = (menu: HTMLElement | null) =>
Array.from(menu?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
const noopSubscribe = () => () => {};
/** false during SSR and hydration, true afterwards — gates the portal. */
function useIsClient() {
return useSyncExternalStore(
noopSubscribe,
() => true,
() => false,
);
}
/**
* Floating frosted-glass action button — same grammar as GlassBottomTabBar
* and GlassBottomSheet. Opening dims the page and springs the actions out
* as a stack of pills or a radial fan; the plus turns into an ×. Scrim tap,
* Escape or picking an action closes it. Follows the WAI-ARIA menu-button
* pattern (aria-haspopup="menu", arrow keys between items).
*/
export function GlassFabMenu({
actions,
open: openProp,
defaultOpen = false,
onOpenChange,
layout = "stack",
accent = "glass",
position = "bottom-right",
placement = "fixed",
glowColorA,
glowColorB,
blur,
springStiffness = DEFAULT_SPRING_STIFFNESS,
springDamping = DEFAULT_SPRING_DAMPING,
"aria-label": ariaLabel = "アクションメニュー",
className,
}: GlassFabMenuProps) {
const isClient = useIsClient();
const fabId = useId();
const menuId = useId();
const fabRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const fixed = placement === "fixed";
const [innerOpen, setInnerOpen] = useState(defaultOpen);
const controlled = openProp !== undefined;
const open = controlled ? openProp : innerOpen;
const setOpen = (next: boolean) => {
if (!controlled) setInnerOpen(next);
onOpenChange?.(next);
};
// Which item to focus once the menu has rendered. Only set by keyboard
// opens, so a menu that mounts open (a preview) or opens on tap leaves
// page focus alone.
const pendingFocusRef = useRef<"first" | "last" | null>(null);
const menuItems = () => itemsIn(menuRef.current);
useEffect(() => {
const target = pendingFocusRef.current;
if (!open || !target) return;
pendingFocusRef.current = null;
const items = itemsIn(menuRef.current);
items[target === "first" ? 0 : items.length - 1]?.focus({
preventScroll: true,
});
}, [open]);
const closeAndReturnFocus = () => {
setOpen(false);
fabRef.current?.focus({ preventScroll: true });
};
// Escape is listened for on the document: Safari doesn't focus a button
// on click, so after a tap focus may be outside the component.
const onDocumentKeyDown = useEffectEvent((event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeAndReturnFocus();
});
useEffect(() => {
if (!open) return;
const listener = (event: globalThis.KeyboardEvent) =>
onDocumentKeyDown(event);
document.addEventListener("keydown", listener);
return () => document.removeEventListener("keydown", listener);
}, [open]);
const onFabClick = (event: MouseEvent<HTMLButtonElement>) => {
// detail === 0: a keyboard "click" (Enter / Space) → focus the first item.
if (!open && event.detail === 0) pendingFocusRef.current = "first";
setOpen(!open);
};
const onFabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
const target = event.key === "ArrowDown" ? "first" : "last";
if (open) {
const items = menuItems();
items[target === "first" ? 0 : items.length - 1]?.focus();
} else {
pendingFocusRef.current = target;
setOpen(true);
}
};
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Tab") {
// Menus aren't tab stops: close and let focus move on.
setOpen(false);
return;
}
const items = menuItems();
const index = items.indexOf(document.activeElement as HTMLElement);
let next: number | null = null;
if (event.key === "ArrowDown") next = (index + 1) % items.length;
else if (event.key === "ArrowUp")
next = (index - 1 + items.length) % items.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = items.length - 1;
if (next === null) return;
event.preventDefault();
items[next]?.focus();
};
const select = (action: GlassFabAction) => {
action.onSelect?.();
closeAndReturnFocus();
};
const spring = {
type: "spring",
stiffness: springStiffness,
damping: springDamping,
mass: 0.9,
} as const;
const exitTransition = { duration: 0.14, ease: "easeIn" } 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["--gfm-glow-a"] = glowColorA;
vars["--gfm-glow-a-rim"] = glowColorA;
vars["--gfm-glow-a-soft"] = tint(glowColorA, 60);
vars["--gfm-glow-a-bottom"] = tint(glowColorA, 75);
}
if (glowColorB && glowColorB.toLowerCase() !== DEFAULT_GLOW_B) {
vars["--gfm-glow-b"] = glowColorB;
}
if (blur !== undefined && blur !== DEFAULT_BLUR) {
vars["--gfm-blur"] = `${blur}px`;
}
const count = actions.length;
const offsets = layout === "radial" ? radialOffsets(count, position) : [];
const content = (
// Follows the OS "reduce motion" setting: items then fade in place.
<MotionConfig reducedMotion="user">
<div
className={className ? `${styles.root} ${className}` : styles.root}
data-glass-fab-menu
data-placement={placement}
data-position={position}
data-layout={layout}
style={vars}
>
<AnimatePresence>
{open ? (
<motion.div
key="scrim"
className={styles.scrim}
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
onClick={() => setOpen(false)}
/>
) : null}
</AnimatePresence>
<div className={styles.anchor}>
<AnimatePresence>
{open && count > 0 ? (
// Keyed by layout so switching it re-runs the entrance. The
// container itself never animates opacity: that would drop
// the items' backdrop blur while it runs.
<div
key={layout}
ref={menuRef}
id={menuId}
role="menu"
aria-labelledby={fabId}
aria-orientation="vertical"
className={layout === "radial" ? styles.radial : styles.stack}
onKeyDown={onMenuKeyDown}
>
{actions.map((action, i) => {
const Icon = action.icon;
if (layout === "radial") {
const { x, y } = offsets[i];
return (
<motion.button
key={action.id}
type="button"
role="menuitem"
tabIndex={-1}
className={styles.radialItem}
onClick={() => select(action)}
initial={{ x: 0, y: 0, scale: 0.4, opacity: 0 }}
animate={{
x,
y,
scale: 1,
opacity: 1,
transition: { ...spring, delay: i * STAGGER },
}}
exit={{
x: 0,
y: 0,
scale: 0.4,
opacity: 0,
transition: exitTransition,
}}
>
<span className={styles.radialLens}>
<Icon size={22} strokeWidth={1.8} aria-hidden />
</span>
<span className={styles.radialLabel}>{action.label}</span>
</motion.button>
);
}
// Stack: the pill nearest the button springs out first.
const fromFab = count - 1 - i;
return (
<motion.button
key={action.id}
type="button"
role="menuitem"
tabIndex={-1}
className={styles.stackItem}
onClick={() => select(action)}
initial={{ y: 18, scale: 0.85, opacity: 0 }}
animate={{
y: 0,
scale: 1,
opacity: 1,
transition: { ...spring, delay: fromFab * STAGGER },
}}
exit={{
y: 12,
scale: 0.9,
opacity: 0,
transition: exitTransition,
}}
>
<span className={styles.stackLens}>
<Icon size={18} strokeWidth={1.8} aria-hidden />
</span>
<span className={styles.stackLabel}>{action.label}</span>
</motion.button>
);
})}
</div>
) : null}
</AnimatePresence>
<div className={styles.fabWrap} data-accent={accent}>
<button
ref={fabRef}
id={fabId}
type="button"
className={styles.fab}
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={onFabClick}
onKeyDown={onFabKeyDown}
>
<motion.span
className={styles.fabIcon}
initial={false}
animate={{ rotate: open ? 45 : 0 }}
transition={spring}
>
<Plus size={26} strokeWidth={2} aria-hidden />
</motion.span>
</button>
</div>
</div>
</div>
</MotionConfig>
);
if (!fixed) return content;
// Portaled so a transformed / overflow:hidden ancestor can't trap the
// fixed button. Nothing renders until hydration is done.
return isClient ? createPortal(content, document.body) : null;
}
export default GlassFabMenu;
```
#### `components/glass-fab-menu/GlassFabMenu.module.css`
```css
/* ==========================================================================
Floating glass action button + menu — GlassBottomTabBar's sibling
Same grammar as the tab bar and the sheet: 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 → icon).
========================================================================== */
.root {
/* Spec colors. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--gfm-glow-a: #ffaac8; /* pink: back glow */
--gfm-glow-b: #beafff; /* lavender: back glow */
--gfm-glow-a-rim: #ffaacd; /* pink: outer light of the glass */
--gfm-glow-a-soft: #ffc8dc; /* lighter pink: bottom glow */
--gfm-glow-a-bottom: #ffbed7; /* pink pooled along the bottom edge */
--gfm-blur: 30px;
--gfm-inset: 20px; /* distance of the button from the container edges */
/* --gfm-inset-bottom is deliberately not declared here, so a consumer
className / [data-glass-fab-menu] rule can lift the button above a
tab bar without fighting this rule's specificity. */
position: fixed;
inset: 0;
/* Above GlassBottomTabBar (50), below GlassBottomSheet (60). */
z-index: 55;
isolation: isolate;
/* The layer covers the page but only the scrim and the button take
taps — a closed menu must never block the page underneath. */
pointer-events: none;
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;
pointer-events: auto;
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;
}
/* --- Anchor: a 60px box in the chosen corner. The button fills it; the
menu hangs off it, positioned against the button. --- */
.anchor {
position: absolute;
width: 60px;
height: 60px;
right: calc(var(--gfm-inset) + env(safe-area-inset-right, 0px));
bottom: calc(
var(--gfm-inset-bottom, var(--gfm-inset)) +
env(safe-area-inset-bottom, 0px)
);
}
.root[data-position="bottom-left"] .anchor {
right: auto;
left: calc(var(--gfm-inset) + env(safe-area-inset-left, 0px));
}
.root[data-position="bottom-center"] .anchor {
right: auto;
left: calc(50% - 30px);
}
/* --- Layer 1: pastel light behind the button --- */
.fabWrap {
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
pointer-events: auto;
}
.fabWrap::before {
content: "";
position: absolute;
z-index: -1;
inset: -16px;
border-radius: 50%;
pointer-events: none;
background:
radial-gradient(
60% 60% at 25% 60%,
color-mix(in srgb, var(--gfm-glow-a) 45%, transparent),
transparent 72%
),
radial-gradient(
55% 55% at 78% 35%,
color-mix(in srgb, var(--gfm-glow-b) 40%, transparent),
transparent 72%
),
radial-gradient(
70% 45% at 50% 100%,
color-mix(in srgb, var(--gfm-glow-a-soft) 25%, transparent),
transparent 70%
);
filter: blur(22px);
}
/* --- Layer 2: white frosted glass — the button, the stack pills and the
radial buttons share one recipe. Rim = inset white box-shadow. --- */
.fab,
.stackItem,
.radialLens {
position: relative;
isolation: isolate;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.17) 0%,
rgba(255, 255, 255, 0.09) 100%
);
backdrop-filter: blur(var(--gfm-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 8px 32px color-mix(in srgb, var(--gfm-glow-a-rim) 16%, transparent),
0 18px 48px rgba(0, 0, 0, 0.32);
}
/* Reflection on the upper part */
.fab::before,
.stackItem::before,
.radialLens::before {
content: "";
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.22) 0%,
rgba(255, 255, 255, 0.06) 35%,
rgba(255, 255, 255, 0) 60%
);
}
/* A little pink pooled along the bottom edge */
.fab::after,
.stackItem::after,
.radialLens::after {
content: "";
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--gfm-glow-a-bottom) 0%, transparent) 55%,
color-mix(in srgb, var(--gfm-glow-a-bottom) 12%, transparent) 100%
);
}
/* Without backdrop-filter, trade transparency for legible labels */
@supports not (backdrop-filter: blur(1px)) {
.fab,
.stackItem,
.radialLens {
background: linear-gradient(
180deg,
rgba(70, 60, 78, 0.94) 0%,
rgba(40, 34, 48, 0.94) 100%
);
}
}
/* --- The main button --- */
.fab {
display: grid;
place-items: center;
width: 100%;
height: 100%;
padding: 0;
border: 0;
border-radius: 9999px;
color: #fff;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition:
transform 0.12s ease,
background-color 0.2s ease;
}
.fabIcon {
display: grid;
place-items: center;
}
/* Red primary variant — same red and highlight as the tab bar's CTA band */
.fabWrap[data-accent="red"] .fab {
background: #e5322d;
backdrop-filter: none;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 8px 24px rgba(229, 50, 45, 0.36),
0 18px 48px rgba(0, 0, 0, 0.32);
}
.fabWrap[data-accent="red"] .fab::after {
content: none;
}
.fab:active {
transform: scale(0.94);
}
/* --- Stack layout: pills above the button, aligned to its side --- */
.stack {
position: absolute;
right: 0;
bottom: calc(100% + 16px);
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
width: max-content;
/* Gaps between pills fall through to the scrim. */
pointer-events: none;
}
.root[data-position="bottom-left"] .stack {
right: auto;
left: 0;
align-items: flex-start;
}
.root[data-position="bottom-center"] .stack {
right: auto;
left: 50%;
translate: -50% 0;
align-items: center;
}
.stackItem {
display: inline-flex;
align-items: center;
gap: 10px;
height: 48px;
padding: 0 18px 0 6px;
border: 0;
border-radius: 9999px;
font: inherit;
font-size: 15px;
font-weight: 500;
color: #fff;
white-space: nowrap;
cursor: pointer;
pointer-events: auto;
transform-origin: 0% 100%;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
/* Bottom-right: icon on the right, so the lenses line up above the button. */
.root[data-position="bottom-right"] .stackItem {
flex-direction: row-reverse;
padding: 0 6px 0 18px;
transform-origin: 100% 100%;
}
.root[data-position="bottom-center"] .stackItem {
transform-origin: 50% 100%;
}
/* Lens = the tab bar's active-pill recipe */
.stackLens {
display: grid;
place-items: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 9999px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.05) 100%
);
backdrop-filter: blur(8px) saturate(160%) brightness(1.08);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
inset 0 0 0 1px rgba(255, 255, 255, 0.42),
0 4px 16px color-mix(in srgb, var(--gfm-glow-a-rim) 18%, transparent);
}
.stackLabel {
line-height: 1;
}
/* --- Radial layout: round glass buttons fanned out from the center of
the main button. framer-motion moves each one out along x / y. --- */
.radial {
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
pointer-events: none;
}
.radialItem {
position: absolute;
left: -26px;
top: -26px;
width: 52px;
height: 52px;
padding: 0;
border: 0;
border-radius: 9999px;
background: none;
color: #fff;
font: inherit;
cursor: pointer;
pointer-events: auto;
outline: none;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.radialLens {
display: grid;
place-items: center;
width: 100%;
height: 100%;
border-radius: 9999px;
transition: background-color 0.2s ease;
}
/* Labels stay visible under each button (touch has no hover). */
.radialLabel {
position: absolute;
top: calc(100% + 6px);
left: 50%;
translate: -50% 0;
font-size: 11px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 1px 6px rgba(12, 9, 20, 0.7);
pointer-events: none;
}
/* --- States --- */
@media (hover: hover) {
.stackItem:hover,
.radialItem:hover .radialLens {
background-color: rgba(255, 255, 255, 0.08);
}
}
.stackItem:active,
.radialItem:active .radialLens {
background-color: rgba(255, 255, 255, 0.12);
}
.fab:focus-visible,
.stackItem:focus-visible,
.radialItem:focus-visible .radialLens {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: 2px;
}
.stackItem:focus-visible {
outline-offset: 3px;
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/glass-fab-menu.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { GlassFabMenu } from "@/components/glass-fab-menu";
import { Camera, PenLine, Radio, CalendarPlus } from "lucide-react";
// TODO: actions の各項目に onSelect: () => { … } を渡して処理をつなぎ、label / icon をアプリに合わせて差し替えてください(アイコンは lucide-react から選べます)
// TODO: GlassBottomTabBar と併用する場合、右下の FAB がバーに重なります。className(または [data-glass-fab-menu])で --gfm-inset-bottom: 104px のように指定してバーの上に逃がしてください
<GlassFabMenu
layout="stack"
accent="glass"
position="bottom-right"
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
springStiffness={380}
springDamping={30}
actions={[
{ id: "photo", label: "写真", icon: Camera },
{ id: "post", label: "投稿", icon: PenLine },
{ id: "live", label: "ライブ", icon: Radio },
{ id: "event", label: "予定", icon: CalendarPlus },
]}
/>
```
### 4. 組み込み手順
1. FAB を出したいページ(またはレイアウト)の `"use client"` コンポーネントに `<GlassFabMenu actions={[…]} />` を置く。各アクションは `{ id, label, icon, onSelect }`(`icon` は lucide-react のアイコン)。選ぶと `onSelect` のあと自動で閉じる。
2. `app/layout.tsx` で viewport に `viewportFit: "cover"` を指定する(GlassBottomTabBar を入れていれば設定済み)。これが無いと `env(safe-area-inset-*)` が 0 になり、iPhone のホームバーや角丸に FAB が寄りすぎる。
3. 既定(`placement="fixed"`)では `<body>` へポータルし、画面の右下(`position` で左下・下中央も可)に固定される。親に `transform` や `overflow: hidden` があっても影響しない。
4. 開閉状態を外から持ちたい場合は `open` / `onOpenChange` を渡す(渡さなければ内部で管理。初期状態は `defaultOpen`)。
5. GlassBottomTabBar と併用する場合、FAB は `z-index: 55`(バー 50 より上、GlassBottomSheet 60 より下)。位置はバーと重なるので、`className` か `[data-glass-fab-menu]` に `--gfm-inset-bottom: 104px` を指定してバーの上に持ち上げる(`--gfm-inset-bottom` は CSS 側で未宣言なので詳細度を気にせず効く)。
6. 主要な作成アクションなら `accent="red"`(タブバーの CTA と同じ #e5322d)。項目が多い・ラベルが長い場合は `layout="stack"`、3〜5 個の短いラベルなら `layout="radial"` も選べる。
7. `npm run build` が通ることを確認し、実機(iPhone)で「ホームバー領域と重ならない」「背景タップで閉じる」「閉じている間は FAB 以外のページ操作を邪魔しない」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| Chrome でぼかしが一切効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| 閉じているのにページがタップできない | 全面を覆うルートが pointer イベントを拾っている | ルートは `pointer-events: none`、スクリムと FAB だけ `auto`。外さない |
| 展開中にアクションのぼかしが消えて急に出る | メニューのコンテナ(親)で `opacity` をアニメーションしている | フェードは各アクション要素に付ける。親に `opacity` / `filter` を掛けると子の `backdrop-filter` が無効になる |
| 閉じるアニメーションが出ずに消える | `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-fab-menu/index.tsx`(`"use client"`)+ `GlassFabMenu.module.css`
- Props: `actions`(`{ id, label, icon: LucideIcon, onSelect? }[]`)、`open` / `defaultOpen` / `onOpenChange`(制御・非制御どちらも可)、`layout`(`"stack" | "radial"`)、`accent`(`"glass" | "red"`)、`position`(`"bottom-right" | "bottom-left" | "bottom-center"`)、`placement`(`"fixed" | "absolute"`)、`glowColorA` / `glowColorB` / `blur`、`springStiffness` / `springDamping`、`aria-label`(既定「アクションメニュー」)
- ルート: `position: fixed; inset: 0; z-index: 55; pointer-events: none`。`fixed` では `createPortal(…, document.body)`、SSR 中は描画しない。`absolute` は最寄りの positioned 祖先を埋めてその場に描画(モックアップ用)
- FAB の位置: 60px の箱を `right: calc(20px + env(safe-area-inset-right))` / `bottom: calc(var(--gfm-inset-bottom, 20px) + env(safe-area-inset-bottom))`。左下は left、下中央は `left: calc(50% - 30px)`
### 見た目 = GlassBottomTabBar と同じグラスモーフィズム
層構成は「スクリム → 発光 → ガラス → レンズ → アイコン」。硬い 1px の黒縁・濃い落ち影は使わない。
- **層 0 スクリム**(開いている間だけ): `rgba(12,9,20,.45)` + `backdrop-filter: blur(4px)`、opacity 0 → 1、220ms easeOut。タップで閉じる
- **層 1 発光(FAB ラッパーの ::before)**: `inset: -16px` の円、ピンク `rgba(255,170,200,.45)`(`60% 60% at 25% 60%`)とラベンダー `rgba(190,175,255,.40)`(`55% 55% at 78% 35%`)、下に `rgba(255,200,220,.25)`。`filter: blur(22px)`、`z-index: -1`(ラッパーに `isolation: isolate`)
- **層 2 ガラス**(FAB・縦積みピル・扇形の丸ボタン共通):
- background: `linear-gradient(180deg, rgba(255,255,255,.17), rgba(255,255,255,.09))`
- `backdrop-filter: blur(30px) saturate(170%)`(unprefixed のみ)
- box-shadow: `inset 0 1px 0 白 .45` / `inset 0 0 0 1px 白 .20` / `0 8px 32px rgba(255,170,205,.16)` / `0 18px 48px rgba(0,0,0,.32)`
- `::before` 上部の反射(白 .22 → .06 at 35% → 0 at 60%)、`::after` 下辺にピンク `rgba(255,190,215,.12)`(55% から)
- `@supports not (backdrop-filter: blur(1px))` では `rgba(70,60,78,.94) → rgba(40,34,48,.94)`
- **FAB**: 60px の円、lucide `Plus` 26px / 線幅 2、白。`accent="red"` では `#e5322d` 塗り(ぼかし無し)+ `inset 0 1px 0 白 .28` / `0 8px 24px rgba(229,50,45,.36)`。押下時 `scale(.94)`
- **stack**: FAB の上 16px から縦に gap 10px。右下では右揃え、左下では左揃え、下中央では中央揃え。各ピルは高さ 48px・`border-radius: 9999px`、15px / 500、左右に 36px の丸レンズ(白 .12 → .05 + `backdrop-filter: blur(8px) saturate(160%) brightness(1.08)` + `inset 0 1px 0 白 .55` / `inset 0 0 0 1px 白 .42` / `0 4px 16px rgba(255,170,205,.18)`)にアイコン 18px。右下ではアイコンを右側に置き、レンズが FAB の真上に縦一列に並ぶ
- **radial**: FAB の中心から 52px の丸ガラスボタンを扇形に配置。右下は真上 → 真左(90°→180°)、左下は真上 → 真右(90°→0°)、下中央は 160°→20° の上向きの扇。半径は `max(100px, 66px × (個数 − 1) ÷ 弧の角度[rad])`(隣同士の中心が 66px 以上離れる)。アイコン 22px。ラベルは各ボタンの 6px 下に常時表示(11px / 600、白 .92、`text-shadow: 0 1px 6px rgba(12,9,20,.7)`)
- `:focus-visible` は白 2px アウトライン(radial はガラス円に付ける)。ホバー可能な環境ではホバーで少し明るく
- フォント: `-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto, sans-serif`
### モーション
- スプリング `stiffness: 380, damping: 30, mass: 0.9`
- FAB の `Plus` は開くと `rotate: 45°`(× になる)、閉じると 0°
- stack: 各ピル `y: 18, scale: .85, opacity: 0 → y: 0, scale: 1, opacity: 1`。FAB に近いものから 40ms ずつ遅らせて出す。`transform-origin` は FAB 側の下隅
- radial: 各ボタン FAB 中心(`x: 0, y: 0, scale: .4, opacity: 0`)→ 扇形の位置へ。配列順に 40ms ずつ遅らせる
- 閉じるときは 140ms easeIn で FAB 側へ戻りながら消える(`AnimatePresence`)
- メニューのコンテナ自体は `opacity` をアニメーションしない(子の `backdrop-filter` が切れるため)
- `<MotionConfig reducedMotion="user">` で包む
### アクセシビリティ
- FAB: `aria-haspopup="menu"`、`aria-expanded`、開いている間 `aria-controls={メニューの id}`、`aria-label`
- メニュー: `role="menu"` + `aria-labelledby={FAB の id}`、各アクション `role="menuitem"`(`tabIndex={-1}`、ローヴィングフォーカス)
- キーボード(WAI-ARIA の menu button パターン): Enter / Space / ↓ で開いて先頭項目へ、↑ で開いて末尾項目へフォーカス。項目上では ↑↓ で移動(端で循環)、Home / End で先頭 / 末尾。Escape で閉じて FAB にフォーカスを戻す(Safari はクリックでボタンにフォーカスしないので document で Escape を拾う)。Tab で閉じてフォーカスはそのまま次へ。項目を選ぶと閉じて FAB にフォーカスを戻す
- タップやマウスで開いたとき・最初から開いた状態でマウントされたときはフォーカスを動かさない
- `touch-action: manipulation`、`-webkit-tap-highlight-color: transparent`
### 受け入れ条件
- FAB を押すと背景が暗くぼけ、アクションがスプリングで順に飛び出し、+ が × に回る
- 背景タップ・Escape・項目選択で閉じ、閉じるアニメーションが出る
- 閉じている間、FAB 以外のページ操作を一切邪魔しない
- キーボードだけで開く → 矢印で選ぶ → Enter で実行 → FAB にフォーカスが戻る、が通る
- iPhone のホームバー領域・角丸と重ならないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/glass-fab-menu.json生成コード
import { GlassFabMenu } from "@/components/glass-fab-menu";
import { Camera, PenLine, Radio, CalendarPlus } from "lucide-react";
// TODO: actions の各項目に onSelect: () => { … } を渡して処理をつなぎ、label / icon をアプリに合わせて差し替えてください(アイコンは lucide-react から選べます)
// TODO: GlassBottomTabBar と併用する場合、右下の FAB がバーに重なります。className(または [data-glass-fab-menu])で --gfm-inset-bottom: 104px のように指定してバーの上に逃がしてください
<GlassFabMenu
layout="stack"
accent="glass"
position="bottom-right"
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
springStiffness={380}
springDamping={30}
actions={[
{ id: "photo", label: "写真", icon: Camera },
{ id: "post", label: "投稿", icon: PenLine },
{ id: "live", label: "ライブ", icon: Radio },
{ id: "event", label: "予定", icon: CalendarPlus },
]}
/>