anima.js
ギャラリー
フィードバック

GlassToast

Motion

GlassBottomTabBar と同じトンマナの Dynamic Island 風トースト通知 — すりガラスのカプセルが上からスプリングで降り、時間経過・上スワイプ・× で閉じる。ホバー中は自動クローズを一時停止。

anima.js demo

設定

保存しました

変更内容はすべてのデバイスに同期されます。

AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約31 KB)。
# GlassToast をこのプロジェクトに追加してください

このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。

## コンポーネントについて
- 名前: GlassToast
- 説明: GlassBottomTabBar と同じトンマナの Dynamic Island 風トースト通知 — すりガラスのカプセルが上からスプリングで降り、時間経過・上スワイプ・× で閉じる。ホバー中は自動クローズを一時停止。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/glass-toast/index.tsx`, `components/glass-toast/GlassToast.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-toast/index.tsx`
```tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { createPortal } from "react-dom";
import {
  AnimatePresence,
  MotionConfig,
  motion,
  useIsPresent,
  type PanInfo,
} from "framer-motion";
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
import styles from "./GlassToast.module.css";

/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;

export type GlassToastVariant = "success" | "info" | "warning" | "error";

/** Small lens button next to the text. The toast closes after `onClick`. */
export type GlassToastAction = {
  label: string;
  onClick?: () => void;
};

export type GlassToastProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: string;
  description?: string;
  /** Picks the icon and its tint. "error" is announced assertively. */
  variant?: GlassToastVariant;
  /**
   * Auto-dismiss delay in ms; 0 keeps the toast until it is closed. The
   * countdown pauses while the toast is hovered, pressed or focused.
   */
  duration?: number;
  action?: GlassToastAction;
  /** Round glass × button. Swipe-up and Escape close it either way. */
  showCloseButton?: boolean;
  /** Glow color A — pink light behind the toast and its outer light. */
  glowColorA?: string;
  /** Glow color B — lavender light behind the toast. */
  glowColorB?: string;
  /** Backdrop blur of the toast's glass in px. */
  blur?: number;
  /** Spring stiffness of the drop in / snap back. */
  springStiffness?: number;
  /** Spring damping of the drop in / snap back. */
  springDamping?: number;
  /**
   * "fixed" (default) pins the toast to the top of the viewport (below
   * the safe-area inset) and portals it to <body>. "absolute" pins it to
   * the top of the nearest positioned ancestor — for phone mockups.
   */
  placement?: "fixed" | "absolute";
  className?: string;
};

const DEFAULT_GLOW_A = "#ffaac8";
const DEFAULT_GLOW_B = "#beafff";
const DEFAULT_BLUR = 30;
const DEFAULT_DURATION = 4000;
const DEFAULT_SPRING_STIFFNESS = 380;
const DEFAULT_SPRING_DAMPING = 30;
/** Swipe-up distance (px) or speed (px/s) that dismisses the toast. */
const DISMISS_OFFSET = 36;
const DISMISS_VELOCITY = 450;

