Toast Notification Accessible Trong React: Đừng Để User Bị Bỏ Lại
Hướng dẫn xây dựng hệ thống Toast Notification trong React đạt chuẩn accessibility - ARIA live regions, focus management, keyboard support và screen reader friendly.
Toast Đẹp Nhưng Không Accessible — Vấn Đề Phổ Biến
Toast notification là thứ gần như app nào cũng có: "Lưu thành công!", "Có lỗi xảy ra!", "Đã copy vào clipboard". Nhưng 80% implementation tôi review đều fail accessibility — người dùng screen reader không nghe thấy, keyboard user không dismiss được, focus bị mất sau khi toast biến mất.
Bài này tôi sẽ build một toast system đúng chuẩn WCAG 2.1 AA từ đầu, không dùng thư viện ngoài.
Core Concept: ARIA Live Regions
Điểm mấu chốt của accessible toast là ARIA live region. Khi content trong live region thay đổi, screen reader tự động đọc lên — không cần focus vào element đó.
aria-live="polite"— đọc sau khi user pause (dùng cho success, info)aria-live="assertive"— đọc ngay lập tức, ngắt ngang nội dung đang đọc (chỉ dùng cho error)role="status"— implicitaria-live="polite"role="alert"— implicitaria-live="assertive"
Xây ToastProvider
// toast/ToastContext.tsx
import {
createContext,
useContext,
useState,
useCallback,
useRef,
useId,
} from "react";
type ToastType = "success" | "error" | "info" | "warning";
interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ToastContextValue {
toasts: Toast[];
addToast: (message: string, type?: ToastType, duration?: number) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const counterRef = useRef(0);
const addToast = useCallback(
(message: string, type: ToastType = "info", duration = 5000) => {
const id = `toast-${++counterRef.current}`;
setToasts((prev) => [...prev, { id, message, type, duration }]);
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
},
[]
);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} onDismiss={removeToast} />
</ToastContext.Provider>
);
}
export const useToast = () => {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
};
ToastContainer Với ARIA Live Region
// toast/ToastContainer.tsx
function ToastContainer({
toasts,
onDismiss,
}: {
toasts: Toast[];
onDismiss: (id: string) => void;
}) {
// Tách polite vs assertive để screen reader đọc đúng priority
const politeToasts = toasts.filter(
(t) => t.type !== "error"
);
const assertiveToasts = toasts.filter(
(t) => t.type === "error"
);
return (
<>
{/* Screen reader only — không visible nhưng được announce */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{politeToasts.map((t) => (
<span key={t.id}>{t.message}</span>
))}
</div>
<div
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{assertiveToasts.map((t) => (
<span key={t.id}>{t.message}</span>
))}
</div>
{/* Visual toast container */}
<div
className="toast-viewport"
aria-label="Thông báo"
>
{toasts.map((toast) => (
<ToastItem
key={toast.id}
toast={toast}
onDismiss={onDismiss}
/>
))}
</div>
</>
);
}
function ToastItem({
toast,
onDismiss,
}: {
toast: Toast;
onDismiss: (id: string) => void;
}) {
return (
<div
role="group"
aria-label={`${toast.type}: ${toast.message}`}
className={`toast toast--${toast.type}`}
>
<p className="toast__message">{toast.message}</p>
<button
onClick={() => onDismiss(toast.id)}
aria-label="Đóng thông báo"
className="toast__close"
>
×
</button>
</div>
);
}
Checklist Accessibility Trước Khi Ship
- ✅ ARIA live region tách polite/assertive theo toast type
- ✅ Close button có
aria-labelrõ ràng - ✅ Toast có thể dismiss bằng
Escapekey - ✅ Pause auto-dismiss khi hover (giúp người đọc chậm)
- ✅ Contrast ratio tối thiểu 4.5:1 theo WCAG AA
- ✅ Animation respect
prefers-reduced-motion
Pro tip: Test với VoiceOver (macOS) hoặc NVDA (Windows) thực sự trước khi ship. Chỉ nhìn code không đủ để biết screen reader experience tốt hay không.
Kết Luận
Accessible toast không phức tạp — chỉ cần hiểu ARIA live regions và test đúng cách. Một component làm đúng từ đầu sẽ phục vụ 100% user, kể cả những người bạn không bao giờ gặp trực tiếp.
Admin
Cal.com
Open source scheduling — tự host booking system, thay thế Calendly. Free & privacy-first.
Bình luận (0)
Đăng nhập để bình luận
Chưa có bình luận nào. Hãy là người đầu tiên!