ナビゲーション
GlassTopBar
Motion
GlassBottomTabBar と対になる iOS 風の浮遊ガラストップバー — 戻るボタン・中央タイトル・右のアクション。最上部では透明で、スクロールに合わせてすりガラスとパステルの発光が現れる。下スクロールで隠すことも可能。
ライブラリ
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約27 KB)。
# GlassTopBar をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: GlassTopBar
- 説明: GlassBottomTabBar と対になる iOS 風の浮遊ガラストップバー — 戻るボタン・中央タイトル・右のアクション。最上部では透明で、スクロールに合わせてすりガラスとパステルの発光が現れる。下スクロールで隠すことも可能。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/glass-top-bar/index.tsx`, `components/glass-top-bar/GlassTopBar.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-top-bar/index.tsx`
```tsx
"use client";
import {
useRef,
useState,
type CSSProperties,
type MouseEvent,
type RefObject,
} from "react";
import {
MotionConfig,
motion,
useMotionValueEvent,
useScroll,
useTransform,
} from "framer-motion";
import type { LucideIcon } from "lucide-react";
import styles from "./GlassTopBar.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
/** A round lens button in the bar (leading back button or trailing action). */
export type GlassTopBarAction = {
icon: LucideIcon;
/** Accessible name — the button shows the icon only. */
label: string;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
};
export type GlassTopBarProps = {
title: string;
/** Small second line under the title. An empty string renders nothing. */
subtitle?: string;
/** Left lens button, typically a ChevronLeft "back". */
leading?: GlassTopBarAction;
/** Right lens buttons. Only the first two are rendered. */
trailing?: GlassTopBarAction[];
/**
* The element that scrolls (overflow: auto). Defaults to the window.
* Pass it when the page scrolls inside a container, e.g. a phone mockup.
*/
scrollContainerRef?: RefObject<HTMLElement | null>;
/** Scroll distance in px over which the glass and glow fade in. */
revealDistance?: number;
/** Slide the bar away while scrolling down; bring it back on scroll up. */
hideOnScroll?: boolean;
/**
* "fixed" (default) pins the bar to the viewport. "absolute" sits it at
* the top of the nearest positioned ancestor — for phone mockups.
*/
placement?: "fixed" | "absolute";
/** Glow color A — the pink light behind the bar and its outer light. */
glowColorA?: string;
/** Glow color B — the lavender light behind the bar. */
glowColorB?: string;
/** Backdrop blur of the bar in px (at full reveal). */
blur?: number;
/** Spring stiffness of the hide / show slide. */
springStiffness?: number;
/** Spring damping of the hide / show slide. */
springDamping?: number;
className?: string;
};
const DEFAULT_GLOW_A = "#ffaac8";
const DEFAULT_GLOW_B = "#beafff";
const DEFAULT_BLUR = 30;
const DEFAULT_REVEAL_DISTANCE = 60;
const DEFAULT_SPRING_STIFFNESS = 380;
const DEFAULT_SPRING_DAMPING = 32;
/** Scroll deltas below this are ignored so trackpad jitter can't flicker the bar. */
const HIDE_DELTA = 4;
/** 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 top bar — the counterpart of GlassBottomTabBar.
* Transparent at the top of the page; the frosted glass and the pastel glow
* fade in as the content scrolls under it. Body padding for the fixed bar is
* the consumer's job.
*/
export function GlassTopBar({
title,
subtitle,
leading,
trailing = [],
scrollContainerRef,
revealDistance = DEFAULT_REVEAL_DISTANCE,
hideOnScroll = false,
placement = "fixed",
glowColorA,
glowColorB,
blur,
springStiffness = DEFAULT_SPRING_STIFFNESS,
springDamping = DEFAULT_SPRING_DAMPING,
className,
}: GlassTopBarProps) {
const { scrollY } = useScroll(
scrollContainerRef ? { container: scrollContainerRef } : undefined,
);
// 0 at the top of the page, 1 once `revealDistance` px have scrolled
// under the bar. useTransform re-reads the range every render, so the
// distance can change live.
const distance = Math.max(1, revealDistance);
const reveal = useTransform(scrollY, [0, distance], [0, 1]);
// "Large title feel": the title sits a touch larger while the bar is
// still transparent and settles to its compact size with the glass.
const titleScale = useTransform(reveal, [0, 1], [1.08, 1]);
const [hidden, setHidden] = useState(false);
const lastY = useRef<number | null>(null);
useMotionValueEvent(scrollY, "change", (y) => {
const prev = lastY.current;
lastY.current = y;
// The first event only sets the baseline: a programmatic jump on mount
// (restored scroll position, a preview starting mid-page) is not the
// user scrolling down.
if (!hideOnScroll || prev === null) return;
const delta = y - prev;
if (Math.abs(delta) < HIDE_DELTA) return;
// Never hide while the page top is still under the bar.
setHidden(delta > 0 && y > distance);
});
const isHidden = hideOnScroll && hidden;
const actions = trailing.slice(0, 2);
const spring = {
type: "spring",
stiffness: springStiffness,
damping: springDamping,
mass: 0.9,
} as const;
// The CSS module carries the spec's defaults; custom properties are only
// set for non-default values so the default render matches the stylesheet.
// Both side slots get the width of the wider one, so the title column is
// centered on the bar and is the one that shrinks (ellipsis) when space
// runs out — never the buttons.
const sideCount = Math.max(leading ? 1 : 0, actions.length);
const vars: CSSVars = {
"--gtb-side": `${sideCount === 0 ? 0 : sideCount * 40 + (sideCount - 1) * 6}px`,
};
if (glowColorA && glowColorA.toLowerCase() !== DEFAULT_GLOW_A) {
vars["--gtb-glow-a"] = glowColorA;
vars["--gtb-glow-a-rim"] = glowColorA;
vars["--gtb-glow-a-soft"] = tint(glowColorA, 60);
vars["--gtb-glow-a-bottom"] = tint(glowColorA, 75);
}
if (glowColorB && glowColorB.toLowerCase() !== DEFAULT_GLOW_B) {
vars["--gtb-glow-b"] = glowColorB;
}
if (blur !== undefined && blur !== DEFAULT_BLUR) {
vars["--gtb-blur"] = `${blur}px`;
}
const renderButton = (action: GlassTopBarAction, key?: string) => {
const Icon = action.icon;
return (
<button
key={key}
type="button"
className={styles.lens}
aria-label={action.label}
onClick={action.onClick}
>
<Icon size={20} strokeWidth={1.8} aria-hidden />
</button>
);
};
return (
// Follows the OS "reduce motion" setting: hide / show then switches
// instantly instead of sliding.
<MotionConfig reducedMotion="user">
<motion.header
className={className ? `${styles.root} ${className}` : styles.root}
data-placement={placement}
data-hidden={isHidden || undefined}
style={vars}
initial={false}
animate={isHidden ? { y: "-150%", opacity: 0 } : { y: "0%", opacity: 1 }}
transition={spring}
// A keyboard user tabbing into a hidden bar must not land on an
// invisible control: focus brings it back.
onFocusCapture={() => setHidden(false)}
>
{/* Layer 0: pastel glow. An element rather than ::before because
its opacity follows the scroll position. */}
<motion.div
className={styles.glow}
style={{ opacity: reveal }}
aria-hidden
/>
{/* Layer 1: the glass. Fading its opacity fades the backdrop blur
with it, so the blur strength itself stays in the stylesheet. */}
<motion.div
className={styles.glass}
style={{ opacity: reveal }}
aria-hidden
/>
<div className={styles.slot} data-side="leading">
{leading ? renderButton(leading) : null}
</div>
<motion.div className={styles.titleBlock} style={{ scale: titleScale }}>
<span className={styles.title}>{title}</span>
{subtitle ? <span className={styles.subtitle}>{subtitle}</span> : null}
</motion.div>
<div className={styles.slot} data-side="trailing">
{actions.map((action, i) => renderButton(action, `${action.label}-${i}`))}
</div>
</motion.header>
</MotionConfig>
);
}
export default GlassTopBar;
```
#### `components/glass-top-bar/GlassTopBar.module.css`
```css
/* ==========================================================================
Floating glass top bar (counterpart of GlassBottomTabBar)
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). The glow and glass fade in with scroll;
the lens buttons are always visible so the bar still reads at the top.
========================================================================== */
.root {
/* Spec colors. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--gtb-glow-a: #ffaac8; /* pink: back glow */
--gtb-glow-b: #beafff; /* lavender: back glow */
--gtb-glow-a-rim: #ffaacd; /* pink: outer light of bar and lenses */
--gtb-glow-a-soft: #ffc8dc; /* lighter pink: bottom glow */
--gtb-glow-a-bottom: #ffbed7; /* pink pooled along the bar's bottom edge */
--gtb-blur: 30px;
position: fixed;
left: 14px;
right: 14px;
/* --gtb-top is intentionally not declared here: CSS Modules are unlayered
and would beat a Tailwind `[--gtb-top:…]` utility, so it stays an
override hook (e.g. to clear a mockup's status bar). */
top: var(--gtb-top, calc(10px + env(safe-area-inset-top, 0px)));
z-index: 50;
isolation: isolate;
display: grid;
/* Equal side slots (--gtb-side, set by the component) keep the title
centered; the title column is the only one that shrinks. */
grid-template-columns: var(--gtb-side, 40px) minmax(0, 1fr) var(--gtb-side, 40px);
align-items: center;
gap: 8px;
height: 52px;
padding: 0 6px;
border-radius: 9999px;
color: #fff;
font-family:
-apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
}
/* Phone mockups / previews: sit at the top of a positioned ancestor. */
.root[data-placement="absolute"] {
position: absolute;
}
.root[data-hidden] {
pointer-events: none;
}
/* --- Layer 0: pastel light behind the bar --- */
.glow {
position: absolute;
z-index: -2;
inset: -22px -10px -18px;
border-radius: 9999px;
pointer-events: none;
background:
radial-gradient(
55% 80% at 22% 70%,
color-mix(in srgb, var(--gtb-glow-a) 45%, transparent),
transparent 70%
),
radial-gradient(
50% 80% at 78% 30%,
color-mix(in srgb, var(--gtb-glow-b) 40%, transparent),
transparent 70%
),
radial-gradient(
70% 60% at 50% 100%,
color-mix(in srgb, var(--gtb-glow-a-soft) 25%, transparent),
transparent 70%
);
filter: blur(22px);
}
/* --- 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. */
.glass {
position: absolute;
z-index: -1;
inset: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
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(--gtb-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(--gtb-glow-a-rim) 16%, transparent),
0 18px 48px rgba(0, 0, 0, 0.32);
/* framer-motion writes opacity every scroll frame. */
will-change: opacity;
}
/* Reflection on the upper half */
.glass::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
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) */
.glass::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--gtb-glow-a-bottom) 0%, transparent) 50%,
color-mix(in srgb, var(--gtb-glow-a-bottom) 12%, transparent) 100%
);
}
/* Without backdrop-filter, trade transparency for legible text */
@supports not (backdrop-filter: blur(1px)) {
.glass {
background: linear-gradient(
180deg,
rgba(70, 60, 78, 0.94) 0%,
rgba(40, 34, 48, 0.94) 100%
);
}
}
/* --- Button slots --- */
.slot {
display: flex;
align-items: center;
gap: 6px;
}
.slot[data-side="trailing"] {
justify-content: flex-end;
}
/* --- Layer 2: lens buttons ---
Always visible (not tied to the reveal), so at the page top the bar is
anchored by its lenses alone, like iOS. */
.lens {
position: relative;
display: grid;
place-items: center;
flex: none;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: 9999px;
color: #fff;
cursor: pointer;
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(--gtb-glow-a-rim) 18%, transparent);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
transition: transform 0.12s ease;
}
.lens:active {
transform: scale(0.92);
}
.lens:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
/* --- Title --- */
.titleBlock {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
max-width: 100%;
text-align: center;
pointer-events: none;
}
.title {
max-width: 100%;
overflow: hidden;
font-size: 17px;
font-weight: 600;
line-height: 1.25;
letter-spacing: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
.subtitle {
max-width: 100%;
overflow: hidden;
margin-top: 1px;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
text-overflow: ellipsis;
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/glass-top-bar.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { GlassTopBar } from "@/components/glass-top-bar";
import { ChevronLeft, Search, Ellipsis } from "lucide-react";
// TODO: trailing の onClick に処理をつないでください(label はボタンの読み上げ名になります)
// TODO: 既定(fixed)ではバーが本文に重なるので、ページ側で padding-top を確保してください。コンテナ内でスクロールするページなら scrollContainerRef にそのスクロール要素の ref を渡します
<GlassTopBar
title="ライブラリ"
subtitle=""
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
revealDistance={60}
hideOnScroll={false}
springStiffness={380}
springDamping={32}
leading={{ icon: ChevronLeft, label: "戻る", onClick: () => history.back() }}
trailing={[
{ icon: Search, label: "検索", onClick: () => {} },
{ icon: Ellipsis, label: "その他の操作", onClick: () => {} },
]}
/>
```
### 4. 組み込み手順
1. ページのコンポーネント(`"use client"`)に `<GlassTopBar title="…" leading={{ icon: ChevronLeft, label: "戻る", onClick: () => router.back() }} />` を置く。右のボタンは `trailing`(最大 2 つ、3 つ目以降は描画されない)。アイコンは `lucide-react` から選び、`label` は必ず意味のある日本語にする(アイコンだけのボタンの読み上げ名になる)。
2. `app/layout.tsx` で viewport に `viewportFit: "cover"` を指定する(GlassBottomTabBar を入れていれば設定済み)。これが無いと `env(safe-area-inset-top)` が 0 になり、iPhone のノッチ / Dynamic Island にバーが重なる。
3. 既定(`placement="fixed"`)ではバーが本文の上に浮くので、本文側に `padding-top: calc(72px + env(safe-area-inset-top))` 程度を確保する。iOS 風にするなら本文の先頭に大きな見出し(28〜34px / 700)を置くと、スクロールで見出しがバーの下に潜り、バーのガラスが現れる流れになる。
4. ページが `window` ではなくコンテナ(`overflow-y: auto` の要素)の中でスクロールする場合は、その要素の ref を `scrollContainerRef` に渡す。
5. 透明 → ガラスに切り替わる距離は `revealDistance`(既定 60px)。読む画面で上部を広く使いたい場合は `hideOnScroll` を付ける(下スクロールで退避、上スクロールで復帰)。
6. ステータスバー付きのモックアップなどで上端位置を変えたい場合は、CSS 変数 `--gtb-top` を `className`(例: Tailwind の `[--gtb-top:48px]`)で渡す。
7. `npm run build` が通ることを確認し、実機(iPhone)で「最上部では透明」「スクロールでガラスと発光が現れる」「ノッチと重ならない」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| Chrome でぼかしが一切効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| スクロールしてもガラスが現れない | `scrollContainerRef` が実際にスクロールしている要素を指していない(または window がスクロールしていない) | `overflow-y: auto` を持つ要素の ref を渡す。window でスクロールするページでは渡さない |
| `--gtb-top` を Tailwind で渡しても効かない | `.root` に既定値を宣言すると、レイヤー外の CSS Modules が `@layer utilities` に常に勝つ | `.root` では `top: var(--gtb-top, calc(10px + env(safe-area-inset-top)))` と参照だけにし、宣言しない |
| ガラスのフェードでぼかしが消える / ちらつく | 親(ヘッダー)の `opacity` やフィルタで backdrop root ができ、背後が見えなくなる | スクロール連動の不透明度はガラス層(`.glass`)と発光層だけにかける。ヘッダー自体は隠すときだけフェードする |
| マウント直後にバーが隠れる | 復元されたスクロール位置へのジャンプを「下スクロール」と判定している | 最初のスクロールイベントは基準値の記録だけにする(同梱の実装どおり) |
| 視差効果オフでもスライドする | 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-top-bar/index.tsx`(`"use client"`)+ `GlassTopBar.module.css`
- props: `title`(必須)、`subtitle`(空文字は非表示)、`leading` / `trailing[]`(`{ icon: LucideIcon, label, onClick }`、trailing は先頭 2 つだけ描画)、`scrollContainerRef?: RefObject<HTMLElement | null>`、`revealDistance`(既定 60)、`hideOnScroll`(既定 false)、`placement: "fixed" | "absolute"`、`glowColorA` / `glowColorB`、`blur`、`springStiffness` / `springDamping`、`className`
- ルート: `<header>`、`position: fixed; left: 14px; right: 14px; top: var(--gtb-top, calc(10px + env(safe-area-inset-top)))`、`z-index: 50`(GlassBottomSheet の 60 より下)、`isolation: isolate`。`placement="absolute"` では `position: absolute`
### 見た目 = GlassBottomTabBar と同じグラスモーフィズム
層構成は「発光 → ガラス → レンズ(ボタン)→ アイコン」。硬い 1px の黒縁・濃い落ち影は使わない。
- **レイアウト**: 高さ 52px、`border-radius: 9999px`、padding `0 6px`、`grid-template-columns: var(--gtb-side) minmax(0, 1fr) var(--gtb-side)`。`--gtb-side` は左右で多い方のボタン数から算出(`n × 40px + (n − 1) × 6px`)してインラインで渡す → タイトルは常にバー中央、長いタイトルはボタンではなくタイトル列が縮んで省略される、gap 8px
- **層 0 発光(.glow、要素)**: `inset: -22px -10px -18px`、ピンク `color-mix(#ffaac8 45%)`(`55% 80% at 22% 70%`)とラベンダー `#beafff 40%`(`50% 80% at 78% 30%`)、下辺に `#ffc8dc 25%`(`70% 60% at 50% 100%`)。`filter: blur(22px)`。不透明度をスクロールに連動させるため疑似要素ではなく要素にする
- **層 1 ガラス(.glass、`inset: 0`)**:
- 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 40% → 0 at 58%)、`::after` 下辺にピンク `rgba(255,190,215,.12)`(50% から)
- `@supports not (backdrop-filter: blur(1px))` では `rgba(70,60,78,.94) → rgba(40,34,48,.94)`
- **層 2 レンズボタン**: 40px 円、lucide アイコン 20px / strokeWidth 1.8。白 .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)`。**スクロール位置に関係なく常に表示**(最上部ではレンズだけがバーの位置を示す)
- **タイトル**: 17px / 600、白、1 行で省略(`text-overflow: ellipsis`)。サブタイトル 12px / 500 / 白 .64
- フォント: `-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto, sans-serif`
### モーション
- `useScroll({ container: scrollContainerRef })`(未指定なら window)の `scrollY` から `reveal = useTransform(scrollY, [0, revealDistance], [0, 1])`
- 発光とガラスの `opacity` を `reveal` に連動(ガラスの不透明度ごとぼかしもフェードする)。タイトルは `scale` 1.08 → 1 で「最上部では大きめ」の感触を出す
- `hideOnScroll`: `useMotionValueEvent(scrollY, "change")` で前回値との差分を見る。差が 4px 未満は無視、下方向かつ `scrollY > revealDistance` で隠し、上方向で戻す。最初のイベントは基準値の記録だけ
- 隠すときはヘッダーを `y: "-150%", opacity: 0`、スプリング `stiffness: 380, damping: 32, mass: 0.9`。隠れている間は `pointer-events: none`
- `<MotionConfig reducedMotion="user">` で包む
### アクセシビリティ
- ルートは `<header>`。ボタンは `<button type="button" aria-label={label}>`、アイコンは `aria-hidden`
- `:focus-visible` は白 2px アウトライン(offset 2px)。`touch-action: manipulation`、`-webkit-tap-highlight-color: transparent`、押下時 `scale(.92)`
- 隠れている間に Tab でボタンへフォーカスが入ったらバーを戻す(`onFocusCapture`)。見えないボタンにフォーカスが乗らない
### 受け入れ条件
- ページ最上部ではバーが透明でレンズボタンとタイトルだけが見え、スクロールに合わせてガラスと発光が現れる
- ガラス越しに背後の色がぼけて乗る。Chrome / Safari の両方でぼかしが効く
- `hideOnScroll` で下スクロール時に隠れ、上スクロールで戻る。マウント直後に勝手に隠れない
- タイトルが長くても 1 行で省略され、左右のボタンと重ならない
- iPhone のノッチ / Dynamic Island と重ならないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/glass-top-bar.json生成コード
import { GlassTopBar } from "@/components/glass-top-bar";
import { ChevronLeft, Search, Ellipsis } from "lucide-react";
// TODO: trailing の onClick に処理をつないでください(label はボタンの読み上げ名になります)
// TODO: 既定(fixed)ではバーが本文に重なるので、ページ側で padding-top を確保してください。コンテナ内でスクロールするページなら scrollContainerRef にそのスクロール要素の ref を渡します
<GlassTopBar
title="ライブラリ"
subtitle=""
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
revealDistance={60}
hideOnScroll={false}
springStiffness={380}
springDamping={32}
leading={{ icon: ChevronLeft, label: "戻る", onClick: () => history.back() }}
trailing={[
{ icon: Search, label: "検索", onClick: () => {} },
{ icon: Ellipsis, label: "その他の操作", onClick: () => {} },
]}
/>