const ICONS = {
  success: CheckCircle2,
  info: Info,
  warning: AlertTriangle,
  error: XCircle,
} 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)`;

const noopSubscribe = () => () => {};

/** false during SSR and hydration, true afterwards — gates the portal. */
function useIsClient() {
  return useSyncExternalStore(
    noopSubscribe,
    () => true,
    () => false,
  );
}

type ToastCardProps = Required<
  Pick<GlassToastProps, "title" | "variant" | "duration" | "showCloseButton">
> &
  Pick<GlassToastProps, "description" | "action"> & {
    spring: { type: "spring"; stiffness: number; damping: number; mass: number };
    onClose: () => void;
  };

/**
 * One shown toast. Mounted per open, so the countdown and the
 * hover / press state start fresh every time it appears.
 */
function ToastCard({
  title,
  description,
  variant,
  duration,
  action,
  showCloseButton,
  spring,
  onClose,
}: ToastCardProps) {
  // Exit animation in progress: the frozen props still say "open", so the
  // timer must not fire a second close.
  const isPresent = useIsPresent();
  const [hovered, setHovered] = useState(false);
  const [pressed, setPressed] = useState(false);
  const [focused, setFocused] = useState(false);
  const paused = hovered || pressed || focused;

  // Countdown that survives pauses: each run subtracts what it waited.
  const remainingRef = useRef(duration);
  const durationRef = useRef(duration);
  useEffect(() => {
    // A new duration while shown restarts the countdown with it.
    if (durationRef.current !== duration) {
      durationRef.current = duration;
      remainingRef.current = duration;
    }
    if (duration <= 0 || paused || !isPresent) return;
    const started = Date.now();
    const id = window.setTimeout(onClose, remainingRef.current);
    return () => {
      window.clearTimeout(id);
      remainingRef.current = Math.max(
        0,
        remainingRef.current - (Date.now() - started),
      );
    };
  }, [duration, paused, isPresent, onClose]);

  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    setPressed(true);
    // The pointer may be released outside the toast (it follows the drag).
    const release = () => {
      setPressed(false);
      window.removeEventListener("pointerup", release);
      window.removeEventListener("pointercancel", release);
    };
    window.addEventListener("pointerup", release);
    window.addEventListener("pointercancel", release);
    if (event.pointerType === "mouse") setHovered(true);
  };

  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
      setFocused(false);
    }
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Escape") {
      event.stopPropagation();
      onClose();
    }
  };

  const onDragEnd = (_: unknown, info: PanInfo) => {
    if (info.offset.y < -DISMISS_OFFSET || info.velocity.y < -DISMISS_VELOCITY) {
      onClose();
    }
  };

  const Icon = ICONS[variant];

  return (
    <motion.div
      className={styles.frame}
      data-variant={variant}
      initial={{ y: "-120%", scale: 0.9, opacity: 0 }}
      animate={{ y: 0, scale: 1, opacity: 1 }}
      exit={{ y: "-120%", scale: 0.9, opacity: 0 }}
      transition={{ ...spring, opacity: { duration: 0.18, ease: "easeOut" } }}
      // Swipe up from anywhere on the toast; downward barely moves.
      drag="y"
      dragConstraints={{ top: 0, bottom: 0 }}
      dragElastic={{ top: 0.8, bottom: 0.08 }}
      onDragEnd={onDragEnd}
      onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
      onPointerLeave={(e) => e.pointerType === "mouse" && setHovered(false)}
      onPointerDown={onPointerDown}
      onFocus={() => setFocused(true)}
      onBlur={onBlur}
      onKeyDown={onKeyDown}
    >
      <div className={styles.toast}>
        <span className={styles.icon} aria-hidden>
          <Icon size={20} strokeWidth={2} />
        </span>
        <div className={styles.text}>
          <p className={styles.title}>
            {title}
          </p>
          {description ? (
            <p className={styles.description}>{description}</p>
          ) : null}
        </div>
        {action ? (
          <button
            type="button"
            className={styles.action}
            onClick={() => {
              action.onClick?.();
              onClose();
            }}
          >
            {action.label}
          </button>
        ) : null}
        {showCloseButton ? (
          <button
            type="button"
            className={styles.close}
            aria-label="通知を閉じる"
            onClick={onClose}
          >
            <X size={15} strokeWidth={2} aria-hidden />
          </button>
        ) : null}
      </div>
    </motion.div>
  );
}

/**
 * Dynamic-Island-style frosted-glass toast — GlassBottomTabBar's sibling
 * for feedback. Drops in from the top with a spring, auto-dismisses after
 * `duration` (paused on hover / press / focus), closes on swipe-up,
 * Escape or ×. Never moves focus.
 */
export function GlassToast({
  open,
  onOpenChange,
  title,
  description,
  variant = "info",
  duration = DEFAULT_DURATION,
  action,
  showCloseButton = true,
  glowColorA,
  glowColorB,
  blur,
  springStiffness = DEFAULT_SPRING_STIFFNESS,
  springDamping = DEFAULT_SPRING_DAMPING,
  placement = "fixed",
  className,
}: GlassToastProps) {
  const isClient = useIsClient();
  const fixed = placement === "fixed";
  const isError = variant === "error";

  // Stable across renders so an inline onOpenChange doesn't restart the
  // countdown effect more than needed (it resumes from the remainder anyway).
  const onOpenChangeRef = useRef(onOpenChange);
  useEffect(() => {
    onOpenChangeRef.current = onOpenChange;
  }, [onOpenChange]);
  const [close] = useState(() => () => onOpenChangeRef.current(false));

  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["--gts-glow-a"] = glowColorA;
    vars["--gts-glow-a-rim"] = glowColorA;
    vars["--gts-glow-a-soft"] = tint(glowColorA, 60);
    vars["--gts-glow-a-bottom"] = tint(glowColorA, 75);
  }
  if (glowColorB && glowColorB.toLowerCase() !== DEFAULT_GLOW_B) {
    vars["--gts-glow-b"] = glowColorB;
  }
  if (blur !== undefined && blur !== DEFAULT_BLUR) {
    vars["--gts-blur"] = `${blur}px`;
  }

  const content = (
    // The live region stays mounted so screen readers announce the toast
    // when its content is inserted. Follows the OS "reduce motion" setting:
    // the toast then fades in place.
    <div
      className={className ? `${styles.root} ${className}` : styles.root}
      data-glass-toast
      data-placement={placement}
      style={vars}
      role={isError ? "alert" : "status"}
      aria-live={isError ? "assertive" : "polite"}
      aria-atomic="true"
    >
      <MotionConfig reducedMotion="user">
        <AnimatePresence>
          {open ? (
            <ToastCard
              key="toast"
              title={title}
              description={description}
              variant={variant}
              duration={duration}
              action={action}
              showCloseButton={showCloseButton}
              spring={spring}
              onClose={close}
            />
          ) : null}
        </AnimatePresence>
      </MotionConfig>
    </div>
  );

  if (!fixed) return content;
  // Portaled so a transformed / overflow:hidden ancestor can't trap the
  // fixed toast. Nothing renders until hydration is done.
  return isClient ? createPortal(content, document.body) : null;
}

export default GlassToast;
```

