入力
GlassSwitch
Motion
iOS サイズのガラストグル — すりガラスのトラックがオンでピンク→ラベンダーに発光し、白いガラスの玉がスプリングで移動、押している間は iOS のように伸びる。ラベル付きで設定行レイアウトにも。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約26 KB)。
# GlassSwitch をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: GlassSwitch
- 説明: iOS サイズのガラストグル — すりガラスのトラックがオンでピンク→ラベンダーに発光し、白いガラスの玉がスプリングで移動、押している間は iOS のように伸びる。ラベル付きで設定行レイアウトにも。
- 実行時に必要な npm パッケージ: framer-motion
- 構成ファイル: `components/glass-switch/index.tsx`, `components/glass-switch/GlassSwitch.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
```
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/glass-switch/index.tsx`
```tsx
"use client";
import { useId, useState, type CSSProperties } from "react";
import { MotionConfig, motion } from "framer-motion";
import styles from "./GlassSwitch.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type GlassSwitchProps = {
/** Controlled state. Leave undefined to let the switch own its state. */
checked?: boolean;
/** Initial state when uncontrolled. */
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
/**
* Text to the left of the switch. When given, the whole row (label +
* description + switch) becomes one full-width tap target.
*/
label?: string;
/** Secondary line under the label (ignored without `label`). */
description?: string;
disabled?: boolean;
/** "md" = iOS size (51×31), "sm" = compact (40×24). */
size?: "sm" | "md";
/** Glow color A — pink end of the ON fill and its outer light. */
glowColorA?: string;
/** Glow color B — lavender end of the ON fill. */
glowColorB?: string;
/** Spring stiffness of the thumb travel / stretch. */
springStiffness?: number;
/** Spring damping of the thumb travel / stretch. */
springDamping?: number;
/** Form field name. Submits `value` while ON, nothing while OFF. */
name?: string;
/** Submitted value while ON (default "on", like a native checkbox). */
value?: string;
/** id of the switch button (auto-generated otherwise). */
id?: string;
/** Accessible name when there is no visible `label`. */
"aria-label"?: string;
className?: string;
};
const DEFAULT_GLOW_A = "#ffaac8";
const DEFAULT_GLOW_B = "#beafff";
const DEFAULT_SPRING_STIFFNESS = 380;
const DEFAULT_SPRING_DAMPING = 30;
/**
* Geometry per size, in px. Kept in JS (not measured from the DOM) so the
* thumb's x / width animate between known numbers. Must match the CSS.
*/
const SIZES = {
md: { width: 51, height: 31, pad: 2, thumb: 27, stretch: 7 },
sm: { width: 40, height: 24, pad: 2, thumb: 20, stretch: 5 },
} as const;
/** 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-sized frosted-glass toggle. The track is white glass; ON fills it
* with a pink → lavender glow, and the white glass bead springs across,
* stretching while pressed like the iOS switch. With `label` it renders
* as a full-width settings row.
*/
export function GlassSwitch({
checked,
defaultChecked = false,
onCheckedChange,
label,
description,
disabled = false,
size = "md",
glowColorA,
glowColorB,
springStiffness = DEFAULT_SPRING_STIFFNESS,
springDamping = DEFAULT_SPRING_DAMPING,
name,
value = "on",
id,
"aria-label": ariaLabel,
className,
}: GlassSwitchProps) {
const autoId = useId();
const switchId = id ?? autoId;
const labelId = `${switchId}-label`;
const descriptionId = `${switchId}-description`;
const [innerChecked, setInnerChecked] = useState(defaultChecked);
const isControlled = checked !== undefined;
const on = isControlled ? checked : innerChecked;
// Drives the iOS-style stretch. Tracked on the outer wrapper so pressing
// the label text stretches the bead too, not only pressing the track.
const [pressed, setPressed] = useState(false);
const toggle = () => {
if (disabled) return;
const next = !on;
if (!isControlled) setInnerChecked(next);
onCheckedChange?.(next);
};
const geometry = SIZES[size];
const travel = geometry.width - geometry.pad * 2 - geometry.thumb;
const stretch = pressed ? geometry.stretch : 0;
// While pressed the bead widens toward the center: from the left edge
// when OFF, and leftward (x pulled back by the same amount) when ON, so
// it never pokes out of the track.
const thumbX = on ? travel - stretch : 0;
const thumbWidth = geometry.thumb + stretch;
const spring = {
type: "spring",
stiffness: springStiffness,
damping: springDamping,
mass: 0.9,
} as const;
// The CSS module carries the defaults; custom properties are only set
// for non-default values so the default render matches the stylesheet.
const vars: CSSVars = {};
if (glowColorA && glowColorA.toLowerCase() !== DEFAULT_GLOW_A) {
vars["--gsw-glow-a"] = glowColorA;
vars["--gsw-glow-a-rim"] = glowColorA;
vars["--gsw-glow-a-soft"] = tint(glowColorA, 60);
}
if (glowColorB && glowColorB.toLowerCase() !== DEFAULT_GLOW_B) {
vars["--gsw-glow-b"] = glowColorB;
}
const pressHandlers = disabled
? {}
: {
onPointerDown: () => setPressed(true),
onPointerUp: () => setPressed(false),
onPointerLeave: () => setPressed(false),
onPointerCancel: () => setPressed(false),
};
const hasLabel = Boolean(label);
const control = (
<span className={styles.control} data-state={on ? "on" : "off"}>
<button
type="button"
id={switchId}
role="switch"
aria-checked={on}
aria-label={hasLabel ? undefined : ariaLabel}
aria-labelledby={hasLabel ? labelId : undefined}
aria-describedby={hasLabel && description ? descriptionId : undefined}
disabled={disabled}
className={styles.track}
data-state={on ? "on" : "off"}
// Enter / Space reach onClick through the native <button>.
onClick={toggle}
>
<span className={styles.fill} aria-hidden />
<motion.span
className={styles.thumb}
aria-hidden
// No mount animation: an ON switch paints already on the right.
initial={false}
animate={{ x: thumbX, width: thumbWidth }}
transition={spring}
/>
</button>
{name && on && !disabled ? (
<input type="hidden" name={name} value={value} />
) : null}
</span>
);
const rootClass = className ? `${styles.root} ${className}` : styles.root;
return (
// Follows the OS "reduce motion" setting: the bead then jumps.
<MotionConfig reducedMotion="user">
{hasLabel ? (
<div
className={`${rootClass} ${styles.row}`}
data-glass-switch
data-size={size}
data-disabled={disabled || undefined}
style={vars}
{...pressHandlers}
>
{/* A label for a <button> forwards clicks to it, so the whole
text area toggles the switch. */}
<label htmlFor={switchId} className={styles.text}>
<span id={labelId} className={styles.label}>
{label}
</span>
{description ? (
<span id={descriptionId} className={styles.description}>
{description}
</span>
) : null}
</label>
{control}
</div>
) : (
<span
className={rootClass}
data-glass-switch
data-size={size}
data-disabled={disabled || undefined}
style={vars}
{...pressHandlers}
>
{control}
</span>
)}
</MotionConfig>
);
}
export default GlassSwitch;
```
#### `components/glass-switch/GlassSwitch.module.css`
```css
/* ==========================================================================
Glass switch — iOS-sized toggle in the GlassBottomTabBar grammar
White frosted glass over a dark page + a soft pastel (pink → lavender)
glow. No hard 1px dark borders, no heavy drop shadows — depth comes
from light and layers (glow → glass track → glow fill → glass bead).
========================================================================== */
.root {
/* Spec colors. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--gsw-glow-a: #ffaac8; /* pink: start of the ON fill, back glow */
--gsw-glow-b: #beafff; /* lavender: end of the ON fill, back glow */
--gsw-glow-a-rim: #ffaacd; /* pink: outer light of track and bead */
--gsw-glow-a-soft: #ffc8dc; /* lighter pink: highlight inside the fill */
/* Geometry — must match SIZES in GlassSwitch.tsx. */
--gsw-width: 51px;
--gsw-height: 31px;
--gsw-pad: 2px;
--gsw-thumb: 27px;
display: inline-flex;
vertical-align: middle;
font-family:
-apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
color: #fff;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.root[data-size="sm"] {
--gsw-width: 40px;
--gsw-height: 24px;
--gsw-thumb: 20px;
}
.root[data-disabled] {
opacity: 0.45;
}
/* --- Row layout (with label): text left, switch right, full width --- */
.row {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
min-height: 44px;
}
.text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
/* Tall enough to make the row's text area an easy tap target. */
padding: 8px 0;
cursor: pointer;
user-select: none;
}
.root[data-disabled] .text {
cursor: not-allowed;
}
.label {
font-size: 16px;
font-weight: 500;
line-height: 1.35;
letter-spacing: 0;
}
.description {
font-size: 13px;
line-height: 1.45;
color: rgba(255, 255, 255, 0.64);
}
/* --- Control: positions the track and carries the glow --- */
.control {
position: relative;
isolation: isolate;
flex: none;
display: inline-flex;
}
/* Layer 1: pastel light behind the track. The brief's 22–26px blur is
for panels; on a 51px control it would swamp everything, so it is
scaled down. Faint while OFF, blooms when ON. */
.control::before {
content: "";
position: absolute;
z-index: -1;
inset: -6px -8px;
border-radius: 9999px;
pointer-events: none;
background:
radial-gradient(
60% 70% at 25% 60%,
color-mix(in srgb, var(--gsw-glow-a) 45%, transparent),
transparent 70%
),
radial-gradient(
60% 70% at 78% 40%,
color-mix(in srgb, var(--gsw-glow-b) 40%, transparent),
transparent 70%
),
radial-gradient(
70% 50% at 50% 100%,
color-mix(in srgb, var(--gsw-glow-a-soft) 25%, transparent),
transparent 70%
);
filter: blur(10px);
opacity: 0.35;
transition: opacity 0.3s ease;
}
.control[data-state="on"]::before {
opacity: 1;
}
/* --- Layer 2: the track (white frosted glass) --- */
.track {
position: relative;
isolation: isolate;
overflow: hidden;
display: block;
width: var(--gsw-width);
height: var(--gsw-height);
padding: 0;
margin: 0;
border: 0;
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(20px) 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 4px 14px color-mix(in srgb, var(--gsw-glow-a-rim) 16%, transparent),
0 6px 16px rgba(0, 0, 0, 0.28);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.track:disabled {
cursor: not-allowed;
}
/* Reflection on the upper half of the track */
.track::before {
content: "";
position: absolute;
z-index: 1;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.22) 0%,
rgba(255, 255, 255, 0.06) 45%,
rgba(255, 255, 255, 0) 70%
);
}
/* Without backdrop-filter, an opaque dark track keeps the bead legible */
@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%
);
}
}
/* --- ON fill: a pink → lavender glow, not a flat color. A soft light
spot on the left and an inner glow keep it luminous. --- */
.fill {
position: absolute;
z-index: 0;
inset: 0;
border-radius: inherit;
pointer-events: none;
background:
radial-gradient(
70% 120% at 22% 30%,
color-mix(in srgb, var(--gsw-glow-a-soft) 85%, transparent),
transparent 70%
),
linear-gradient(
90deg,
color-mix(in srgb, var(--gsw-glow-a) 92%, transparent) 0%,
color-mix(in srgb, var(--gsw-glow-b) 92%, transparent) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
inset 0 0 10px rgba(255, 255, 255, 0.35);
opacity: 0;
transition: opacity 0.25s ease;
}
.track[data-state="on"] .fill {
opacity: 1;
}
.track[data-state="on"] {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.45),
inset 0 0 0 1px rgba(255, 255, 255, 0.28),
0 0 16px color-mix(in srgb, var(--gsw-glow-a-rim) 45%, transparent),
0 6px 16px rgba(0, 0, 0, 0.24);
}
/* --- Layer 3: the bead — a bright white glass lens. Width and x are
animated by framer-motion; top / left / height are fixed here. --- */
.thumb {
position: absolute;
z-index: 2;
top: var(--gsw-pad);
left: var(--gsw-pad);
width: var(--gsw-thumb);
height: var(--gsw-thumb);
border-radius: 9999px;
background:
radial-gradient(
80% 60% at 50% 18%,
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0) 70%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 255, 255, 0.8) 100%
);
backdrop-filter: blur(8px) saturate(160%) brightness(1.08);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 0 0 1px rgba(255, 255, 255, 0.6),
inset 0 -2px 4px color-mix(in srgb, var(--gsw-glow-b) 18%, transparent),
0 2px 6px rgba(0, 0, 0, 0.2),
0 3px 12px color-mix(in srgb, var(--gsw-glow-a-rim) 22%, transparent);
will-change: transform, width;
}
.track:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.fill,
.control::before {
transition: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/glass-switch.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { GlassSwitch } from "@/components/glass-switch";
import { useState } from "react";
// TODO: "use client" のコンポーネント内で const [checked, setChecked] = useState(true); を用意してください
// TODO: フォーム送信に使う場合は name(と必要なら value)を渡すと、オンのときだけ hidden input で送信されます
<GlassSwitch
size="md"
glowColorA="#ffaac8"
glowColorB="#beafff"
disabled={false}
springStiffness={380}
springDamping={30}
label="通知"
description="新着メッセージをお知らせします"
checked={checked}
onCheckedChange={setChecked}
/>
```
### 4. 組み込み手順
1. スイッチを置くコンポーネント(`"use client"`)で `const [checked, setChecked] = useState(true)` を持ち、`<GlassSwitch checked={checked} onCheckedChange={setChecked} />` を置く。状態を親で持つ必要がなければ `defaultChecked` だけ渡す非制御でもよい。
2. 設定画面の行として使うときは `label`(と `description`)を渡す。行全体(テキスト部分 + スイッチ)がタップ領域になり、ラベルはスクリーンリーダー向けにも関連付けられる。
3. ラベルを表示しない単体スイッチには `aria-label` を渡すか、外側の `<label htmlFor={id}>` と `id` で関連付ける。名前の無いスイッチにしない。
4. ガラスなので **暗く、色のある背景の上** に置く。真っ白・単色の背景ではすりガラスと発光が見えない。
5. フォームで送信するなら `name`(必要なら `value`、既定は `"on"`)を渡す。オンのときだけ hidden input が出力され、ネイティブのチェックボックスと同じ送信結果になる。
6. `npm run build` が通ることを確認し、実機で「タップでつまみがスプリングで移動する」「押している間つまみが伸びる」「オンでピンク→ラベンダーに光る」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| Chrome でトラックのぼかしが効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| オンのときに押すと、つまみがトラックの右からはみ出す | 伸びる幅だけ `width` を広げ、`x` をそのままにしている | オン中の押下は `x = travel − stretch` にして左方向へ伸ばす(同梱の計算を変えない) |
| サイズを変えるとつまみの位置がずれる | CSS のサイズ変数と TS の `SIZES` が食い違っている | `--gsw-width / height / thumb` と `SIZES` を必ず両方そろえて変更する |
| オンで初期表示したスイッチが、表示のたびに左から滑ってくる | `motion.span` の `initial` を外した | `initial={false}` を外さない |
| 視差効果オフでもつまみが滑る | 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 以降)
- ファイル: `components/glass-switch/index.tsx`(`"use client"`)+ `GlassSwitch.module.css`
- props: `checked` / `defaultChecked` / `onCheckedChange(checked)`(制御・非制御の両対応)、`label`、`description`、`disabled`、`size`(`"md"` 51×31 / `"sm"` 40×24)、`glowColorA` / `glowColorB`、`springStiffness` / `springDamping`、`name` / `value`、`id`、`aria-label`
- `label` があると行レイアウト: `display: flex; align-items: center; gap: 14px; width: 100%; min-height: 44px`、左にテキスト(`<label htmlFor={スイッチの id}>`、flex: 1)、右にスイッチ
- 色の既定値は CSS のカスタムプロパティ(`--gsw-glow-a` など)が持ち、コンポーネントは既定以外の値のときだけインラインで上書きする
### 見た目 = GlassBottomTabBar と同じグラスモーフィズム
層構成は「発光 → ガラスのトラック → 発光する塗り → ガラスの玉」。硬い 1px の黒縁・濃い落ち影は使わない。
- **サイズ**: md はトラック 51×31・つまみ 27px、sm は 40×24・20px。内側の余白はどちらも 2px。押下時の伸び幅は md 7px / sm 5px
- **層 1 発光(.control::before)**: `inset: -6px -8px`、ピンク `rgba(255,170,200,.45)`(`60% 70% at 25% 60%`)とラベンダー `rgba(190,175,255,.40)`(`60% 70% at 78% 40%`)、下辺に `rgba(255,200,220,.25)`。`filter: blur(10px)`(パネル用の 26px は小さな部品には強すぎるので縮小)。オフ時 opacity .35、オン時 1(300ms)
- **層 2 トラック**: `border-radius: 9999px`、`overflow: hidden`
- background: `linear-gradient(180deg, rgba(255,255,255,.17), rgba(255,255,255,.09))`
- `backdrop-filter: blur(20px) saturate(170%)`(unprefixed のみ)
- box-shadow: `inset 0 1px 0 白 .45` / `inset 0 0 0 1px 白 .20` / `0 4px 14px rgba(255,170,205,.16)` / `0 6px 16px rgba(0,0,0,.28)`。オン時は外光を `0 0 16px rgba(255,170,205,.45)` に強める
- `::before` 上部の反射(白 .22 → .06 at 45% → 0 at 70%)
- `@supports not (backdrop-filter: blur(1px))` では `rgba(70,60,78,.94) → rgba(40,34,48,.94)`
- **オンの塗り(.fill)**: 平坦な単色ではなく発光。`linear-gradient(90deg, 発光色 A 92%, 発光色 B 92%)` の上に、左寄りの淡いピンクの光だまり(`radial-gradient(70% 120% at 22% 30%, #ffc8dc 85%, transparent 70%)`)。`inset 0 1px 0 白 .5` / `inset 0 0 10px 白 .35`。opacity 0 → 1、250ms
- **層 3 つまみ(白いガラスの玉)**: 円形、`top / left: 2px`。上部に白いハイライト(`radial-gradient(80% 60% at 50% 18%, 白, 透明 70%)`)+ `linear-gradient(180deg, 白 .95, 白 .80)`、`backdrop-filter: blur(8px) saturate(160%) brightness(1.08)`。box-shadow: `inset 0 1px 0 白 .9` / `inset 0 0 0 1px 白 .6` / 下側にうっすらラベンダー / `0 2px 6px rgba(0,0,0,.2)` / `0 3px 12px rgba(255,170,205,.22)`
- **行のテキスト**: ラベル 16px / 500、説明 13px / 行間 1.45 / 白 .64。テキスト部分は上下 8px の余白でタップしやすく
- 無効時は全体 opacity .45、`cursor: not-allowed`
- フォント: `-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto, sans-serif`
### モーション
- つまみは `motion.span` で `x` と `width` をアニメーション。スプリング `stiffness: 380, damping: 30, mass: 0.9`、`initial={false}`(マウント時は動かさない)
- 移動量 `travel = トラック幅 − 2 × 余白 − つまみ径`(md 20px / sm 16px)。オフは `x: 0`、オンは `x: travel`
- iOS と同じく、押している間はつまみが横に伸びる: `width = つまみ径 + 伸び幅`。オンのときは `x = travel − 伸び幅` にして中央(左)方向へ伸ばし、トラックからはみ出させない
- 押下状態は行全体(ラベル部分も含む)の `pointerdown` / `pointerup` / `pointerleave` / `pointercancel` で管理。無効時は伸びない
- 塗りと背後の発光は CSS の opacity トランジション(`prefers-reduced-motion: reduce` では無効)
- `<MotionConfig reducedMotion="user">` で包む
### アクセシビリティ
- `<button type="button" role="switch" aria-checked>`。Enter / Space はネイティブの button のクリックとして切り替わる
- ラベルがあるときは `aria-labelledby`(ラベル)と `aria-describedby`(説明)、無いときは `aria-label`
- `<label htmlFor>` でテキストをタップしても切り替わる。`disabled` 時は button を `disabled` にし、切り替わらない
- `:focus-visible` で白 2px アウトライン(offset 3px)。`touch-action: manipulation`、`-webkit-tap-highlight-color: transparent`
- `name` があり、オンかつ有効なときだけ `<input type="hidden" name value>` を出力する
### 受け入れ条件
- タップ・クリック・Enter・Space で切り替わり、つまみがスプリングで移動する
- 押している間つまみが伸び、オン状態でもトラックからはみ出さない
- オンでトラックがピンク→ラベンダーのグラデーションで光り、背後に淡い発光がにじむ
- ラベル付きでは行のどこ(テキスト部分)をタップしても切り替わる
- スクリーンリーダーで「スイッチ、オン/オフ」とラベル名が読み上げられるインストール
npx shadcn@latest add https://anima-js.vercel.app/r/glass-switch.json生成コード
import { GlassSwitch } from "@/components/glass-switch";
import { useState } from "react";
// TODO: "use client" のコンポーネント内で const [checked, setChecked] = useState(true); を用意してください
// TODO: フォーム送信に使う場合は name(と必要なら value)を渡すと、オンのときだけ hidden input で送信されます
<GlassSwitch
size="md"
glowColorA="#ffaac8"
glowColorB="#beafff"
disabled={false}
springStiffness={380}
springDamping={30}
label="通知"
description="新着メッセージをお知らせします"
checked={checked}
onCheckedChange={setChecked}
/>