Tips & Tricks (更新: 2026/6/2)

Claude Codeでアクセシブルなトースト通知を実装するReact実践ガイド

Reactでキュー、停止、aria-live、モバイル対応まで含むトースト通知をClaude Codeで安全に作る実装ガイド。

Claude Codeでアクセシブルなトースト通知を実装するReact実践ガイド

トースト通知は「保存しました」「同期に失敗しました」のような短い状態メッセージを、画面の端に一時表示するUIです。便利ですが、雑に作ると見えないユーザーには伝わらず、見えているユーザーには勝手に消えて読み切れず、モバイルでは下部ナビやセーフエリアに重なります。

Claude Codeに「トースト通知を作って」とだけ頼むと、見た目はすぐできます。しかし公開できる品質にするには、キュー制御、自動消去、ホバー・フォーカス時の一時停止、aria-liverole="status"role="alert"の使い分け、prefers-reduced-motion、閉じるボタン、テスト観点まで指定する必要があります。

この記事では、ReactとTypeScriptでコピペして動かせるトースト通知を作ります。対象は、Claude CodeでUI実装を始めたばかりの人、既存のプロダクトに通知を入れたい人、問い合わせや購入導線を邪魔しない状態表示を作りたい人です。関連して、UI全体の考え方はClaude Codeアクセシビリティ実装、動きの調整はClaude Codeアニメーション実装、レスポンシブの詰めはClaude Codeレスポンシブデザインも参考になります。

先に決める設計

トースト通知はモーダルではありません。操作を止めるためのUIではなく、操作結果を補足する非ブロッキングな状態表示です。たとえばフォーム送信後に「送信しました」と伝えるのはトースト向きですが、決済失敗でユーザーが次に何をすべきか選ぶ必要があるなら、画面内のエラー表示やダイアログのほうが適しています。

この実装では次の方針にします。

  • 通知は最大3件だけ表示し、古いものから消す
  • successinfowarningrole="status"で控えめに読む
  • errorは即時注意が必要な場合だけrole="alert"にする
  • 自動消去はあるが、マウスホバーとキーボードフォーカス中は止める
  • 閉じるボタンを必ず置き、読む時間をユーザーが少し制御できるようにする
  • モーション削減設定ではスライドアニメーションを止める
  • スマホではenv(safe-area-inset-*)を使い、ノッチやホームバーを避ける

根拠にする公式資料は、MDNのstatus roleMDNのalert roleW3C WCAG 2.2 Status MessagesW3C WCAG 2.2 Pause, Stop, HideMDN setTimeoutMDN prefers-reduced-motionです。Claude Code自体の基本はClaude Code公式ドキュメントCommon workflowsを確認してください。

コピペで動くReact実装

まずToastProvider.tsxを作ります。依存はReactだけです。Vite、Next.jsのクライアントコンポーネント、AstroのReact islandなどで使えます。Next.js App Routerで使う場合はファイル先頭に"use client";を追加してください。

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from "react";

type ToastTone = "success" | "info" | "warning" | "error";

type ToastInput = {
  title: string;
  description?: string;
  tone?: ToastTone;
  durationMs?: number;
};

type ToastItem = Required<Omit<ToastInput, "durationMs">> & {
  id: string;
  durationMs: number;
  createdAt: number;
};

type ToastContextValue = {
  showToast: (input: ToastInput) => string;
  dismissToast: (id: string) => void;
};

const ToastContext = createContext<ToastContextValue | null>(null);
const MAX_VISIBLE_TOASTS = 3;
const DEFAULT_DURATION = 5000;

export function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<ToastItem[]>([]);

  const dismissToast = useCallback((id: string) => {
    setToasts((current) => current.filter((toast) => toast.id !== id));
  }, []);

  const showToast = useCallback((input: ToastInput) => {
    const id = crypto.randomUUID();
    const nextToast: ToastItem = {
      id,
      title: input.title,
      description: input.description ?? "",
      tone: input.tone ?? "info",
      durationMs: input.durationMs ?? DEFAULT_DURATION,
      createdAt: Date.now(),
    };

    setToasts((current) => [...current, nextToast].slice(-MAX_VISIBLE_TOASTS));
    return id;
  }, []);

  const value = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);

  return (
    <ToastContext.Provider value={value}>
      {children}
      <ToastViewport toasts={toasts} onDismiss={dismissToast} />
    </ToastContext.Provider>
  );
}

export function useToast() {
  const context = useContext(ToastContext);
  if (!context) {
    throw new Error("useToast must be used inside ToastProvider");
  }
  return context;
}

function ToastViewport({
  toasts,
  onDismiss,
}: {
  toasts: ToastItem[];
  onDismiss: (id: string) => void;
}) {
  return (
    <div className="toast-viewport" aria-label="通知">
      {toasts.map((toast) => (
        <ToastCard key={toast.id} toast={toast} onDismiss={onDismiss} />
      ))}
    </div>
  );
}

