Notificaciones toast accesibles en React con Claude Code
Guía React para toast con cola, autocierre, pausa, aria-live, movimiento reducido y safe areas móviles.
Una notificación toast parece un detalle pequeño: “Guardado”, “Exportación iniciada”, “No se pudo enviar”. En producción, ese detalle afecta accesibilidad, confianza, móvil y conversión. Un toast mal hecho desaparece antes de leerse, interrumpe lectores de pantalla con demasiados alert, tapa el CTA de compra o deja un error importante en un mensaje que se evapora.
Claude Code puede generar el componente visual en minutos. Para publicarlo con calidad hay que pedir cola, autocierre, pausa con hover y foco, diferencia entre role="status" y role="alert", soporte para prefers-reduced-motion, safe areas móviles y revisión crítica. Para contexto adicional, lee accesibilidad con Claude Code, animaciones, diseño responsive y React development.
Reglas de diseño
Un toast no es un modal. No debe bloquear la tarea ni robar foco. Úsalo para feedback breve y no bloqueante. Si la persona debe corregir un campo, confirmar una acción destructiva, recuperar un pago o leer instrucciones largas, deja también un mensaje persistente en la página.
Esta implementación usa estas reglas:
- máximo 3 toasts visibles
success,infoywarningusanrole="status"errorusarole="alert"solo si requiere atención inmediata- autocierre con pausa al pasar el mouse o enfocar
- botón de cierre en cada toast
- animación desactivada con
prefers-reduced-motion - separación con
env(safe-area-inset-*)en móvil
Consulta fuentes oficiales: MDN status role, MDN alert role, W3C WCAG Status Messages, W3C WCAG Pause, Stop, Hide, MDN setTimeout, MDN prefers-reduced-motion y Claude Code docs.
Código React copiable
Crea ToastProvider.tsx. Solo depende de React. En Next.js App Router añade "use client"; al inicio.
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="Notificaciones">
{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={`Cerrar ${toast.title}`} onClick={() => onDismiss(toast.id)}>
×
</button>
</section>
);
}
Añade 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; }
}
Uso básico:
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: "Perfil guardado",
description: "Los cambios aparecerán la próxima vez que abras esta pantalla.",
});
} catch {
showToast({
tone: "error",
title: "No se pudo guardar",
description: "Revisa la conexión e inténtalo de nuevo.",
durationMs: 8000,
});
}
}
return <button onClick={handleSave}>Guardar</button>;
}
export default function App() {
return (
<ToastProvider>
<main>
<h1>Ajustes</h1>
<SaveProfileButton />
</main>
</ToastProvider>
);
}
Casos de uso reales
El primer caso es una pantalla de ajustes. Un toast de éxito confirma que el guardado terminó sin abrir un modal. Los errores de campos deben quedar junto al campo; el toast solo resume.
El segundo caso son tareas en segundo plano: exportar CSV, generar un resumen con IA, procesar imágenes o enviar correos. Pide a Claude Code los estados de inicio, éxito, fallo, reintento y cancelación.
El tercer caso es monetización. Un toast puede confirmar que se envió un PDF gratuito, se recibió una solicitud de consultoría o se preparó una descarga pagada. No debe tapar precios, enlaces de Gumroad, formularios ni botones fijos inferiores. Para medir impacto, combínalo con analytics implementation.
Fallos comunes
No uses role="alert" para todo. Los mensajes no urgentes deben ser status; alert queda para fallos que requieren atención inmediata.
No cierres demasiado rápido. Cinco segundos suele ser un buen valor por defecto y los errores necesitan más tiempo. La pausa con hover/focus ayuda a usuarios de mouse y teclado.
No pongas información crítica solo en el toast. Pagos fallidos, permisos insuficientes y validación de formularios deben permanecer en la pantalla.
No uses parpadeos ni animaciones en bucle. Mantén la entrada corta y apágala con reduced motion.
Prompts para Claude Code
Implementa notificaciones toast accesibles con React + TypeScript.
Edita solo ToastProvider.tsx y toast.css.
Requisitos:
- tonos success/info/warning/error
- máximo 3 toasts visibles
- autocierre, botón de cierre, pausa con hover/focus
- role="status" para mensajes no urgentes
- role="alert" solo para errores urgentes
- aria-atomic="true"
- soporte prefers-reduced-motion
- safe-area en móvil
- ningún error importante debe existir solo en un toast
Verificación:
- npm run typecheck
- npm run lint
- probar éxito, error, más de 3 toasts, pausa por hover/focus y cierre con teclado
Revisa críticamente esta implementación de toast.
Comprueba status/alert, tiempos de autocierre, pausa hover/focus,
reduced motion, safe areas, teclado y si información importante desaparece.
Devuelve hallazgos por severidad con archivo, línea y arreglo concreto.
Puedes convertir estos criterios en checklist con CLAUDE.md best practices y review workflow checklist.
Verificación práctica
Probé el patrón en un proyecto React pequeño separando ToastProvider.tsx y toast.css. Validé éxito, error, más de tres toasts, pausa con hover, pausa con foco, botón de cierre y reduced motion. El tiempo restante vive en useRef, así que al reanudar no empieza de cero. En producción añade Playwright, axe y una pasada manual con lector de pantalla.
Cierre
Los toast son pequeños, pero afectan accesibilidad, confianza y conversión móvil. Al pedirlos a Claude Code, define comportamiento, límites y revisión, no solo el diseño visual.
Para aprender solo, empieza con la chuleta gratuita de Claude Code. Para prompts y material listo, revisa productos ClaudeCodeLab. Si tu equipo necesita estandarizar revisión UI, accesibilidad y rutas de monetización, empieza por formación y consultoría Claude Code.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.