入力
GlassSegmentedControl
Motion
GlassBottomTabBar と同じトンマナのセグメントコントロール — すりガラスのカプセルに、スプリングで移動するレンズのピル。radiogroup として矢印キーで選択でき、アイコンも付けられます。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約27 KB)。
# GlassSegmentedControl をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: GlassSegmentedControl
- 説明: GlassBottomTabBar と同じトンマナのセグメントコントロール — すりガラスのカプセルに、スプリングで移動するレンズのピル。radiogroup として矢印キーで選択でき、アイコンも付けられます。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/glass-segmented-control/index.tsx`, `components/glass-segmented-control/GlassSegmentedControl.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-segmented-control/index.tsx`
```tsx
"use client";
import {
useId,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
} from "react";
import { MotionConfig, motion } from "framer-motion";
import type { LucideIcon } from "lucide-react";
import styles from "./GlassSegmentedControl.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type GlassSegmentOption = {
value: string;
label: string;
/** Optional lucide icon drawn before the label. */
icon?: LucideIcon;
};
export type GlassSegmentedControlSize = "sm" | "md" | "lg";
export type GlassSegmentedControlProps = {
options: GlassSegmentOption[];
/** Selected value (controlled). Pair with `onValueChange`. */
value?: string;
/** Initially selected value when uncontrolled (defaults to the first option). */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Track height: sm 36px / md 44px / lg 52px. */
size?: GlassSegmentedControlSize;
/** Stretch to the container width with equal segments. */
fullWidth?: boolean;
/**
* Glow color A — the pink light behind the track, the track's outer light
* and the selected label glow. Any CSS color; softer tints are derived.
*/
glowColorA?: string;
/** Glow color B — the lavender light behind the track. */
glowColorB?: string;
/** Backdrop blur of the track in px. */
blur?: number;
/** Spring stiffness of the selected pill's move between segments. */
springStiffness?: number;
/** Spring damping of the selected pill's move between segments. */
springDamping?: number;
disabled?: boolean;
/** Accessible name of the group. Use this or `aria-labelledby`. */
"aria-label"?: string;
"aria-labelledby"?: string;
className?: string;
};
const DEFAULT_GLOW_A = "#ffaac8";
const DEFAULT_GLOW_B = "#beafff";
const DEFAULT_BLUR = 30;
/** Same feel as the tab bar's pill: a slight overshoot. */
const DEFAULT_SPRING_STIFFNESS = 380;
const DEFAULT_SPRING_DAMPING = 30;
const ICON_SIZE: Record<GlassSegmentedControlSize, number> = {
sm: 14,
md: 16,
lg: 18,
};
/** 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)`;
/**
* Glass segmented control in the GlassBottomTabBar's visual language: a
* frosted capsule track with a lens pill that springs to the selected
* segment. A radiogroup underneath — arrow keys / Home / End move the
* selection, and only the selected segment is in the tab order.
*/
export function GlassSegmentedControl({
options,
value: valueProp,
defaultValue,
onValueChange,
size = "md",
fullWidth = false,
glowColorA,
glowColorB,
blur,
springStiffness = DEFAULT_SPRING_STIFFNESS,
springDamping = DEFAULT_SPRING_DAMPING,
disabled = false,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
className,
}: GlassSegmentedControlProps) {
// layoutId is page-global in framer-motion, so two controls on one page
// would otherwise fling their pills at each other.
const pillId = `${useId()}-segment-pill`;
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [internalValue, setInternalValue] = useState(
defaultValue ?? options[0]?.value,
);
const requested = valueProp !== undefined ? valueProp : internalValue;
// An unknown value (e.g. options changed underneath) still needs one
// reachable segment for the roving tabindex; the first one takes it.
const selectedIndex = Math.max(
0,
options.findIndex((option) => option.value === requested),
);
const hasMatch = options.some((option) => option.value === requested);
const select = (index: number) => {
const option = options[index];
if (!option || disabled) return;
if (valueProp === undefined) setInternalValue(option.value);
if (option.value !== requested) onValueChange?.(option.value);
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const last = options.length - 1;
let next: number | null = null;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = selectedIndex === last ? 0 : selectedIndex + 1;
break;
case "ArrowLeft":
case "ArrowUp":
next = selectedIndex === 0 ? last : selectedIndex - 1;
break;
case "Home":
next = 0;
break;
case "End":
next = last;
break;
}
if (next === null) return;
event.preventDefault();
// Native radio behavior: moving focus also selects.
select(next);
buttonRefs.current[next]?.focus();
};
const spring = {
type: "spring",
stiffness: springStiffness,
damping: springDamping,
mass: 0.9,
} as const;
// The CSS module carries the hand-tuned defaults; custom properties are
// only set for non-default values so the default render is the stylesheet.
const vars: CSSVars = {};
if (glowColorA && glowColorA.toLowerCase() !== DEFAULT_GLOW_A) {
vars["--gsc-glow-a"] = glowColorA;
vars["--gsc-glow-a-rim"] = glowColorA;
vars["--gsc-glow-a-soft"] = tint(glowColorA, 60);
vars["--gsc-glow-a-label"] = tint(glowColorA, 55);
vars["--gsc-glow-a-bottom"] = tint(glowColorA, 75);
}
if (glowColorB && glowColorB.toLowerCase() !== DEFAULT_GLOW_B) {
vars["--gsc-glow-b"] = glowColorB;
}
if (blur !== undefined && blur !== DEFAULT_BLUR) {
vars["--gsc-blur"] = `${blur}px`;
}
const iconSize = ICON_SIZE[size];
return (
// Follows the OS "reduce motion" setting: the pill then jumps instead
// of springing across.
<MotionConfig reducedMotion="user">
<div
className={className ? `${styles.root} ${className}` : styles.root}
data-size={size}
data-full-width={fullWidth || undefined}
data-disabled={disabled || undefined}
style={vars}
>
<div
className={styles.track}
role="radiogroup"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-disabled={disabled || undefined}
>
{options.map((option, index) => {
const { value, label, icon: Icon } = option;
const selected = hasMatch && index === selectedIndex;
return (
<button
key={value}
ref={(node) => {
buttonRefs.current[index] = node;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={index === selectedIndex ? 0 : -1}
disabled={disabled}
className={styles.segment}
data-selected={selected || undefined}
onClick={() => select(index)}
onKeyDown={onKeyDown}
>
{selected ? (
<motion.span
layoutId={pillId}
className={styles.pill}
// Segments can differ in width, so the pill also scales
// on the move; framer only corrects the radius for that
// scale when it is given here rather than in CSS.
style={{ borderRadius: 9999 }}
transition={spring}
/>
) : null}
{Icon ? (
<Icon
className={styles.icon}
size={iconSize}
strokeWidth={1.8}
aria-hidden
/>
) : null}
<span className={styles.label}>{label}</span>
</button>
);
})}
</div>
</div>
</MotionConfig>
);
}
export default GlassSegmentedControl;
```
#### `components/glass-segmented-control/GlassSegmentedControl.module.css`
```css
/* ==========================================================================
Glass segmented control
Same glassmorphism as GlassBottomTabBar: 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 track → lens pill → label).
========================================================================== */
.root {
/* Spec colors. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--gsc-glow-a: #ffaac8; /* pink: back glow */
--gsc-glow-b: #beafff; /* lavender: back glow */
--gsc-glow-a-rim: #ffaacd; /* pink: outer light of track and pill */
--gsc-glow-a-soft: #ffc8dc; /* lighter pink: bottom glow */
--gsc-glow-a-label: #ffc8e1; /* lighter pink: selected label glow */
--gsc-glow-a-bottom: #ffbed7; /* pink pooled along the track's bottom edge */
--gsc-blur: 30px;
--gsc-height: 44px;
--gsc-font-size: 14px;
--gsc-segment-padding: 16px;
position: relative;
isolation: isolate;
display: inline-flex;
max-width: 100%;
font-family:
-apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
}
.root[data-size="sm"] {
--gsc-height: 36px;
--gsc-font-size: 13px;
--gsc-segment-padding: 12px;
}
.root[data-size="lg"] {
--gsc-height: 52px;
--gsc-font-size: 15px;
--gsc-segment-padding: 20px;
}
.root[data-full-width] {
display: flex;
width: 100%;
}
.root[data-disabled] {
opacity: 0.5;
}
/* Layer 0: pastel light behind the track. Two blurred ellipses, offset
left/right, tint the glass from behind. */
.root::before {
content: "";
position: absolute;
z-index: -1;
inset: -14px -10px -18px;
border-radius: 9999px;
pointer-events: none;
background:
radial-gradient(
55% 80% at 22% 70%,
color-mix(in srgb, var(--gsc-glow-a) 45%, transparent),
transparent 70%
),
radial-gradient(
50% 80% at 78% 30%,
color-mix(in srgb, var(--gsc-glow-b) 40%, transparent),
transparent 70%
),
radial-gradient(
70% 60% at 50% 100%,
color-mix(in srgb, var(--gsc-glow-a-soft) 25%, transparent),
transparent 70%
);
filter: blur(22px);
}
/* --- Layer 1: the track (white frosted glass) ---
The rim is an inset white box-shadow, not a border, so the 1px doesn't
smear around the capsule. Only large, soft shadows. */
.track {
position: relative;
isolation: isolate;
overflow: hidden;
display: flex;
flex: 1;
min-width: 0;
height: var(--gsc-height);
padding: 4px;
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(--gsc-blur)) saturate(170%);
/* 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(--gsc-glow-a-rim) 16%, transparent),
0 18px 48px rgba(0, 0, 0, 0.32);
}
/* Reflection on the upper half */
.track::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) */
.track::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--gsc-glow-a-bottom) 0%, transparent) 50%,
color-mix(in srgb, var(--gsc-glow-a-bottom) 12%, transparent) 100%
);
}
/* Without backdrop-filter, trade transparency for legible text */
@supports not (backdrop-filter: blur(1px)) {
.track {
background: linear-gradient(
180deg,
rgba(70, 60, 78, 0.94) 0%,
rgba(40, 34, 48, 0.94) 100%
);
}
}
/* --- Segments --- */
.segment {
position: relative;
z-index: 1; /* above ::before / ::after (reflection, pooled tint) */
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
padding: 0 var(--gsc-segment-padding);
margin: 0;
border: 0;
border-radius: 9999px;
background: none;
color: rgba(255, 255, 255, 0.64);
font: inherit;
font-size: var(--gsc-font-size);
font-weight: 500;
line-height: 1;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
transition:
color 0.2s ease,
transform 0.12s ease;
}
/* Full width: equal columns, and tighter padding so 4–5 segments fit a
phone width. */
.root[data-full-width] .segment {
flex: 1 1 0;
padding: 0 6px;
}
.segment[data-selected] {
color: #fff;
}
.segment:active:not(:disabled) {
transform: scale(0.96);
}
.segment:disabled {
cursor: not-allowed;
}
/* The track clips overflow, so the ring is drawn inside the segment. */
.segment:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: -2px;
}
/* --- Layer 2: selected pill = inner lens (springs between segments) ---
Not painted white: a white rim plus the faintest glass face, so the
tinted glass behind shows through. */
.pill {
position: absolute;
inset: 0;
z-index: -1;
border-radius: 9999px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.05) 100%
);
/* Weaker blur than the track: "slightly sharper through the lens" */
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(--gsc-glow-a-rim) 18%, transparent);
pointer-events: none;
/* framer-motion moves it with transform. Promote it 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 + label --- */
.icon {
flex: none;
transition: filter 0.2s ease;
}
.segment[data-selected] .icon {
filter: drop-shadow(
0 0 6px color-mix(in srgb, var(--gsc-glow-a-label) 55%, transparent)
);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: text-shadow 0.2s ease;
}
.segment[data-selected] .label {
text-shadow: 0 0 10px
color-mix(in srgb, var(--gsc-glow-a-label) 50%, transparent);
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/glass-segmented-control.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { GlassSegmentedControl } from "@/components/glass-segmented-control";
import { useState } from "react";
import { LayoutGrid, Truck, PackageCheck } from "lucide-react";
// TODO: "use client" のコンポーネント内で const [value, setValue] = useState("all"); を用意してください(状態を持たないなら value / onValueChange を外して defaultValue を渡します)
// TODO: options の value / label / icon と aria-label を用途に合わせて差し替えてください(アイコンは lucide-react から選べます)
<GlassSegmentedControl
size="md"
fullWidth={true}
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
disabled={false}
springStiffness={380}
springDamping={30}
options={[
{ value: "all", label: "すべて", icon: LayoutGrid },
{ value: "shipping", label: "配送中", icon: Truck },
{ value: "done", label: "完了", icon: PackageCheck },
]}
value={value}
onValueChange={setValue}
aria-label="注文の状態"
/>
```
### 4. 組み込み手順
1. 置くコンポーネントを `"use client"` にし、`const [value, setValue] = useState("all")` を持って `<GlassSegmentedControl options={…} value={value} onValueChange={setValue} aria-label="…" />` を置く。状態を外に出さなくてよい場合は `value` / `onValueChange` を外して `defaultValue` だけ渡す(非制御)。
2. `options` は `{ value, label, icon? }` の配列。`value` は一意な文字列にする(React の key と選択判定に使う)。`icon` には lucide-react のアイコンコンポーネント(`Truck` など)をそのまま渡す。
3. 必ず `aria-label`(または見出しの id を `aria-labelledby`)を渡す。スクリーンリーダーはこれをグループ名として読み上げる。
4. 暗いページの上で使う前提のガラス。背後が単色の暗い面だとぼかしが見えないので、カラフルなコンテンツや画像の上に置くとトンマナどおりに見える。
5. 4〜5 セグメントをスマホ幅に収めるなら `fullWidth` を付ける(均等幅・余白を詰める)。ラベルは 2〜4 文字程度を推奨。収まらない分は省略記号(…)になる。
6. `npm run build` が通ることを確認し、実機で「タップでピルがばねで移動する」「Tab で選択中セグメントにだけ止まり、矢印キーで選択が動く」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| Chrome でぼかしが一切効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| 同じページの別のコントロールにピルが飛んでいく | framer-motion の `layoutId` はページ全体で共有される | `layoutId` は `useId()` 由来のインスタンス固有の値のままにする。固定文字列にしない |
| ピルがラベルの上に被る / 見えない | ピルの重なり順が崩れている | セグメントは `position: relative; z-index: 1`、ピルは `z-index: -1`(セグメント内のスタッキングコンテキストで文字の背面) |
| フォーカスリングが欠ける | トラックの `overflow: hidden` で外側のアウトラインが切れる | `outline-offset: -2px` で内側に描く |
| 視差効果オフでもピルが移動アニメーションする | 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-segmented-control/GlassSegmentedControl.tsx`(`"use client"`)+ `GlassSegmentedControl.module.css` + `index.ts`
- Props: `options: { value: string; label: string; icon?: LucideIcon }[]`、`value` / `defaultValue` / `onValueChange(value)`(制御・非制御の両対応。非制御の初期値は先頭の option)、`size: "sm" | "md" | "lg"`(既定 md)、`fullWidth`、`glowColorA`(既定 `#ffaac8`)/ `glowColorB`(既定 `#beafff`)、`blur`(既定 30)、`springStiffness`(380)/ `springDamping`(30)、`disabled`、`aria-label` / `aria-labelledby`、`className`
- CSS 変数は `--gsc-` 接頭辞で `.root` に既定値を持ち、コンポーネントは既定と違う値のときだけインラインで上書きする。発光色 A からは `color-mix(in srgb, A n%, white)` で淡い派生色(外光・下辺・ラベル発光)を作る
### 見た目 = GlassBottomTabBar と同じグラスモーフィズム
層構成は「発光 → ガラスのトラック → レンズのピル → ラベル」。硬い 1px の黒縁・濃い落ち影・角は使わない。
- **ルート(.root)**: `display: inline-flex; position: relative; isolation: isolate`。`fullWidth` では `display: flex; width: 100%`。`disabled` で `opacity: .5`
- **層 0 発光(.root::before)**: `inset: -14px -10px -18px`、`z-index: -1`、`filter: blur(22px)`。ピンク 45%(`55% 80% at 22% 70%`)、ラベンダー 40%(`50% 80% at 78% 30%`)、下辺に淡いピンク `#ffc8dc` 25%(`70% 60% at 50% 100%`)
- **層 1 トラック(.track)**: `role="radiogroup"`、高さ sm 36px / md 44px / lg 52px、`padding: 4px`、`border-radius: 9999px`、`overflow: hidden`、横 flex
- 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` 下辺にピンク `#ffbed7` 12%(50% から)
- `@supports not (backdrop-filter: blur(1px))` では `rgba(70,60,78,.94) → rgba(40,34,48,.94)`
- **セグメント(button)**: `role="radio"`、ピル形、横 padding sm 12px / md 16px / lg 20px(`fullWidth` では `flex: 1 1 0` の均等幅で 6px)。文字 sm 13px / md 14px / lg 15px、500、白 .64。選択中は白 + ラベルに `0 0 10px` の淡いピンク発光。アイコンは sm 14 / md 16 / lg 18px、`strokeWidth 1.8`、ラベルの左に gap 6px、選択中は `drop-shadow(0 0 6px …)`。ラベルは `text-overflow: ellipsis`
- 押下時 `scale(.96)` 120ms、`:focus-visible` は白 .9 の 2px アウトラインを `outline-offset: -2px` で内側に
- **層 2 ピル(選択中のレンズ)**: 選択中セグメント内に `position: absolute; inset: 0; z-index: -1`。白 .12 → .05 のグラデーション、`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` に上辺だけの薄い反射。`will-change: transform`
- フォント: `-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto, sans-serif`
### モーション
- ピルは選択中セグメントの中にだけ描画し、`layoutId` でセグメント間を移動させる。`layoutId` は `useId()` から作りインスタンスごとに一意にする
- スプリング `stiffness: 380, damping: 30, mass: 0.9`(少しだけ行き過ぎる)
- `<MotionConfig reducedMotion="user">` で包む(OS の視差効果を減らす設定ではピルが即座に切り替わる)
### アクセシビリティ
- トラックは `role="radiogroup"` + `aria-label` / `aria-labelledby`、各セグメントは `role="radio" aria-checked` の `<button type="button">`
- ロービング tabindex: 選択中のセグメントだけ `tabIndex=0`、他は `-1`(選択値が options に無いときは先頭が 0)
- ArrowRight / ArrowDown で次、ArrowLeft / ArrowUp で前(端で折り返し)、Home で先頭、End で末尾。移動と同時に選択し、そのボタンにフォーカスを移す(ネイティブのラジオと同じ)
- `disabled` では全ボタンを `disabled`、トラックに `aria-disabled`
- マウント時にフォーカスを奪わない。`touch-action: manipulation`、`-webkit-tap-highlight-color: transparent`
### 受け入れ条件
- タップ / クリックでピルがばねで隣のセグメントへ移動し、選択中のラベルが白く光る
- 暗いページのカラフルなコンテンツの上で、トラック越しに背後の色がぼけて乗る
- キーボードだけで選択でき、Tab ではグループ内の 1 箇所にしか止まらない
- 同じページに 2 つ置いても、ピルが互いに干渉しないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/glass-segmented-control.json生成コード
import { GlassSegmentedControl } from "@/components/glass-segmented-control";
import { useState } from "react";
import { LayoutGrid, Truck, PackageCheck } from "lucide-react";
// TODO: "use client" のコンポーネント内で const [value, setValue] = useState("all"); を用意してください(状態を持たないなら value / onValueChange を外して defaultValue を渡します)
// TODO: options の value / label / icon と aria-label を用途に合わせて差し替えてください(アイコンは lucide-react から選べます)
<GlassSegmentedControl
size="md"
fullWidth={true}
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
disabled={false}
springStiffness={380}
springDamping={30}
options={[
{ value: "all", label: "すべて", icon: LayoutGrid },
{ value: "shipping", label: "配送中", icon: Truck },
{ value: "done", label: "完了", icon: PackageCheck },
]}
value={value}
onValueChange={setValue}
aria-label="注文の状態"
/>