function ToastCard({
  toast,
  onDismiss,
}: {
  toast: ToastItem;
  onDismiss: (id: string) => void;
}) {
  const [paused, setPaused] = useState(false);
  const remainingMs = useRef(toast.durationMs);
  const startedAt = useRef<number | null>(null);
  const timeoutId = useRef<number | null>(null);

  useEffect(() => {
    if (toast.durationMs <= 0 || paused) return;

    startedAt.current = Date.now();
    timeoutId.current = window.setTimeout(() => {
      onDismiss(toast.id);
    }, remainingMs.current);

    return () => {
      if (timeoutId.current !== null) {
        window.clearTimeout(timeoutId.current);
      }
      if (startedAt.current !== null) {
        remainingMs.current -= Date.now() - startedAt.current;
      }
    };
  }, [onDismiss, paused, toast.durationMs, toast.id]);

  const role = toast.tone === "error" ? "alert" : "status";

  return (
    <section
      className={`toast-card toast-card--${toast.tone}`}
      role={role}
      aria-atomic="true"
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
      onFocus={() => setPaused(true)}
      onBlur={() => setPaused(false)}
    >
      <div className="toast-card__content">
        <strong className="toast-card__title">{toast.title}</strong>
        {toast.description ? <p>{toast.description}</p> : null}
      </div>
      <button
        type="button"
        className="toast-card__close"
        aria-label={`${toast.title}を閉じる`}
        onClick={() => onDismiss(toast.id)}
      >
        ×
      </button>
    </section>
  );
}

次にCSSです。position: fixedで右上に置きますが、モバイルでは左右いっぱいに近い幅にし、env(safe-area-inset-top)env(safe-area-inset-right)で端末の安全領域を見ます。

.toast-viewport {
  position: fixed;
  top: max(16px, env(safe-area-inset-top));
  right: max(16px, env(safe-area-inset-right));
  z-index: 1000;
  display: grid;
  gap: 10px;
  width: min(380px, calc(100vw - 32px));
  pointer-events: none;
}

.toast-card {
  pointer-events: auto;
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: start;
  gap: 12px;
  padding: 14px 14px 14px 16px;
  border: 1px solid #d8dee8;
  border-left-width: 5px;
  border-radius: 8px;
  background: #ffffff;
  color: #172033;
  box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
  animation: toast-slide-in 180ms ease-out;
}

