用 Claude Code 实现可访问的 React Toast 通知
从队列、自动关闭、暂停、aria-live、减少动画到移动端安全区,完整实现 React toast 通知。
Toast 通知常用来显示“保存成功”“导出开始”“提交失败”这类短状态信息。它不应该打断用户操作,却必须让用户知道结果。很多实现只追求右上角弹一下:没有队列、两秒就消失、所有消息都用 alert、在手机上盖住 CTA,或者把重要错误只放在会消失的 toast 里。
Claude Code 很适合生成这类 UI,但提示词要写清楚质量标准。本文给出一套可以直接复制的 React + TypeScript 实现,并说明 role="status" 与 role="alert" 的取舍、自动关闭的暂停逻辑、prefers-reduced-motion、移动端 safe area,以及让 Claude Code 审查实现的提示词。相关内容可以继续读 Claude Code 无障碍实践、动画实现、响应式设计 和 React 开发。
设计原则
Toast 不是 modal。它不应该抢焦点,也不应该要求用户先处理它才能继续操作。适合 toast 的是短反馈:保存完成、复制成功、后台任务开始、邮件已发送。需要用户修正字段、确认风险、重新付款或选择下一步的内容,应该留在页面里。
本文实现采用这些规则:
- 同时最多显示 3 条,避免信息堆叠
success、info、warning使用role="status"- 只有紧急错误使用
role="alert" - 自动关闭,但 hover 和 focus 时暂停
- 每条通知都有关闭按钮
- 尊重
prefers-reduced-motion - 使用 CSS safe-area 避开手机刘海和 home indicator
官方资料建议以原文为准:MDN status role、MDN alert role、W3C WCAG Status Messages、W3C WCAG Pause, Stop, Hide、MDN setTimeout、MDN prefers-reduced-motion 和 Claude Code docs。
可复制的 React 实现
新建 ToastProvider.tsx。只依赖 React。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 放在 toast.css。
.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: #fff;
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; }
}
使用示例:
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 个真实使用场景
第一个是设置页或资料页。保存后显示成功 toast,可以减少用户的不确定感。但字段错误必须贴近字段本身显示,toast 只能做摘要,例如“请确认 3 个字段”。
第二个是后台任务。CSV 导出、AI 摘要、图片处理、邮件发送都适合用 toast 显示开始、完成、失败。让 Claude Code 实现时,要明确失败、重试、取消后的状态,不要只写 happy path。
第三个是商业转化流程。免费 PDF、Gumroad 下载、咨询表单、newsletter 注册都可以用 toast 确认动作已完成。但 toast 不能遮住价格 CTA、购买按钮或移动端底部固定按钮。需要衡量效果时,可以结合 Claude Code analytics implementation。
常见坑
不要把所有 toast 都做成 alert。alert 很强,会打断辅助技术正在读的内容。复制成功、保存完成用 status 更合适。
不要消失得太快。翻译成中文、德语或印尼语后文案长度会变化,小屏阅读也更慢。默认 5 秒比较稳,错误可以 8 秒。hover 和 focus 暂停是为了让鼠标和键盘用户都有时间读完并关闭。
不要让重要信息只存在于 toast。支付失败、权限不足、表单错误必须保留在页面上。Toast 是会消失的 UI,不适合做唯一记录。
不要使用循环动画或闪烁。进入动画要短,prefers-reduced-motion 下应关闭动画。
Claude Code 提示词
请实现可访问的 React + TypeScript toast 通知。
只修改 ToastProvider.tsx 和 toast.css。
要求:
- success/info/warning/error
- 最多显示 3 条
- 自动关闭、关闭按钮、hover/focus 暂停
- 非紧急消息用 role="status"
- 紧急错误才用 role="alert"
- aria-atomic="true"
- 支持 prefers-reduced-motion
- 支持移动端 safe-area
- 重要表单错误不能只放在 toast 中
验证:
- npm run typecheck
- npm run lint
- 手动检查成功、失败、超过 3 条、hover 暂停、focus 暂停、键盘关闭
审查时使用:
请批判性 review 这个 toast 实现。
检查 aria-live/status/alert、自动关闭时间、hover/focus 暂停、
reduced motion、移动端 safe area、键盘操作,以及重要信息是否会消失。
按严重程度列出问题,并给出文件、行号和修复建议。
这些规则适合写进 CLAUDE.md 最佳实践 和 review workflow checklist。
实测结果
我在一个小型 React 验证项目中把 ToastProvider.tsx 和 toast.css 分开粘贴,测试了成功、错误、连续超过 3 条、hover 暂停、focus 暂停、关闭按钮和减少动画。剩余时间保存在 useRef 中,所以暂停后不会重新从 5 秒开始倒计时,而是继续使用剩余时间。生产环境还应加 Playwright 交互测试、axe 检查和至少一次屏幕阅读器手动确认。
总结
Toast 通知虽小,却影响无障碍、信任感、移动端转化和客服成本。向 Claude Code 提需求时,不要只说“做个 toast”,而要写清楚行为、限制和 review 标准。
个人学习可以先用 免费 Claude Code cheatsheet。需要可复用提示词和设置教材,可以看 ClaudeCodeLab 产品。团队要把 UI review、无障碍规则和商业转化路径落到真实仓库,可以从 Claude Code 培训与咨询 开始。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。