#### `components/glass-toast/GlassToast.module.css`
```css
/* ==========================================================================
   Dynamic-Island-style glass toast — GlassBottomTabBar's sibling

   Same grammar as the tab bar: 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 → lens icon / buttons → content).
   ========================================================================== */

.root {
  /* Spec colors. The component only sets these inline for non-default
     values, so the default render is exactly this stylesheet. */
  --gts-glow-a: #ffaac8; /* pink: back glow */
  --gts-glow-b: #beafff; /* lavender: back glow */
  --gts-glow-a-rim: #ffaacd; /* pink: outer light of the toast */
  --gts-glow-a-soft: #ffc8dc; /* lighter pink: bottom glow */
  --gts-glow-a-bottom: #ffbed7; /* pink pooled along the bottom edge */
  --gts-blur: 30px;

  position: fixed;
  top: calc(10px + env(safe-area-inset-top, 0px));
  left: 0;
  right: 0;
  z-index: 70; /* above GlassBottomSheet (60) and the tab bar (50) */
  display: flex;
  justify-content: center;
  /* The strip spans the width; only the toast itself takes pointers. */
  pointer-events: none;
  font-family:
    -apple-system, BlinkMacSystemFont, "Hiragino Sans",
    "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
    sans-serif;
  color: #fff;
}

/* Phone mockups / previews: pin to a positioned ancestor. */
.root[data-placement="absolute"] {
  position: absolute;
}

/* --- Frame: carries the glow and is moved by framer-motion (drop-in +
   swipe-up), so the glow travels with the toast. --- */
.frame {
  position: relative;
  isolation: isolate;
  /* Shrinks to its content like the Dynamic Island, capped by the
     container (not the viewport) so 10px gutters hold in a mockup. */
  width: max-content;
  min-width: min(240px, calc(100% - 20px));
  max-width: min(440px, calc(100% - 20px));
  pointer-events: auto;
  touch-action: none; /* the swipe drives the toast, not the page */
  user-select: none;
  cursor: grab;
  -webkit-tap-highlight-color: transparent;
  will-change: transform;
}

.frame:active {
  cursor: grabbing;
}

/* Layer 1: pastel light behind the toast — two offset blurred ellipses. */
.frame::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: -16px -14px -22px;
  border-radius: 44px;
  pointer-events: none;
  background:
    radial-gradient(
      60% 70% at 18% 70%,
      color-mix(in srgb, var(--gts-glow-a) 45%, transparent),
      transparent 70%
    ),
    radial-gradient(
      55% 65% at 84% 30%,
      color-mix(in srgb, var(--gts-glow-b) 40%, transparent),
      transparent 70%
    ),
    radial-gradient(
      70% 45% at 50% 100%,
      color-mix(in srgb, var(--gts-glow-a-soft) 25%, transparent),
      transparent 70%
    );
  filter: blur(22px);
}

/* --- Layer 2: the capsule (white frosted glass) ---
   Rim = inset white box-shadow, not a border. */
.toast {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 56px;
  padding: 8px 10px 8px 8px;
  border-radius: 28px;
  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(--gts-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(--gts-glow-a-rim) 16%, transparent),
    0 18px 48px rgba(0, 0, 0, 0.32);
}

/* Reflection on the upper part */
.toast::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) 35%,
    rgba(255, 255, 255, 0) 60%
  );
}

/* A little pink pooled along the bottom edge */
.toast::after {
  content: "";
  position: absolute;
  z-index: -1;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--gts-glow-a-bottom) 0%, transparent) 50%,
    color-mix(in srgb, var(--gts-glow-a-bottom) 12%, transparent) 100%
  );
}

/* Without backdrop-filter, trade transparency for legible text */
@supports not (backdrop-filter: blur(1px)) {
  .toast {
    background: linear-gradient(
      180deg,
      rgba(70, 60, 78, 0.94) 0%,
      rgba(40, 34, 48, 0.94) 100%
    );
  }
}

/* --- Variant tone: only tints the icon and its glow, never the glass. --- */
.frame[data-variant="success"] {
  --gts-tone: #8ef0b6;
}
.frame[data-variant="info"] {
  --gts-tone: #a9c8ff;
}
.frame[data-variant="warning"] {
  --gts-tone: #ffd98a;
}
.frame[data-variant="error"] {
  --gts-tone: #ff9a96;
}

/* Icon slot = a round lens, same recipe as the tab bar's active pill,
   with a faint glow in the variant's tone. */
.icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 9999px;
  color: var(--gts-tone);
  background:
    radial-gradient(
      circle at 50% 60%,
      color-mix(in srgb, var(--gts-tone) 22%, transparent),
      transparent 70%
    ),
    linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05));
  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(--gts-tone) 22%, transparent);
}

.icon svg {
  filter: drop-shadow(0 0 6px color-mix(in srgb, var(--gts-tone) 55%, transparent));
}

/* --- Text --- */
.text {
  flex: 1;
  min-width: 0;
  padding: 2px 0;
}

.title {
  margin: 0;
  font-size: 15px;
  font-weight: 600;
  line-height: 1.35;
  letter-spacing: 0;
}

.description {
  margin: 2px 0 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.64);
}

/* --- Buttons: small lenses --- */
.action,
.close {
  flex: none;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 9999px;
  font: inherit;
  color: #fff;
  background: linear-gradient(
    180deg,
    rgba(255, 255, 255, 0.12) 0%,
    rgba(255, 255, 255, 0.05) 100%
  );
  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(--gts-glow-a-rim) 18%, transparent);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
  transition: transform 0.12s ease;
}

.action {
  height: 32px;
  padding: 0 14px;
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
}

.close {
  width: 28px;
  height: 28px;
  padding: 0;
  color: rgba(255, 255, 255, 0.8);
}

.action:active,
.close:active {
  transform: scale(0.94);
}

.action:focus-visible,
.close:focus-visible {
  outline: 2px solid rgba(255, 255, 255, 0.85);
  outline-offset: 2px;
}
```

