AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約32 KB)。
# GlassBottomTabBar をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: GlassBottomTabBar
- 説明: iOS アプリ風の浮遊ガラスボトムタブバー — すりガラスとパステルの発光、スプリングで移動するアクティブピル、SVG マスクでくり抜いたアイコン。モバイル幅専用。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/glass-bottom-tab-bar/index.tsx`, `components/glass-bottom-tab-bar/GlassBottomTabBar.module.css`
## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。このコンポーネントは `next/link` や `next/navigation` を使うため Next.js 専用です。Next.js 以外のプロジェクトでは、それらの import だけをそのルーターの同等機能に置き換えてください。
- スタイルは同梱の 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-tab-bar/index.tsx`
```tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useId, type CSSProperties, type MouseEvent } from "react";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
import {
BookOpen,
House,
MessageCircle,
ShoppingBag,
type LucideIcon,
} from "lucide-react";
import styles from "./GlassBottomTabBar.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type GlassTab = { href: string; label: string; icon: LucideIcon };
/** Contextual CTA band (red) shown right under the bar; slides in when passed. */
export type GlassTabBarCta = {
label: string;
href: string;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
};
/** Default tabs. Replace per project via the `tabs` prop. */
export const DEFAULT_TABS: GlassTab[] = [
{ href: "/", label: "ホーム", icon: House },
{ href: "/products", label: "商品", icon: ShoppingBag },
{ href: "/guide", label: "使い方", icon: BookOpen },
{ href: "/contact", label: "お問い合わせ", icon: MessageCircle },
];
export type GlassBottomTabBarProps = {
tabs?: GlassTab[];
/** Paths (exact match) where the bar is not rendered. */
hiddenPaths?: string[];
cta?: GlassTabBarCta;
/** Render the text label under each icon. */
showLabels?: boolean;
/**
* Glow color A — the pink light behind the bar, the bar's outer light and
* the active icon glow. Any CSS color; softer tints are derived from it.
*/
glowColorA?: string;
/** Glow color B — the lavender light behind the bar. */
glowColorB?: string;
/** Backdrop blur of the bar in px. */
blur?: number;
/** Spring stiffness of the active pill's move between tabs. */
springStiffness?: number;
/** Spring damping of the active pill's move between tabs. */
springDamping?: number;
/**
* Overrides the current path (defaults to `usePathname()`). Lets the bar
* run outside the router, e.g. in a preview or a phone mockup. Route
* prefetching is off in this mode.
*/
pathname?: string;
/**
* Runs before a tab navigates. Call `event.preventDefault()` to handle
* the selection yourself (controlled previews, analytics, etc.).
*/
onTabSelect?: (tab: GlassTab, event: MouseEvent<HTMLAnchorElement>) => void;
/**
* "fixed" (default) pins the bar to the viewport. "absolute" sits it at
* the bottom of the nearest positioned ancestor — for phone mockups.
*/
placement?: "fixed" | "absolute";
/** Hide the bar at 768px and wider (default true). */
mobileOnly?: boolean;
className?: string;
};
const DEFAULT_GLOW_A = "#ffaac8";
const DEFAULT_GLOW_B = "#beafff";
const DEFAULT_BLUR = 30;
/** Slight overshoot on the pill move. */
const DEFAULT_SPRING_STIFFNESS = 380;
const DEFAULT_SPRING_DAMPING = 30;
const safeId = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "");
/** 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)`;
/**
* iOS-style floating glass bottom tab bar. Shown at mobile widths (< 768px)
* only. Body padding for the bar is the consumer's job: a globals.css rule
* keyed on `[data-bottom-tab-bar]` (see the registry docs).
*/
export function GlassBottomTabBar({
tabs = DEFAULT_TABS,
hiddenPaths = ["/"],
cta,
showLabels = true,
glowColorA,
glowColorB,
blur,
springStiffness = DEFAULT_SPRING_STIFFNESS,
springDamping = DEFAULT_SPRING_DAMPING,
pathname: pathnameProp,
onTabSelect,
placement = "fixed",
mobileOnly = true,
className,
}: GlassBottomTabBarProps) {
const routerPathname = usePathname();
const pathname = pathnameProp ?? routerPathname;
// SVG mask ids must be unique per page. useId's punctuation and the "/"
// in hrefs are awkward inside url(#...), so both are stripped.
const idBase = safeId(useId());
const maskIdFor = (href: string) => `tab-mask-${idBase}-${safeId(href)}`;
const controlled = pathnameProp !== undefined;
if (hiddenPaths.includes(pathname)) return null;
// "/" is a prefix of every path, so it only matches exactly.
const isActive = (href: string) =>
href === "/"
? pathname === "/"
: pathname === href || pathname.startsWith(`${href}/`);
const spring = {
type: "spring",
stiffness: springStiffness,
damping: springDamping,
mass: 0.9,
} as const;
// The CSS module carries the spec's hand-tuned defaults. Custom properties
// are only set for non-default values so the default render stays
// identical to the stylesheet.
const vars: CSSVars = { "--tab-count": tabs.length };
if (glowColorA && glowColorA.toLowerCase() !== DEFAULT_GLOW_A) {
vars["--gbt-glow-a"] = glowColorA;
vars["--gbt-glow-a-rim"] = glowColorA;
vars["--gbt-glow-a-soft"] = tint(glowColorA, 60);
vars["--gbt-glow-a-icon"] = tint(glowColorA, 55);
vars["--gbt-glow-a-bottom"] = tint(glowColorA, 75);
}
if (glowColorB && glowColorB.toLowerCase() !== DEFAULT_GLOW_B) {
vars["--gbt-glow-b"] = glowColorB;
}
if (blur !== undefined && blur !== DEFAULT_BLUR) {
vars["--gbt-blur"] = `${blur}px`;
}
return (
// Follows the OS "reduce motion" setting: the pill then switches
// instantly instead of springing across.
<MotionConfig reducedMotion="user">
<div
className={className ? `${styles.root} ${className}` : styles.root}
data-bottom-tab-bar
data-placement={placement}
data-mobile-only={mobileOnly || undefined}
style={vars}
>
<nav className={styles.bar} aria-label="メインナビゲーション">
{tabs.map((tab) => {
const { href, label, icon: Icon } = tab;
const active = isActive(href);
return (
<Link
key={href}
href={href}
className={styles.tab}
data-active={active || undefined}
aria-current={active ? "page" : undefined}
prefetch={controlled ? false : undefined}
onClick={onTabSelect ? (e) => onTabSelect(tab, e) : undefined}
>
{active ? (
<motion.span
layoutId={`${idBase}-active-pill`}
className={styles.pill}
transition={spring}
/>
) : null}
{/* Inactive: the line icon (iconLine).
Active: a white silhouette with the inner lines cut out
(iconFill). lucide draws detail paths first and the
outline last, so filling the outline alone would bury the
details. An SVG mask layers "silhouette = show (white)"
over "details = hide (black)", and the bar's glass shows
through the cut-outs instead of black. */}
<span className={styles.iconStack} aria-hidden>
<Icon className={styles.iconLine} size={24} strokeWidth={1.6} />
<svg
className={styles.iconFill}
width={24}
height={24}
viewBox="0 0 24 24"
>
<mask
id={maskIdFor(href)}
maskUnits="userSpaceOnUse"
x={0}
y={0}
width={24}
height={24}
>
<Icon
x={0}
y={0}
width={24}
height={24}
fill="#fff"
stroke="#fff"
strokeWidth={1.6}
/>
<Icon
className={styles.maskDetail}
x={0}
y={0}
width={24}
height={24}
fill="none"
strokeWidth={1.6}
/>
</mask>
<rect
width={24}
height={24}
fill="currentColor"
mask={`url(#${maskIdFor(href)})`}
/>
</svg>
</span>
{showLabels ? <span className={styles.label}>{label}</span> : null}
</Link>
);
})}
</nav>
<AnimatePresence initial={false}>
{cta ? (
<motion.div
key="cta"
className={styles.ctaSlot}
initial={{ height: 0 }}
animate={{ height: 44 }}
exit={{ height: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Link
href={cta.href}
className={styles.cta}
prefetch={controlled ? false : undefined}
onClick={cta.onClick}
>
{cta.label}
</Link>
</motion.div>
) : null}
</AnimatePresence>
</div>
</MotionConfig>
);
}
export default GlassBottomTabBar;
```
#### `components/glass-bottom-tab-bar/GlassBottomTabBar.module.css`
```css
/* ==========================================================================
Floating glass bottom tab bar (mobile widths only by default)
Glassmorphism: white frosted glass over a dark page + a soft pastel
(pink → lavender) glow. No hard 1px dark borders, no heavy drop shadows,
no sharp corners — depth comes from light and layers
(glow → glass → lens → icon). Content behind the bar blurs through.
========================================================================== */
.root {
/* Spec colors. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--gbt-glow-a: #ffaac8; /* pink: back glow */
--gbt-glow-b: #beafff; /* lavender: back glow */
--gbt-glow-a-rim: #ffaacd; /* pink: outer light of bar and pill */
--gbt-glow-a-soft: #ffc8dc; /* lighter pink: bottom glow */
--gbt-glow-a-icon: #ffc8e1; /* lighter pink: active icon / label glow */
--gbt-glow-a-bottom: #ffbed7; /* pink pooled along the bar's bottom edge */
--gbt-blur: 30px;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed;
left: 14px;
right: 14px;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
z-index: 50;
isolation: isolate;
display: flex;
flex-direction: column;
transition:
transform 0.4s var(--ease-out),
opacity 0.4s var(--ease-out);
}
/* Phone mockups / previews: sit at the bottom of a positioned ancestor. */
.root[data-placement="absolute"] {
position: absolute;
}
/* Layer 0: pastel light behind the bar. Two blurred ellipses, offset from
each other, tint the glass from behind. */
.root::before {
content: "";
position: absolute;
z-index: -1;
inset: -18px -10px -26px;
border-radius: 9999px;
pointer-events: none;
background:
radial-gradient(
55% 80% at 22% 70%,
color-mix(in srgb, var(--gbt-glow-a) 45%, transparent),
transparent 70%
),
radial-gradient(
50% 80% at 78% 30%,
color-mix(in srgb, var(--gbt-glow-b) 40%, transparent),
transparent 70%
),
radial-gradient(
70% 60% at 50% 100%,
color-mix(in srgb, var(--gbt-glow-a-soft) 25%, transparent),
transparent 70%
);
filter: blur(22px);
}
/* Not on desktop */
@media (min-width: 768px) {
.root[data-mobile-only] {
display: none;
}
}
/* (Optional) retreat while a full-screen menu is open. Works as-is when the
site's header gets `data-open` while its menu is open. */
body:has(header[data-open]) .root {
transform: translateY(calc(100% + 24px));
opacity: 0;
pointer-events: none;
}
/* --- Layer 1: the bar (white frosted glass) ---
The rim is an inset white box-shadow, not a border, so the 1px doesn't
smear around the rounded outline. Only large, soft shadows. */
.bar {
position: relative;
isolation: isolate;
overflow: hidden;
display: grid;
grid-template-columns: repeat(var(--tab-count, 4), 1fr);
height: 62px;
padding: 5px;
border-radius: 9999px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.17) 0%,
rgba(255, 255, 255, 0.09) 100%
);
/* No -webkit-backdrop-filter here: the build adds the prefix itself, and
writing both makes Lightning CSS drop the unprefixed one. */
backdrop-filter: blur(var(--gbt-blur)) saturate(170%);
/* Top to bottom: top highlight line / faint inner rim / soft pink outer
light / floating shadow (large and faint) */
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(--gbt-glow-a-rim) 16%, transparent),
0 18px 48px rgba(0, 0, 0, 0.32);
}
/* Reflection on the upper half */
.bar::before {
content: "";
position: absolute;
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) 40%,
rgba(255, 255, 255, 0) 58%
);
}
/* A little pink pooled along the bottom edge (depth via color, not dark) */
.bar::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--gbt-glow-a-bottom) 0%, transparent) 50%,
color-mix(in srgb, var(--gbt-glow-a-bottom) 12%, transparent) 100%
);
}
/* Without backdrop-filter, trade transparency for legible text */
@supports not (backdrop-filter: blur(1px)) {
.bar {
background: linear-gradient(
180deg,
rgba(70, 60, 78, 0.94) 0%,
rgba(40, 34, 48, 0.94) 100%
);
}
}
/* --- Tabs --- */
.tab {
position: relative;
z-index: 1; /* above ::before / ::after (reflection, pooled tint) */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border-radius: 9999px;
color: rgba(255, 255, 255, 0.62);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
transition:
color 0.2s ease,
transform 0.12s ease;
}
.tab[data-active] {
color: #fff;
}
.tab:active {
transform: scale(0.94);
}
.tab:focus-visible {
outline-offset: -2px;
}
/* --- Layer 2: active pill = inner lens (springs between tabs) ---
Not painted white: a white rim plus the faintest glass face, so the
tinted glass behind shows through. */
.pill {
position: absolute;
inset: 0; /* fills the 52px cell */
border-radius: 9999px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.03) 100%
);
/* Weaker blur than the bar: "slightly sharper through the lens" */
backdrop-filter: blur(8px) saturate(160%) brightness(1.08);
/* Top to bottom: top reflection / white rim / faint pink outer light */
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(--gbt-glow-a-rim) 18%, transparent);
pointer-events: none;
/* framer-motion moves it with transform. Promote it to its own layer up
front so Safari's compositing holds inside an overflow:hidden +
backdrop-filter parent. */
will-change: transform;
}
/* A thin reflection along the top edge only */
.pill::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(
60% 40% at 50% 0%,
rgba(255, 255, 255, 0.14),
transparent 70%
);
}
/* --- Layer 3: icon (two layers) ---
iconLine: the regular line icon, shown while inactive.
iconFill: white silhouette with the inner lines cut out via SVG mask,
shown while active. The bar's glass shows through the cut-outs,
so the details read without any black.
lucide's outline is the last <path>, so in the mask's detail layer only
that one is white (= show) to avoid a groove around the outline
(checked with shopping-bag / book-open / house / message-circle). */
.iconStack {
position: relative;
width: 24px;
height: 24px;
transition: filter 0.2s ease;
}
/* Active: soft pinkish glow */
.tab[data-active] .iconStack {
filter: drop-shadow(
0 0 8px color-mix(in srgb, var(--gbt-glow-a-icon) 55%, transparent)
);
}
.iconFill,
.iconLine {
position: absolute;
inset: 0;
width: 24px;
height: 24px;
transition: opacity 0.2s ease;
}
.iconLine {
fill: none;
stroke: currentColor;
}
.iconFill {
opacity: 0;
}
/* Mask detail layer: black = hide (cut out). Only the outline stays white. */
.maskDetail {
stroke: #000;
}
.maskDetail path:last-child {
stroke: #fff;
}
.tab[data-active] .iconLine {
opacity: 0;
}
.tab[data-active] .iconFill {
opacity: 1;
}
.label {
position: relative;
font-family:
-apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
font-size: 11px;
font-weight: 500;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
transition: text-shadow 0.2s ease;
}
.tab[data-active] .label {
text-shadow: 0 0 10px
color-mix(in srgb, var(--gbt-glow-a-icon) 50%, transparent);
}
/* --- Contextual CTA band (under the bar, slides in 0 → 44px) --- */
.ctaSlot {
overflow: hidden;
margin-top: 8px;
border-radius: 9999px;
}
.cta {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
color: #fff;
font-size: 17px;
font-weight: 500;
background: #e5322d;
border-radius: 9999px;
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/glass-bottom-tab-bar.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { GlassBottomTabBar } from "@/components/glass-bottom-tab-bar";
import { House, ShoppingBag, BookOpen, MessageCircle } from "lucide-react";
// TODO: tabs の href / label / icon をプロジェクトのルートに合わせて差し替えてください(href は実在するルートに向けること。アイコンは lucide-react から選べます)
// TODO: hiddenPaths(既定 ["/"])を確認してください。トップページにも出す場合は hiddenPaths={[]} を渡します
<GlassBottomTabBar
showLabels={true}
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
springStiffness={380}
springDamping={30}
tabs={[
{ href: "/", label: "ホーム", icon: House },
{ href: "/products", label: "商品", icon: ShoppingBag },
{ href: "/guide", label: "使い方", icon: BookOpen },
{ href: "/contact", label: "お問い合わせ", icon: MessageCircle },
]}
/>
```
### 4. 組み込み手順
1. `app/layout.tsx` の `<body>` 内、`{children}` の直後に `<GlassBottomTabBar />` を 1 つだけ置く。ページごとには置かない。
2. `app/layout.tsx` で viewport に `viewportFit: "cover"` を指定する。これが無いと `env(safe-area-inset-bottom)` が 0 になり、iPhone のホームバーとバーが重なる。
```tsx
import type { Viewport } from "next";
export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" };
```
3. `app/globals.css` に、バーが出ているページだけ本文の下端を空けるルールを追記する(バーは `hiddenPaths` と 768px 以上では出ないので、padding も同じ条件で付け外しされる)。
```css
@media (max-width: 767px) {
body:has([data-bottom-tab-bar]) {
padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px));
}
}
```
4. `tabs` にプロジェクトのタブ(`href` / `label` / lucide-react のアイコン)を渡す。`href` は**実在するルート**に向けること。404 のリンクは Next.js がフルリロードにするため、バーごと再マウントされてピルのスプリング移動が起きない。ラベルは 11px / 改行なしなので 4 文字前後が目安。長い場合はタブ数を減らす。
5. `hiddenPaths`(既定 `["/"]`、完全一致)で、バーを出さないパスを指定する。トップページにも出したい場合は `hiddenPaths={[]}`。
6. 全画面メニューがあるサイトでは、開いたときに `<header data-open>` が付くようにするとバーが自動で下へ退く。無ければ何もしなくてよい(CSS の `body:has(header[data-open])` ブロックは削除しても構わない)。
7. `npm run build` が通ることを確認し、実機(iPhone)で「ホームバー領域と重ならない」「スクロール時にバー越しにコンテンツがぼけて透ける」を確認する。
#### 触ってはいけないところ(実際に踏んだハマりどころ)
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| Chrome でぼかしが一切効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が `-webkit-` だけ残して unprefixed を削る。Chrome は `-webkit-` を解釈しない | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| タブを押してもピルがスライドしない | リンク先が 404 だと Next.js がフルリロードにし、バーごと再マウントされる | 実在するルートに向ける |
| アクティブアイコンが白い塊になる | lucide は細部パスを先に・外形を最後に描く。外形を `fill` すると細部が下に隠れる | 同梱の SVG マスク方式(シルエットから細部をくり抜く)をそのまま使う |
| アイコンの周りに黒い輪郭が出る | マスクの細部層で外形パスまで黒(= 隠す)にしている | `.maskDetail path:last-child { stroke: #fff }` を消さない |
| 視差効果オフで動きが止まらない | グローバルの reduced-motion は CSS だけに効き、framer-motion は JS で動く | 同梱の `<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-tab-bar/index.tsx`(`"use client"`)+ `GlassBottomTabBar.module.css`
- モバイル幅(< 768px)のみ表示。`hiddenPaths`(既定 `["/"]`、完全一致)では `null` を返す
- `position: fixed; left/right: 14px; bottom: calc(12px + env(safe-area-inset-bottom)); z-index: 50`
- 本文がバーに隠れないよう、globals.css で `@media (max-width: 767px) { body:has([data-bottom-tab-bar]) { padding-bottom: calc(96px + env(safe-area-inset-bottom)) } }`。ルート要素に `data-bottom-tab-bar` を付ける
- viewport は `viewportFit: "cover"` にして `env()` を有効にする
### 見た目 = グラスモーフィズム(ダーク地の上に白系のすりガラス + パステルの発光)
層構成は「発光 → ガラス → レンズ(ピル) → アイコン」の 4 層。硬い 1px の黒縁・濃い落ち影・鋭い角は使わない。
- **層 0 発光(root::before)**: バーの背後(`inset: -18px -10px -26px`)に、ピンク `rgba(255,170,200,.45)` とラベンダー `rgba(190,175,255,.40)` の radial-gradient を左右にずらして重ね(`55% 80% at 22% 70%` / `50% 80% at 78% 30%`)、さらに下辺中央に `rgba(255,200,220,.25)`(`70% 60% at 50% 100%`)。`filter: blur(22px)`、`z-index: -1`
- **層 1 バー本体(.bar)**: 高さ 62px、`border-radius: 9999px`、padding 5px、`overflow: hidden`、`isolation: isolate`、`display: grid; grid-template-columns: repeat(タブ数, 1fr)`
- background: `linear-gradient(180deg, rgba(255,255,255,.17), rgba(255,255,255,.09))`
- `backdrop-filter: blur(30px) saturate(170%)` ※ `-webkit-backdrop-filter` は書かない(ビルド時に自動付与。併記すると unprefixed が消える)
- box-shadow: `inset 0 1px 0 rgba(255,255,255,.45)` / `inset 0 0 0 1px rgba(255,255,255,.20)` / `0 8px 32px rgba(255,170,205,.16)` / `0 18px 48px rgba(0,0,0,.32)`(リムは border ではなく inset の白で描く)
- `::before` 上半分の反射(白 .22 → .06 at 40% → 0 at 58%)、`::after` 下辺にピンク `rgba(255,190,215,.12)` を溜める(50% から)
- `@supports not (backdrop-filter: blur(1px))` では不透明度 .94 の暗い地色(`rgba(70,60,78,.94) → rgba(40,34,48,.94)`)に落とす
- **層 2 アクティブピル(.pill)**: セルいっぱい(`position: absolute; inset: 0`、52px)、白く塗らず「枠だけのガラス」
- background: 白 .08 → .03 のグラデ、`backdrop-filter: blur(8px) saturate(160%) brightness(1.08)`
- box-shadow: `inset 0 1px 0 白 .55` / `inset 0 0 0 1px 白 .42` / `0 4px 16px rgba(255,170,205,.18)`
- `::before` で上辺にだけ細い反射(`radial-gradient(60% 40% at 50% 0%, 白 .14, transparent 70%)`)
- framer-motion の `layoutId` でタブ間をスプリング移動(`type: "spring", stiffness: 380, damping: 30, mass: 0.9`)。`will-change: transform`、`pointer-events: none`
- **層 3 アイコン(24px、lucide-react、strokeWidth 1.6)**
- 非アクティブ: 線画、色 `rgba(255,255,255,.62)`
- アクティブ: 白いシルエットから細部の線(持ち手・綴じ目・扉)を SVG `<mask>` で「くり抜く」。マスク内に同じアイコンを 2 枚重ね、1 枚目は `fill="#fff" stroke="#fff"`(シルエット = 見せる)、2 枚目は `fill="none"` で `stroke: #000`(細部 = 隠す)。くり抜いた部分にはバーのガラス背景がそのまま透ける(黒は使わない)。`<rect fill="currentColor" mask="url(#id)">` で塗る
- lucide は細部パスを先に・外形パスを最後に描くので、マスクの細部層では `path:last-child` だけ白(= 見せる)にして外周に溝を作らない
- マスクの id は `useId()` + href から生成し、`url(#...)` で扱えない記号は落とす
- アクティブ時は `filter: drop-shadow(0 0 8px rgba(255,200,225,.55))` の柔らかい発光。線画/くり抜きの切替は opacity 200ms
- ラベル 11px / 500 / システムフォント(`-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto, sans-serif`)、`white-space: nowrap`。アクティブは白 + `text-shadow: 0 0 10px rgba(255,200,225,.5)`
- 押下時 `transform: scale(.94)` 120ms、色の切替 200ms、`-webkit-tap-highlight-color: transparent`、`touch-action: manipulation`
- `aria-current="page"` を付ける。`role="tab"` は付けない(リンクの意味が壊れる)。`<nav aria-label="メインナビゲーション">`
- `<MotionConfig reducedMotion="user">` で包み、OS の視差効果オフに従う
- 任意: `cta={{ label, href }}` を渡すとバー直下に赤い帯(高さ 44px、`#e5322d`、17px / 500、`border-radius: 9999px`、`margin-top: 8px`)が `AnimatePresence` で高さ 0 → 44px、200ms easeOut でスライドインする
- 任意: `body:has(header[data-open]) .root` で `translateY(calc(100% + 24px))` + opacity 0 にして、全画面メニュー中は下へ退く(0.4s `cubic-bezier(0.22, 1, 0.36, 1)`)
### アクティブ判定
- `usePathname()` と各タブの `href` を比較。`"/"` は全パスに前方一致してしまうので完全一致のみ、それ以外は `pathname === href || pathname.startsWith(href + "/")`
### 受け入れ条件
- `hiddenPaths` で非表示、他ページで表示。デスクトップ(768px 以上)で非表示
- タブ切替でピルがスプリング移動し、アイコンが線画 → くり抜きシルエットに変わる
- スクロール時にバー越しにコンテンツがぼけて透け、背後の色がガラスに乗る
- iPhone のホームバー領域と重ならないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/glass-bottom-tab-bar.json生成コード
import { GlassBottomTabBar } from "@/components/glass-bottom-tab-bar";
import { House, ShoppingBag, BookOpen, MessageCircle } from "lucide-react";
// TODO: tabs の href / label / icon をプロジェクトのルートに合わせて差し替えてください(href は実在するルートに向けること。アイコンは lucide-react から選べます)
// TODO: hiddenPaths(既定 ["/"])を確認してください。トップページにも出す場合は hiddenPaths={[]} を渡します
<GlassBottomTabBar
showLabels={true}
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
springStiffness={380}
springDamping={30}
tabs={[
{ href: "/", label: "ホーム", icon: House },
{ href: "/products", label: "商品", icon: ShoppingBag },
{ href: "/guide", label: "使い方", icon: BookOpen },
{ href: "/contact", label: "お問い合わせ", icon: MessageCircle },
]}
/>