Membuat Toast Notification React yang Aksesibel dengan Claude Code
Panduan React toast dengan antrean, auto-dismiss, pause, aria-live, reduced motion, dan mobile safe area.
Toast notification terlihat kecil: “Tersimpan”, “Ekspor dimulai”, “Pembayaran gagal”. Di produk nyata, komponen kecil ini memengaruhi aksesibilitas, rasa percaya, tampilan mobile, dan konversi. Toast yang buruk hilang terlalu cepat, memakai alert untuk semua pesan, menutupi CTA utama, atau menjadi satu-satunya tempat munculnya error penting.
Claude Code bisa membuat tampilan toast dengan cepat. Agar layak produksi, prompt harus meminta antrean, auto-dismiss, pause saat hover dan focus, pilihan role="status" versus role="alert", prefers-reduced-motion, safe area mobile, dan review yang kritis. Bacaan terkait: Claude Code accessibility, animation implementation, responsive design, dan React development.
Aturan desain
Toast bukan modal. Ia tidak boleh mengambil focus atau menghentikan pekerjaan user. Gunakan untuk feedback pendek yang tidak memblokir. Jika user harus memperbaiki field, mengonfirmasi aksi berisiko, memulihkan pembayaran, atau membaca instruksi panjang, tampilkan juga pesan yang menetap di halaman.
Implementasi ini memakai aturan berikut:
- maksimal 3 toast terlihat
success,info, danwarningmemakairole="status"errormemakairole="alert"hanya untuk masalah mendesak- auto-dismiss, tetapi pause saat hover dan focus
- setiap toast punya tombol tutup
- animasi mati saat
prefers-reduced-motion - spacing mobile memakai
env(safe-area-inset-*)
Rujukan resmi: MDN status role, MDN alert role, W3C WCAG Status Messages, W3C WCAG Pause, Stop, Hide, MDN setTimeout, MDN prefers-reduced-motion, dan Claude Code docs.
Kode React siap salin
Buat ToastProvider.tsx. File ini hanya butuh React. Untuk Next.js App Router, tambahkan "use client"; di baris pertama.
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="Notifikasi">
{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={`Tutup ${toast.title}`} onClick={() => onDismiss(toast.id)}>
×
</button>
</section>
);
}
Tambahkan 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; }
}
Contoh penggunaan:
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: "Profil tersimpan",
description: "Perubahan akan muncul saat layar ini dibuka lagi.",
});
} catch {
showToast({
tone: "error",
title: "Gagal menyimpan",
description: "Periksa koneksi lalu coba lagi.",
durationMs: 8000,
});
}
}
return <button onClick={handleSave}>Simpan</button>;
}
export default function App() {
return (
<ToastProvider>
<main>
<h1>Pengaturan</h1>
<SaveProfileButton />
</main>
</ToastProvider>
);
}
3 use case nyata
Pertama, halaman pengaturan. Toast sukses membuat user yakin data sudah tersimpan tanpa modal. Error validasi tetap harus berada dekat field; toast hanya merangkum.
Kedua, pekerjaan background seperti ekspor CSV, ringkasan AI, pemrosesan gambar, atau pengiriman email. Minta Claude Code membuat state mulai, sukses, gagal, batal, dan retry.
Ketiga, funnel monetisasi. Toast dapat mengonfirmasi PDF gratis sudah dikirim, permintaan konsultasi diterima, atau download berbayar disiapkan. Jangan biarkan toast menutupi harga, link Gumroad, form newsletter, atau tombol sticky mobile. Untuk pengukuran, sambungkan dengan Claude Code analytics implementation.
Kesalahan umum
Jangan memakai role="alert" untuk semua pesan. Success dan info biasa cukup memakai status; alert hanya untuk error mendesak.
Jangan auto-dismiss terlalu cepat. Lima detik adalah default yang aman, dan error sebaiknya lebih lama. Pause saat hover/focus memberi waktu untuk membaca dan menutup.
Jangan taruh informasi kritis hanya di toast. Payment failure, masalah permission, dan form validation harus tetap terlihat di halaman.
Hindari blinking dan animasi berulang. Animasi masuk harus pendek dan mati saat reduced motion.
Prompt untuk Claude Code
Implementasikan toast notification yang aksesibel dengan React + TypeScript.
Edit hanya ToastProvider.tsx dan toast.css.
Kebutuhan:
- tone success/info/warning/error
- maksimum 3 toast terlihat
- auto-dismiss, tombol tutup, pause saat hover/focus
- role="status" untuk pesan tidak mendesak
- role="alert" hanya untuk error mendesak
- aria-atomic="true"
- dukung prefers-reduced-motion
- dukung mobile safe-area
- error form penting tidak boleh hanya di toast
Verifikasi:
- npm run typecheck
- npm run lint
- test sukses, error, lebih dari 3 toast, hover pause, focus pause, keyboard close
Review implementasi toast ini secara kritis.
Cek status/alert, waktu auto-dismiss, pause hover/focus,
reduced motion, mobile safe areas, akses keyboard, dan informasi penting yang hilang.
Kembalikan temuan berdasarkan severity dengan file, baris, dan solusi konkret.
Masukkan checklist ini ke CLAUDE.md best practices dan review workflow checklist.
Verifikasi langsung
Saya mencoba pola ini di proyek React kecil dengan ToastProvider.tsx dan toast.css terpisah. Saya memicu success, error, lebih dari tiga toast, hover pause, focus pause, tombol tutup, dan reduced motion. Sisa waktu disimpan di useRef, jadi setelah pause timer tidak mulai lagi dari lima detik. Di production, tambahkan Playwright, axe, dan pengecekan manual dengan screen reader.
Penutup
Toast kecil, tetapi berdampak pada aksesibilitas, kepercayaan, dan konversi mobile. Saat meminta Claude Code, jelaskan perilaku, batasan, dan kriteria review, bukan hanya bentuk visual.
Untuk belajar sendiri, mulai dari free Claude Code cheatsheet. Untuk prompt dan materi siap pakai, lihat produk ClaudeCodeLab. Untuk tim yang ingin menstandardisasi UI review, aksesibilitas, dan guardrail monetisasi, mulai dari Claude Code training and consultation.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Permission safety ladder Claude Code: perluas akses tanpa kehilangan kontrol
Naik dari read-only ke edit terbatas, command bukti, dan cek deploy dengan kontrol yang jelas.
Claude Code Small PR Proof Pack: perubahan kecil yang mudah direview
Paket bukti untuk PR Claude Code: diff, check, URL publik, jalur CTA, dan rollback.
Review gate Claude Code sebelum commit: diff, test, URL publik, dan CTA
Cara memakai Claude Code sebelum commit: diff scope, build, URL publik, link Gumroad, CTA konsultasi, missing test, dan file tidak terkait.