代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/glass-toast.json
```

### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { GlassToast } from "@/components/glass-toast";
import { useState } from "react";

// TODO: "use client" のコンポーネント内で const [open, setOpen] = useState(false); を用意し、通知したいタイミングで setOpen(true) してください
// TODO: action に onClick を渡して処理(元に戻す など)をつないでください
<GlassToast
  title="保存しました"
  description="変更内容はすべてのデバイスに同期されます。"
  variant="success"
  glowColorA="#ffaac8"
  glowColorB="#beafff"
  blur={30}
  duration={4000}
  springStiffness={380}
  springDamping={30}
  open={open}
  onOpenChange={setOpen}
  action={{ label: "元に戻す" }}
/>
```

### 4. 組み込み手順
1. 通知を出すコンポーネント(`"use client"`)で `const [open, setOpen] = useState(false)` を持ち、`<GlassToast open={open} onOpenChange={setOpen} title="…" variant="success" />` を置く。保存完了などのタイミングで `setOpen(true)`。
2. `app/layout.tsx` で viewport に `viewportFit: "cover"` を指定する(GlassBottomTabBar を入れていれば設定済み)。これが無いと `env(safe-area-inset-top)` が 0 になり、iPhone のノッチ / Dynamic Island に通知が重なる。
3. 既定(`placement="fixed"`)では `<body>` へポータルし、画面上端の中央に出る。親に `transform` や `overflow: hidden` があっても影響しない。フォーカスは奪わない。
4. `duration`(ms、既定 4000)で自動的に閉じる。`0` にすると × か上スワイプで閉じるまで表示し続ける。ホバー・押下・フォーカス中はカウントが止まり、離れると残り時間から再開する。
5. `action={{ label: "元に戻す", onClick }}` で小さなガラスのボタンを出せる。押すと `onClick` のあと自動で閉じる。
6. 同じ通知をもう一度出したいとき、表示中なら一度 `setOpen(false)` してから出す(または `key` を変えて再マウントする)。
7. GlassBottomSheet / GlassBottomTabBar と併用する場合、通知は `z-index: 70` で両方より上に出る。追加の設定は不要。
8. `npm run build` が通ることを確認し、実機(iPhone)で「Dynamic Island の下に出る」「上スワイプで閉じる」「背後がぼけて透ける」を確認する。