.toast-card--success { border-left-color: #15803d; }
.toast-card--info { border-left-color: #2563eb; }
.toast-card--warning { border-left-color: #b45309; }
.toast-card--error { border-left-color: #b91c1c; }

.toast-card__title {
  display: block;
  font-size: 0.95rem;
  line-height: 1.35;
}

.toast-card p {
  margin: 4px 0 0;
  color: #46536a;
  font-size: 0.875rem;
  line-height: 1.5;
}

.toast-card__close {
  min-width: 32px;
  min-height: 32px;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: #526071;
  cursor: pointer;
  font-size: 1.25rem;
  line-height: 1;
}

.toast-card__close:hover,
.toast-card__close:focus-visible {
  background: #eef2f7;
  outline: 2px solid transparent;
}

@keyframes toast-slide-in {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (max-width: 640px) {
  .toast-viewport {
    left: 16px;
    right: 16px;
    width: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .toast-card {
    animation: none;
  }
}

使う側はApp全体をToastProviderで包み、保存・削除・送信などの処理結果でshowToastを呼びます。

import { ToastProvider, useToast } from "./ToastProvider";
import "./toast.css";

function SaveProfileButton() {
  const { showToast } = useToast();

  async function handleSave() {
    try {
      await new Promise((resolve) => window.setTimeout(resolve, 600));
      showToast({
        tone: "success",
        title: "プロフィールを保存しました",
        description: "変更内容は次回の画面表示にも反映されます。",
      });
    } catch {
      showToast({
        tone: "error",
        title: "保存に失敗しました",
        description: "通信状況を確認して、もう一度試してください。",
        durationMs: 8000,
      });
    }
  }

  return <button onClick={handleSave}>保存する</button>;
}

export default function App() {
  return (
    <ToastProvider>
      <main>
        <h1>設定</h1>
        <SaveProfileButton />
      </main>
    </ToastProvider>
  );
}

実務で使える3つのユースケース

1つ目は設定画面の保存結果です。保存ボタンを押した直後に画面遷移しない場合、「保存しました」という短い成功通知はユーザーの不安を減らします。ただし、フォーム項目ごとの入力エラーはトーストだけにしません。該当フィールドの近くにエラーを出し、トーストは「3項目を確認してください」のような補助に留めます。

2つ目は非同期のバックグラウンド処理です。CSVエクスポート、AI要約、画像生成、メール送信のように数秒かかる処理では、「開始しました」「完了しました」「失敗しました」を段階的に出します。Claude Codeに依頼するときは、成功だけでなく失敗・再試行・キャンセル後の表示を指定します。

3つ目は収益導線を邪魔しない通知です。無料PDFのダウンロード、Gumroadの商品リンク、研修相談フォーム、広告クリックの手前で、トーストがCTAを覆うと売上に直接響きます。モバイルで下部固定CTAを使うサイトでは、トースト位置を上に寄せるか、CTAの高さを避けるCSS変数を使います。ClaudeCodeLabのようなコンテンツサイトでは、読者の集中を切らさずに「送信完了」「資料をメールしました」と伝えるのが目的です。

よくある失敗と落とし穴

一番多い失敗は、すべての通知をrole="alert"にすることです。alertは割り込みが強く、スクリーンリーダー利用者の現在の読み上げを中断する場合があります。保存成功やコピー完了はstatusで十分です。alertは、接続断、セッション期限切れ、保存不能のように即時対応が必要なものに絞ります。

次の失敗は、自動消去が早すぎることです。2秒で消える通知は、長い文面や翻訳後の文面では読み切れません。初期値は5秒前後にし、エラーは長めにします。ホバーやフォーカスで止める理由は、マウス利用者だけでなくキーボード利用者が閉じるボタンへ移動する時間を確保するためです。

3つ目は、ライブリージョンを後からDOMに追加して、最初のメッセージが読み上げられない問題です。スクリーンリーダーは既に存在する領域の更新を監視するほうが安定します。この実装ではToastViewportを常時描画し、カードの追加を変化として扱います。実案件ではVoiceOver、NVDA、Chrome、Safariの組み合わせで確認してください。

4つ目は、アニメーションと点滅で注意を引きすぎることです。WCAG 2.2の「Pause, Stop, Hide」は、動きや自動更新がユーザーの読解を邪魔しないよう制御を求めています。トーストの入場アニメーションは短くし、ループや点滅は避けます。prefers-reduced-motionでは動きを止めます。

5つ目は、通知だけに重要情報を載せることです。決済失敗、権限不足、フォーム未入力など、ユーザーが次に修正すべき内容は画面内にも残します。トーストは消えるUIなので、履歴や再確認が必要な情報の唯一の置き場所にしてはいけません。

Claude Codeに渡す実装プロンプト

以下のように、目的、範囲、アクセシビリティ、検証をまとめて渡すと、見た目だけの実装になりにくくなります。

React + TypeScriptでトースト通知を実装してください。
対象ファイルは ToastProvider.tsx と toast.css だけです。

要件:
- success/info/warning/errorをサポート
- 最大3件のキュー表示
- 自動消去、閉じるボタン、hover/focus中の一時停止
- success/info/warningはrole="status"、errorは必要な場合だけrole="alert"
- aria-atomicを設定
- prefers-reduced-motionでアニメーションを無効化
- モバイルのsafe-areaに配慮
- 重要なフォームエラーはトーストだけにしない注意コメントを残す

検証:
- npm run typecheck
- npm run lint
- 保存成功、保存失敗、3件超過、hover/focus停止、キーボード操作を確認

レビュー時は、次のプロンプトで批判的に見てもらいます。

このトースト通知実装をレビューしてください。
特に、aria-live/status/alertの使い分け、自動消去時間、pause-on-hover/focus、
reduced motion、モバイルsafe-area、重要情報が消えるUIだけに依存していないかを確認してください。
問題は重大度順に、該当行と修正案つきで出してください。

Claude Codeレビュー用ワークフローCLAUDE.mdベストプラクティスに、この観点をチェックリストとして書いておくと、毎回同じ品質で見直せます。

この記事で紹介した内容を実際に試した結果

手元のReact検証環境で、ToastProvider.tsxtoast.cssを分けて貼り付け、保存成功、失敗、3件を超える連続表示、ホバー停止、閉じるボタン、キーボードフォーカス停止、prefers-reduced-motionを確認しました。setTimeoutの残り時間をuseRefに保持しているため、ホバー解除後に最初から5秒に戻らず、残り時間で消えます。実プロダクトでは、この上にPlaywrightのクリックテスト、axeの自動検査、スクリーンリーダーでの手動確認を足すのが現実的です。

まとめ

トースト通知は小さなUIですが、アクセシビリティ、時間制御、モバイル表示、収益導線に影響します。Claude Codeを使うなら「通知を作って」ではなく、「どの通知を割り込ませるか」「いつ消すか」「消えてはいけない情報はどこに残すか」まで指定してください。

個人でまず型を覚えたい場合は無料Claude Codeチートシートを使い、UI実装・レビュー・検証プロンプトをまとめたい場合はClaudeCodeLabのプロダクトが近道です。チームで既存プロダクトに導入するなら、Claude Code研修・導入相談で、CLAUDE.md、レビュー基準、アクセシビリティ検証、収益導線を一緒に設計できます。

#Claude Code #toast #notification #React #UI
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。