入力
GlassSlider
Motion
iOS コントロールセンター風のガラススライダー — 太いすりガラスのカプセルにピンク → ラベンダーに光る塗り、レンズのつまみ、トラック内アイコン。どこからでもドラッグでき、押下中はふにっとつぶれる。キーボード操作対応。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約30 KB)。
# GlassSlider をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: GlassSlider
- 説明: iOS コントロールセンター風のガラススライダー — 太いすりガラスのカプセルにピンク → ラベンダーに光る塗り、レンズのつまみ、トラック内アイコン。どこからでもドラッグでき、押下中はふにっとつぶれる。キーボード操作対応。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/glass-slider/index.tsx`, `components/glass-slider/GlassSlider.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-slider/index.tsx`
```tsx
"use client";
import {
useEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
} from "react";
import {
MotionConfig,
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "framer-motion";
import type { LucideIcon } from "lucide-react";
import styles from "./GlassSlider.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type GlassSliderProps = {
/** Controlled value. Pair with `onValueChange`. */
value?: number;
/** Initial value when uncontrolled. Defaults to `min`. */
defaultValue?: number;
/** Fires on every change (each drag move, each key press). */
onValueChange?: (value: number) => void;
/** Fires once when a change is finished: pointer released, or a key press. */
onValueCommit?: (value: number) => void;
min?: number;
max?: number;
step?: number;
/**
* "lens" (default): a glass lens bead rides the fill edge.
* "none": Control Center style — the fill edge itself is the handle.
*/
thumb?: "lens" | "none";
/** Value label inside the track, on the trailing side. */
showValue?: boolean;
/** Formats the label and aria-valuetext. Defaults to the plain number. */
formatValue?: (value: number) => string;
/** Icon inside the track at the leading end (e.g. Volume1, SunDim). */
iconStart?: LucideIcon;
/** Icon inside the track at the trailing end (e.g. Volume2, Sun). */
iconEnd?: LucideIcon;
disabled?: boolean;
/** Glow color A — pink: start of the fill and the light behind the track. */
glowColorA?: string;
/** Glow color B — lavender: end of the fill and the light behind the track. */
glowColorB?: string;
/** Backdrop blur of the track's glass in px. */
blur?: number;
/** Spring stiffness of the fill (click / keys) and the squish. */
springStiffness?: number;
/** Spring damping of the fill (click / keys) and the squish. */
springDamping?: number;
"aria-label"?: string;
"aria-labelledby"?: 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 = 32;
/** Track height = lens size + 2 × inset. Mirrors the CSS. */
const TRACK_HEIGHT = 44;
const LENS_INSET = 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)`;
const clamp = (v: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, v));
/** Decimal places of the step, so 0.1 steps don't render as 0.30000000000000004. */
const decimalsOf = (step: number) => (String(step).split(".")[1] ?? "").length;
/**
* Frosted-glass slider in the iOS Control Center mold: a thick glass
* capsule whose filled part glows pink → lavender. Drag anywhere on the
* track (pointer capture keeps the drag alive off the track); the capsule
* squishes a little while held and springs back on release.
*/
export function GlassSlider({
value,
defaultValue,
onValueChange,
onValueCommit,
min = 0,
max = 100,
step = 1,
thumb = "lens",
showValue = false,
formatValue,
iconStart: IconStart,
iconEnd: IconEnd,
disabled = false,
glowColorA,
glowColorB,
blur,
springStiffness = DEFAULT_SPRING_STIFFNESS,
springDamping = DEFAULT_SPRING_DAMPING,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
className,
}: GlassSliderProps) {
// Guard against a collapsed / inverted range or a non-positive step.
const hi = max > min ? max : min + 1;
const safeStep = step > 0 ? step : 1;
const decimals = decimalsOf(safeStep);
const snap = (raw: number) =>
clamp(
Number(
(min + Math.round((raw - min) / safeStep) * safeStep).toFixed(decimals),
),
min,
hi,
);
const isControlled = value !== undefined;
const [internal, setInternal] = useState(() => defaultValue ?? min);
const current = snap(isControlled ? value : internal);
const fraction = (current - min) / (hi - min);
const [dragging, setDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
// "press" animates the fill to the pointer; "drag" makes it follow 1:1.
const pointerModeRef = useRef<"idle" | "press" | "drag">("idle");
const lastValueRef = useRef(current);
const reduceMotion = useReducedMotion();
const spring = {
type: "spring",
stiffness: springStiffness,
damping: springDamping,
mass: 0.9,
} as const;
// The fill is driven by a motion value so clicks and keys glide with the
// spring while an active drag tracks the finger without lag.
const progress = useMotionValue(fraction);
useEffect(() => {
if (pointerModeRef.current === "drag" || reduceMotion) {
progress.jump(fraction);
return;
}
const controls = animate(progress, fraction, {
type: "spring",
stiffness: springStiffness,
damping: springDamping,
mass: 0.9,
});
return () => controls.stop();
}, [fraction, progress, reduceMotion, springStiffness, springDamping]);
// With a lens the fill always reaches the lens' far edge, so the bead
// sits on the fill even at the minimum; without one the fill is the
// handle and empties completely.
// Both are built unconditionally (hook order); `thumb` picks one below.
const lensFillWidth = useTransform(
progress,
(p) => `calc(${p * 100}% + ${TRACK_HEIGHT * (1 - p)}px)`,
);
const plainFillWidth = useTransform(progress, (p) => `${p * 100}%`);
const fillWidth = thumb === "lens" ? lensFillWidth : plainFillWidth;
const lensLeft = useTransform(
progress,
(p) => `calc(${p * 100}% + ${LENS_INSET - TRACK_HEIGHT * p}px)`,
);
const setValue = (next: number) => {
lastValueRef.current = next;
if (next === current) return;
if (!isControlled) setInternal(next);
onValueChange?.(next);
};
const valueFromPointer = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return current;
// With a lens, the lens center follows the pointer, so the usable span
// is the track minus one lens width.
const p =
thumb === "lens"
? (clientX - rect.left - TRACK_HEIGHT / 2) / (rect.width - TRACK_HEIGHT)
: (clientX - rect.left) / rect.width;
return snap(min + clamp(p, 0, 1) * (hi - min));
};
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (disabled || (event.pointerType === "mouse" && event.button !== 0)) return;
event.currentTarget.setPointerCapture(event.pointerId);
pointerModeRef.current = "press";
setDragging(true);
setValue(valueFromPointer(event.clientX));
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (pointerModeRef.current === "idle") return;
pointerModeRef.current = "drag";
setValue(valueFromPointer(event.clientX));
};
const endDrag = () => {
if (pointerModeRef.current === "idle") return;
pointerModeRef.current = "idle";
setDragging(false);
onValueCommit?.(lastValueRef.current);
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const bigStep = Math.max(safeStep, (hi - min) / 10);
let next: number;
switch (event.key) {
case "ArrowRight":
case "ArrowUp":
next = current + safeStep;
break;
case "ArrowLeft":
case "ArrowDown":
next = current - safeStep;
break;
case "PageUp":
next = current + bigStep;
break;
case "PageDown":
next = current - bigStep;
break;
case "Home":
next = min;
break;
case "End":
next = hi;
break;
default:
return;
}
event.preventDefault();
const snapped = snap(next);
setValue(snapped);
onValueCommit?.(snapped);
};
const text = formatValue ? formatValue(current) : current.toFixed(decimals);
// 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["--gsl-glow-a"] = glowColorA;
vars["--gsl-glow-a-rim"] = glowColorA;
vars["--gsl-glow-a-soft"] = tint(glowColorA, 60);
vars["--gsl-glow-a-bottom"] = tint(glowColorA, 75);
}
if (glowColorB && glowColorB.toLowerCase() !== DEFAULT_GLOW_B) {
vars["--gsl-glow-b"] = glowColorB;
}
if (blur !== undefined && blur !== DEFAULT_BLUR) {
vars["--gsl-blur"] = `${blur}px`;
}
return (
// Follows the OS "reduce motion" setting: no squish, the fill jumps.
<MotionConfig reducedMotion="user">
<div
className={className ? `${styles.root} ${className}` : styles.root}
data-glass-slider
data-dragging={dragging || undefined}
data-disabled={disabled || undefined}
style={vars}
>
<motion.div
ref={trackRef}
className={styles.track}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-valuemin={min}
aria-valuemax={hi}
aria-valuenow={current}
aria-valuetext={text}
aria-orientation="horizontal"
aria-disabled={disabled || undefined}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
animate={
dragging ? { scaleX: 1.015, scaleY: 0.92 } : { scaleX: 1, scaleY: 1 }
}
transition={spring}
>
<motion.div className={styles.fill} style={{ width: fillWidth }} aria-hidden />
{thumb === "lens" ? (
<motion.div className={styles.lens} style={{ left: lensLeft }} aria-hidden />
) : null}
{IconStart || IconEnd || showValue ? (
<div className={styles.content} aria-hidden>
{IconStart ? (
<IconStart className={styles.icon} size={18} strokeWidth={2} />
) : null}
<span className={styles.spacer} />
{showValue ? <span className={styles.value}>{text}</span> : null}
{IconEnd ? (
<IconEnd className={styles.icon} size={18} strokeWidth={2} />
) : null}
</div>
) : null}
</motion.div>
</div>
</MotionConfig>
);
}
export default GlassSlider;
```
#### `components/glass-slider/GlassSlider.module.css`
```css
/* ==========================================================================
Glass slider — iOS Control Center style, GlassBottomTabBar's grammar
A thick white frosted-glass capsule over a dark page with a soft pastel
(pink → lavender) glow behind it. The filled part is the glow itself,
brought onto the glass. No hard 1px dark borders, no heavy drop shadows
— depth comes from light and layers (glow → glass → fill → lens → icons).
========================================================================== */
.root {
/* Spec colors. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--gsl-glow-a: #ffaac8; /* pink: back glow + fill start */
--gsl-glow-b: #beafff; /* lavender: back glow + fill end */
--gsl-glow-a-rim: #ffaacd; /* pink: outer light of the track */
--gsl-glow-a-soft: #ffc8dc; /* lighter pink: bottom glow */
--gsl-glow-a-bottom: #ffbed7; /* pink pooled along the bottom edge */
--gsl-blur: 30px;
position: relative;
isolation: isolate;
width: 100%;
font-family:
-apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
color: #fff;
}
/* Layer 1: pastel light behind the track — two offset blurred ellipses.
Brightens a little while the slider is held. */
.root::before {
content: "";
position: absolute;
z-index: -1;
inset: -12px -8px -16px;
border-radius: 9999px;
pointer-events: none;
background:
radial-gradient(
60% 70% at 18% 70%,
color-mix(in srgb, var(--gsl-glow-a) 45%, transparent),
transparent 70%
),
radial-gradient(
55% 65% at 84% 30%,
color-mix(in srgb, var(--gsl-glow-b) 40%, transparent),
transparent 70%
),
radial-gradient(
70% 50% at 50% 100%,
color-mix(in srgb, var(--gsl-glow-a-soft) 25%, transparent),
transparent 70%
);
filter: blur(22px);
opacity: 0.85;
transition: opacity 0.3s ease;
}
.root[data-dragging]::before {
opacity: 1;
}
.root[data-disabled] {
opacity: 0.45;
}
/* --- Layer 2: the track (white frosted glass) ---
Rim = inset white box-shadow, not a border. framer-motion scales it
for the squish while held. */
.track {
position: relative;
isolation: isolate;
overflow: hidden;
height: 44px;
border-radius: 9999px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.17) 0%,
rgba(255, 255, 255, 0.09) 100%
);
/* Unprefixed only: the build adds -webkit-, and writing both makes
Lightning CSS drop the unprefixed one (no blur in Chrome). */
backdrop-filter: blur(var(--gsl-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(--gsl-glow-a-rim) 16%, transparent),
0 18px 48px rgba(0, 0, 0, 0.32);
cursor: pointer;
outline: none;
user-select: none;
/* Horizontal drags drive the slider; vertical swipes still scroll the page. */
touch-action: pan-y;
-webkit-tap-highlight-color: transparent;
will-change: transform;
}
.root[data-disabled] .track {
cursor: default;
}
/* Reflection on the upper part — above the fill so the glow reads as
being under the glass, not painted on top of it. */
.track::before {
content: "";
position: absolute;
z-index: 2;
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) 45%,
rgba(255, 255, 255, 0) 70%
);
}
/* A little pink pooled along the bottom edge */
.track::after {
content: "";
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--gsl-glow-a-bottom) 0%, transparent) 50%,
color-mix(in srgb, var(--gsl-glow-a-bottom) 12%, transparent) 100%
);
}
/* Without backdrop-filter, trade transparency for contrast */
@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%
);
}
}
.track:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: 3px;
}
/* --- Layer 3: the fill — the back glow brought onto the glass. --- */
.fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--gsl-glow-a) 92%, white) 0%,
var(--gsl-glow-b) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
inset -8px 0 16px rgba(255, 255, 255, 0.28);
opacity: 0.9;
will-change: width;
}
/* --- Layer 4: lens bead at the fill edge (thumb="lens") ---
Same lens recipe as the tab bar's active pill, a touch denser so it
reads over the bright fill. */
.lens {
position: absolute;
top: 4px;
width: 36px;
height: 36px;
border-radius: 9999px;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0.06) 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(--gsl-glow-a-rim) 18%, transparent);
will-change: left;
}
/* --- Layer 5: icons + value label, above everything but the reflection. --- */
.content {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 0 13px;
pointer-events: none;
}
.icon,
.value {
flex: none;
color: rgba(255, 255, 255, 0.95);
/* Keeps white legible where it crosses the pale pastel fill. */
filter: drop-shadow(0 1px 2px rgba(60, 30, 70, 0.35));
}
.spacer {
flex: 1;
}
.value {
min-width: 3ch;
font-size: 13px;
font-weight: 600;
line-height: 1;
text-align: right;
font-variant-numeric: tabular-nums;
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/glass-slider.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { GlassSlider } from "@/components/glass-slider";
import { useState } from "react";
import { Volume1, Volume2 } from "lucide-react";
// TODO: "use client" のコンポーネント内で const [value, setValue] = useState(60); を用意してください
// TODO: 保存や API 送信は onValueCommit(ドラッグを離した時・キー操作時に 1 回)につなぐと、ドラッグ中に連打されません。aria-label は用途に合わせて変えてください
<GlassSlider
min={0}
max={100}
step={1}
thumb="lens"
showValue={false}
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
springStiffness={380}
springDamping={32}
value={value}
onValueChange={setValue}
iconStart={Volume1}
iconEnd={Volume2}
aria-label="音量"
/>
```
### 4. 組み込み手順
1. スライダーを置くコンポーネント(`"use client"`)で `const [value, setValue] = useState(60)` を持ち、`<GlassSlider value={value} onValueChange={setValue} aria-label="音量" />` を置く。状態を持たなくてよい場合は `defaultValue={60}` だけでも動く(非制御)。
2. 範囲は `min` / `max` / `step`(既定 0 / 100 / 1)。ドラッグもキー操作も `step` 単位に丸められる。
3. 保存・API 送信などの重い処理は `onValueCommit` につなぐ。ドラッグを離した時と、キー操作ごとに 1 回だけ呼ばれる(`onValueChange` はドラッグ中に毎フレーム呼ばれる)。
4. アイコンは lucide-react のコンポーネントを `iconStart` / `iconEnd` に渡す(例: 音量 `Volume1` / `Volume2`、明るさ `SunDim` / `Sun`)。表示用の値ラベルは `showValue`、書式は `formatValue={(v) => v + "%"}` のように変える。
5. 見出しテキストがある場合は `aria-labelledby` でその id を指す。無ければ `aria-label` を必ず渡す(スクリーンリーダーが何のスライダーか読み上げられない)。
6. 幅は親いっぱい(`width: 100%`)、高さは 44px 固定。暗い背景の上に置く前提のガラスなので、明るい背景ではガラスの白が見えにくい。
7. `npm run build` が通ることを確認し、実機で「横ドラッグで値が動き、縦スワイプではページがスクロールする」「押している間トラックがわずかにつぶれ、離すとばねで戻る」「Tab でフォーカスリングが出て矢印キーで動く」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| Chrome でぼかしが一切効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| 指がトラックから外れるとドラッグが止まる | `pointermove` をトラック上でしか受けていない | `pointerdown` で `setPointerCapture` する処理を外さない |
| スマホで横ドラッグするとページが動く / 縦スクロールできない | `touch-action` の指定違い | トラックは `touch-action: pan-y` のまま(横はスライダー、縦はページ) |
| ドラッグ中に塗りが指から遅れる | ドラッグ中もスプリングで追従させている | ドラッグ中は motion value を `jump()`、クリック / キー操作だけ `animate()` でばね移動 |
| 視差効果オフでもつぶれ・ばねが動く | framer-motion は JS で動くので CSS の reduced-motion が効かない | `<MotionConfig reducedMotion="user">` と `useReducedMotion()` の分岐を外さない |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)、`lucide-react`
- ファイル: `components/glass-slider/index.tsx`(`"use client"`)+ `GlassSlider.module.css`
- 制御 / 非制御: `value` / `defaultValue` / `onValueChange(value)`、`onValueCommit(value)`、`min`(0)/ `max`(100)/ `step`(1)
- その他: `thumb: "lens" | "none"`(既定 `"lens"`)、`showValue`、`formatValue`、`iconStart` / `iconEnd`(`LucideIcon`)、`disabled`、`glowColorA` / `glowColorB` / `blur`、`springStiffness`(380)/ `springDamping`(32)、`aria-label` / `aria-labelledby`
- 値は常に `step` に丸めて `min〜max` にクランプ(`toFixed(step の小数桁)` で浮動小数のノイズを消す)
### 見た目 = GlassBottomTabBar と同じグラスモーフィズム
層構成は「発光 → ガラス → 塗り → レンズ → アイコン」。硬い 1px の黒縁・濃い落ち影は使わない。
- **ルート**: `position: relative; isolation: isolate; width: 100%`、文字色は白
- **層 1 発光(.root::before)**: `inset: -12px -8px -16px`、ピンク `rgba(255,170,200,.45)`(`60% 70% at 18% 70%`)とラベンダー `rgba(190,175,255,.40)`(`55% 65% at 84% 30%`)、下辺に `rgba(255,200,220,.25)`。`filter: blur(22px)`、`opacity: .85`、ドラッグ中は 1(300ms)
- **層 2 トラック(.track)**: 高さ 44px、`border-radius: 9999px`、`overflow: hidden`、`isolation: isolate`
- 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 45% → 0 at 70%)を **塗りより上**(z-index 2)に重ね、塗りがガラスの下にあるように見せる。`::after` 下辺にピンク `rgba(255,190,215,.12)`
- `@supports not (backdrop-filter: blur(1px))` では `rgba(70,60,78,.94) → rgba(40,34,48,.94)`
- **層 3 塗り(.fill)**: 左端から現在値まで。`linear-gradient(90deg, ピンク 92% + 白, ラベンダー)`、`opacity: .9`、`inset 0 1px 0 白 .55` / `inset -8px 0 16px 白 .28`
- `thumb="lens"`: 幅 = `calc(p×100% + 44px×(1−p))`(最小値でもレンズの下に丸い塗りが残る)
- `thumb="none"`: 幅 = `p×100%`(塗りの端がつまみ。最小値で空になる)
- **層 4 レンズ(.lens, thumb="lens" のみ)**: 36px の円、top 4px、left = `calc(p×100% + 4px − 44px×p)`。白 .18 → .06、`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)`
- **層 5 中身(.content)**: 横 flex、padding `0 13px`、gap 8px、`pointer-events: none`。[iconStart] [余白] [値ラベル] [iconEnd]。アイコン 18px / strokeWidth 2、値ラベル 13px / 600 / `tabular-nums`。白 .95 + `drop-shadow(0 1px 2px rgba(60,30,70,.35))` で淡い塗りの上でも読めるようにする
- `disabled` はルートごと `opacity: .45`
- フォント: `-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto, sans-serif`
### モーション
- 塗りとレンズは 0〜1 の motion value(`useMotionValue`)から `useTransform` で幅 / left を作る
- クリック(pointerdown)・キー操作: `animate(mv, p, { type: "spring", stiffness: 380, damping: 32, mass: 0.9 })`
- ドラッグ中(pointermove): `mv.jump(p)` で指に 1:1 追従
- ドラッグ: トラックのどこでも `pointerdown` → `setPointerCapture`。レンズありはレンズの中心が指に来るよう `(x − 22) / (幅 − 44)`、なしは `x / 幅` で値を算出。`pointerup` / `pointercancel` / `lostpointercapture` で終了し `onValueCommit`
- 押している間トラックが `scaleX: 1.015, scaleY: .92` につぶれ、離すと同じスプリングで 1 に戻る
- `<MotionConfig reducedMotion="user">` で包み、`useReducedMotion()` が true なら塗りも `jump()`
### アクセシビリティ
- トラックが `role="slider"`、`tabIndex={0}`(disabled では -1 + `aria-disabled`)、`aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext`(`formatValue` の結果)、`aria-orientation="horizontal"`、`aria-label` か `aria-labelledby`
- キー: →/↑ で +step、←/↓ で −step、PageUp / PageDown で ±範囲の 1/10(最低 1 step)、Home / End で最小 / 最大。各操作で `onValueCommit`
- `:focus-visible` で白 2px アウトライン(offset 3px)。塗り・レンズ・アイコンは `aria-hidden`
- `touch-action: pan-y`、`-webkit-tap-highlight-color: transparent`、`user-select: none`
### 受け入れ条件
- トラックのどこを押しても値がそこへばねで移動し、そのまま横ドラッグすると指に遅れず追従する。指がトラック外に出ても追従が続く
- 押している間トラックがわずかにつぶれ、離すとばねで戻る。背後の発光が少し明るくなる
- 暗い背景の上でガラスが背後の色を拾って透け、塗りがピンク → ラベンダーに光る
- キーボードだけで全操作ができ、スクリーンリーダーが名前と値を読み上げるインストール
npx shadcn@latest add https://anima-js.vercel.app/r/glass-slider.json生成コード
import { GlassSlider } from "@/components/glass-slider";
import { useState } from "react";
import { Volume1, Volume2 } from "lucide-react";
// TODO: "use client" のコンポーネント内で const [value, setValue] = useState(60); を用意してください
// TODO: 保存や API 送信は onValueCommit(ドラッグを離した時・キー操作時に 1 回)につなぐと、ドラッグ中に連打されません。aria-label は用途に合わせて変えてください
<GlassSlider
min={0}
max={100}
step={1}
thumb="lens"
showValue={false}
glowColorA="#ffaac8"
glowColorB="#beafff"
blur={30}
springStiffness={380}
springDamping={32}
value={value}
onValueChange={setValue}
iconStart={Volume1}
iconEnd={Volume2}
aria-label="音量"
/>