#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| Chrome でぼかしが一切効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| スクリーンリーダーが読み上げない | ライブリージョンごと条件付きで描画している | `role="status"` / `aria-live` を持つ外側のラッパーは常にマウントし、中身だけを `AnimatePresence` で出し入れする |
| 閉じるアニメーションが出ずに消える | `AnimatePresence` の外で条件分岐している | `open` の分岐は `AnimatePresence` の内側で行う。コンポーネント自体は常にマウントしておく |
| 通知の左右の空白をタップできない | 横幅いっぱいのラッパーがタップを拾っている | ラッパーは `pointer-events: none`、カプセル(.frame)だけ `pointer-events: auto` |
| SSR でハイドレーションエラー | サーバーで `document.body` にポータルしようとしている | 同梱の `useSyncExternalStore` によるクライアント判定を外さない |
| 視差効果オフでも降りてくる | 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-toast/index.tsx`(`"use client"`)+ `GlassToast.module.css`
- 制御コンポーネント: `open` / `onOpenChange(open)`、`title`(必須)、`description`、`variant`(`"success" | "info" | "warning" | "error"`、既定 `"info"`)、`duration`(ms、既定 4000、0 = 自動で閉じない)、`action`(`{ label, onClick? }`)、`showCloseButton`(既定 true)、`glowColorA` / `glowColorB` / `blur`、`springStiffness` / `springDamping`、`placement`(`"fixed" | "absolute"`)
- ルート: `position: fixed; top: calc(10px + env(safe-area-inset-top)); left: 0; right: 0; z-index: 70`、`display: flex; justify-content: center; pointer-events: none`。`createPortal(…, document.body)`、SSR 中は描画しない。`placement="absolute"` では `position: absolute`(モックアップ用)

### 見た目 = GlassBottomTabBar と同じグラスモーフィズム(Dynamic Island 風カプセル)
層構成は「発光 → ガラス → レンズ(アイコン / ボタン)→ 文字」。硬い 1px の黒縁・濃い落ち影は使わない。

- **フレーム(.frame)**: `width: max-content`、`min-width: min(240px, 100% - 20px)`、`max-width: min(440px, 100% - 20px)`(親基準なのでモックアップ内でも左右 10px が保たれる)。`pointer-events: auto; touch-action: none; cursor: grab`。framer-motion で降下とドラッグを担当し、発光ごと動く
- **層 1 発光(.frame::before)**: `inset: -16px -14px -22px`、ピンク `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)`(`70% 45% at 50% 100%`)。`filter: blur(22px)`、`z-index: -1`
- **層 2 カプセル(.toast)**: `border-radius: 28px`、`min-height: 56px`、padding `8px 10px 8px 8px`、横 flex・gap 12px・`align-items: center`、`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 35% → 0 at 60%)、`::after` 下辺にピンク `rgba(255,190,215,.12)`(50% から)
  - `@supports not (backdrop-filter: blur(1px))` では `rgba(70,60,78,.94) → rgba(40,34,48,.94)`
- **アイコン(.icon)**: 40px の円いレンズ。白 .12 → .05 + `backdrop-filter: blur(8px) saturate(160%) brightness(1.08)` + `inset 0 1px 0 白 .55` / `inset 0 0 0 1px 白 .42`。lucide のアイコン 20px(success `CheckCircle2` / info `Info` / warning `AlertTriangle` / error `XCircle`)
  - 種類ごとの色(`--gts-tone`): success `#8ef0b6`、info `#a9c8ff`、warning `#ffd98a`、error `#ff9a96`。アイコン色・レンズ内側のほのかな光(22%)・外光 `0 4px 16px`(22%)・アイコンの `drop-shadow(0 0 6px)`(55%)にだけ使い、ガラスやピンク / ラベンダーの発光は変えない
- **文字**: タイトル 15px / 600 / 行間 1.35、説明 12.5px / 行間 1.5 / 白 .64
- **アクションボタン**: 高さ 32px のピル、padding `0 14px`、13px / 600、レンズ(白 .12 → .05、`inset 0 1px 0 白 .55` / `inset 0 0 0 1px 白 .42` / `0 4px 16px rgba(255,170,205,.18)`)
- **× ボタン**: 28px の円、同じレンズ、lucide `X` 15px、白 .8
- 押下時 `scale(.94)` 120ms、`:focus-visible` は白 2px アウトライン
- フォント: `-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto, sans-serif`

### モーション
- `AnimatePresence` で開閉。`{ y: "-120%", scale: .9, opacity: 0 } → { y: 0, scale: 1, opacity: 1 }`、スプリング `stiffness: 380, damping: 30, mass: 0.9`(opacity だけ 180ms easeOut)。閉じるときは逆
- 上スワイプで閉じる: カプセルのどこからでも `drag="y"`、`dragConstraints={{ top: 0, bottom: 0 }}`、`dragElastic={{ top: .8, bottom: .08 }}`。`offset.y < -36px` または `velocity.y < -450px/s` で閉じ、未満はスプリングで戻る
- 自動クローズ: 表示ごとに残り時間をリセットし、ホバー(マウス)・押下(`pointerup` は window で拾う)・フォーカス中は停止、再開時は残り時間から。退場アニメ中(`useIsPresent()` が false)はタイマーを動かさない
- `<MotionConfig reducedMotion="user">` で包む

### アクセシビリティ
- 常にマウントしている外側のラッパーに `role="status" aria-live="polite" aria-atomic="true"`(`variant="error"` のときは `role="alert" aria-live="assertive"`)
- フォーカスは移動しない。Tab で通知内のボタンに入った間はカウントが止まる(WCAG 2.2.1)。フォーカスが通知内にあるとき Escape で閉じる
- × は `aria-label="通知を閉じる"`、アイコンは `aria-hidden`

### 受け入れ条件
- `setOpen(true)` で上からカプセルが降り、背後の色がガラスに乗ってぼける
- `duration` 経過で自動で閉じ、ホバー中は閉じない。`duration={0}` では閉じない
- 上スワイプ・×・アクション・Escape で閉じ、閉じるアニメーションが出る
- スクリーンリーダーがタイトルと説明を読み上げる(エラーは割り込みで)
- iPhone のノッチ / Dynamic Island と重ならない
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/glass-toast.json
生成コード
import { GlassToast } from "@/components/glass-toast";
import { useState } from "react";

// TODO: "use client" のコンポーネント内で const [open, setOpen] = useState(false); を用意し、通知したいタイミングで setOpen(true) してください
// TODO: action に onClick を渡して処理(元に戻す など)をつないでください
<GlassToast
  title="保存しました"
  description="変更内容はすべてのデバイスに同期されます。"
  variant="success"
  glowColorA="#ffaac8"
  glowColorB="#beafff"
  blur={30}
  duration={4000}
  springStiffness={380}
  springDamping={30}
  open={open}
  onOpenChange={setOpen}
  action={{ label: "元に戻す" }}
/>