Notificación
Una notificación breve que se cierra automáticamente y aparece al borde de la pantalla para informar a los usuarios del resultado de una acción sin interrumpir su flujo de trabajo.
Casos de uso
- Confirmar un envío de formulario exitoso (elemento guardado, perfil actualizado)
- Advertir sobre un problema no crítico (conexión lenta, función obsoleta)
- Mostrar retroalimentación de error después de una llamada a API fallida
- Informar sobre eventos en segundo plano (nuevo mensaje recibido, archivo cargado)
- Mostrar acciones deshacer después de operaciones destructivas (elemento eliminado — deshacer)
- Notificar sobre cambios de permisos o acceso
Implementación más simple
"use client";
import { useState, useEffect } from "react";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
duration?: number;
}
export function Toast({ message, open, onClose, duration = 3000 }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div className="fixed bottom-4 right-4 z-50 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
{message}
</div>
);
}Una notificación mínima que se cierra automáticamente después de duration milisegundos. La limpieza de useEffect borra el temporizador si la notificación se cierra temprano o el componente se desmonta, evitando actualizaciones de estado obsoletas.
Variaciones
Tipos Éxito / Error / Advertencia / Información
"use client";
import { useEffect } from "react";
type ToastType = "success" | "error" | "warning" | "info";
interface ToastProps {
message: string;
type?: ToastType;
open: boolean;
onClose: () => void;
duration?: number;
}
const typeClasses: Record<ToastType, string> = {
success: "bg-green-600 text-white",
error: "bg-red-600 text-white",
warning: "bg-yellow-500 text-gray-900",
info: "bg-blue-600 text-white",
};
const typeIcons: Record<ToastType, string> = {
success: "✓",
error: "✕",
warning: "⚠",
info: "ℹ",
};
export function Toast({ message, type = "info", open, onClose, duration = 3000 }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div
role="alert"
className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium shadow-lg ${typeClasses[type]}`}
>
<span aria-hidden="true">{typeIcons[type]}</span>
{message}
</div>
);
}Mapea tipos de notificación a clases de color e iconos usando Record. El atributo role="alert" asegura que los lectores de pantalla anuncien la notificación inmediatamente cuando aparece.
Con botón de acción
"use client";
import { useEffect } from "react";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
}
export function Toast({ message, open, onClose, duration = 5000, action }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
<span>{message}</span>
{action && (
<button
onClick={() => {
action.onClick();
onClose();
}}
className="shrink-0 rounded px-2 py-1 font-semibold text-blue-400 hover:bg-gray-800"
>
{action.label}
</button>
)}
</div>
);
}El botón de acción llama a su manejador y luego cierra la notificación. La duración se establece más larga (5 segundos) para dar a los usuarios tiempo suficiente para leer y actuar. El botón de acción usa shrink-0 para evitar que se comprima en pantallas estrechas.
Con barra de progreso
"use client";
import { useEffect, useState } from "react";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
duration?: number;
}
export function Toast({ message, open, onClose, duration = 4000 }: ToastProps) {
const [progress, setProgress] = useState(100);
useEffect(() => {
if (!open) {
setProgress(100);
return;
}
const interval = 50;
const step = (interval / duration) * 100;
const timer = setInterval(() => {
setProgress((prev) => {
const next = prev - step;
if (next <= 0) {
clearInterval(timer);
onClose();
return 0;
}
return next;
});
}, interval);
return () => clearInterval(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div className="fixed bottom-4 right-4 z-50 w-80 overflow-hidden rounded-lg bg-gray-900 shadow-lg">
<div className="px-4 py-3 text-sm text-white">{message}</div>
<div className="h-1 bg-gray-700">
<div
className="h-full bg-blue-500 transition-none"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
}La barra de progreso se agota del 100% al 0% durante la duración usando setInterval. La clase transition-none evita que las transiciones de Tailwind interfieran con la animación suave impulsada por JavaScript. El progreso se reinicia al 100% cuando la notificación se cierra.
Notificaciones apiladas
"use client";
import { useState, useCallback, useEffect } from "react";
interface ToastItem {
id: number;
message: string;
}
let toastId = 0;
export function useToast() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const add = useCallback((message: string) => {
const id = ++toastId;
setToasts((prev) => [...prev, { id, message }]);
}, []);
const remove = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return { toasts, add, remove };
}
function ToastCard({ item, onRemove }: { item: ToastItem; onRemove: (id: number) => void }) {
useEffect(() => {
const timer = setTimeout(() => onRemove(item.id), 3000);
return () => clearTimeout(timer);
}, [item.id, onRemove]);
return (
<div className="rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
{item.message}
</div>
);
}
export function ToastContainer({ toasts, onRemove }: { toasts: ToastItem[]; onRemove: (id: number) => void }) {
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-2">
{toasts.map((t) => (
<ToastCard key={t.id} item={t} onRemove={onRemove} />
))}
</div>
);
}Cada notificación gestiona su propio temporizador de cierre automático de forma independiente. El contenedor usa flex-col-reverse para que las nuevas notificaciones se apilen encima de las más antiguas. El hook useToast se coloca en un componente padre, y ToastContainer se renderiza una sola vez en el nivel de layout.
Posicionado (Superior / Inferior)
"use client";
import { useEffect } from "react";
type Position = "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
position?: Position;
duration?: number;
}
const positionClasses: Record<Position, string> = {
"top-right": "top-4 right-4",
"top-left": "top-4 left-4",
"bottom-right": "bottom-4 right-4",
"bottom-left": "bottom-4 left-4",
"top-center": "top-4 left-1/2 -translate-x-1/2",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
};
export function Toast({ message, open, onClose, position = "bottom-right", duration = 3000 }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div
role="status"
className={`fixed z-50 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg ${positionClasses[position]}`}
>
{message}
</div>
);
}Un Record mapea nombres de posición a clases de posicionamiento de Tailwind. Las posiciones centradas usan left-1/2 -translate-x-1/2 para centrado horizontal independientemente del ancho de la notificación.
Descartar con botón de cierre
"use client";
import { useEffect } from "react";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
duration?: number;
dismissible?: boolean;
}
export function Toast({ message, open, onClose, duration = 3000, dismissible = true }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div
role="alert"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg"
>
<span>{message}</span>
{dismissible && (
<button
onClick={onClose}
aria-label="Dismiss"
className="shrink-0 rounded p-0.5 text-gray-400 hover:text-white"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}Añade un icono de cierre que permite a los usuarios descartar la notificación manualmente. El aria-label="Dismiss" proporciona un nombre accesible para el botón solo con icono. El temporizador de cierre automático sigue ejecutándose, así que la notificación se cierra por sí sola si el usuario la ignora.
Implementación compleja
"use client";
import { createContext, useContext, useCallback, useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
// --- Types ---
type ToastType = "success" | "error" | "warning" | "info";
type Position = "top-right" | "top-left" | "bottom-right" | "bottom-left";
interface ToastAction {
label: string;
onClick: () => void;
}
interface ToastItem {
id: number;
message: string;
type: ToastType;
duration: number;
action?: ToastAction;
dismissible: boolean;
}
interface ToastOptions {
type?: ToastType;
duration?: number;
action?: ToastAction;
dismissible?: boolean;
}
interface ToastContextValue {
toast: (message: string, options?: ToastOptions) => number;
dismiss: (id: number) => void;
dismissAll: () => void;
}
// --- Context ---
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside ToastProvider");
return ctx;
}
// --- Toast card ---
const typeConfig: Record<ToastType, { bg: string; icon: string }> = {
success: { bg: "bg-green-600", icon: "✓" },
error: { bg: "bg-red-600", icon: "✕" },
warning: { bg: "bg-yellow-500 text-gray-900", icon: "⚠" },
info: { bg: "bg-blue-600", icon: "ℹ" },
};
function ToastCard({
item,
onDismiss,
}: {
item: ToastItem;
onDismiss: (id: number) => void;
}) {
const [visible, setVisible] = useState(false);
const [progress, setProgress] = useState(100);
const pausedRef = useRef(false);
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
useEffect(() => {
const interval = 50;
const step = (interval / item.duration) * 100;
const timer = setInterval(() => {
if (pausedRef.current) return;
setProgress((prev) => {
const next = prev - step;
if (next <= 0) {
clearInterval(timer);
setVisible(false);
setTimeout(() => onDismiss(item.id), 200);
return 0;
}
return next;
});
}, interval);
return () => clearInterval(timer);
}, [item.id, item.duration, onDismiss]);
const config = typeConfig[item.type];
return (
<div
role="alert"
onMouseEnter={() => (pausedRef.current = true)}
onMouseLeave={() => (pausedRef.current = false)}
className={`pointer-events-auto w-80 overflow-hidden rounded-lg shadow-lg transition-all duration-200 ${
visible ? "translate-x-0 opacity-100" : "translate-x-4 opacity-0"
} ${config.bg} ${item.type === "warning" ? "" : "text-white"}`}
>
<div className="flex items-start gap-2 px-4 py-3">
<span className="mt-0.5 text-sm" aria-hidden="true">
{config.icon}
</span>
<p className="flex-1 text-sm font-medium">{item.message}</p>
{item.dismissible && (
<button
onClick={() => {
setVisible(false);
setTimeout(() => onDismiss(item.id), 200);
}}
aria-label="Dismiss"
className="shrink-0 rounded p-0.5 opacity-70 hover:opacity-100"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{item.action && (
<div className="px-4 pb-3">
<button
onClick={() => {
item.action!.onClick();
onDismiss(item.id);
}}
className="text-sm font-semibold underline underline-offset-2 opacity-90 hover:opacity-100"
>
{item.action.label}
</button>
</div>
)}
<div className={`h-1 ${item.type === "warning" ? "bg-yellow-600/30" : "bg-white/20"}`}>
<div
className="h-full bg-white/50 transition-none"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
}
// --- Provider ---
let nextId = 0;
interface ToastProviderProps {
children: React.ReactNode;
position?: Position;
maxToasts?: number;
}
const positionClasses: Record<Position, string> = {
"top-right": "top-4 right-4 flex-col",
"top-left": "top-4 left-4 flex-col",
"bottom-right": "bottom-4 right-4 flex-col-reverse",
"bottom-left": "bottom-4 left-4 flex-col-reverse",
};
export function ToastProvider({ children, position = "bottom-right", maxToasts = 5 }: ToastProviderProps) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const dismissAll = useCallback(() => setToasts([]), []);
const toast = useCallback(
(message: string, options: ToastOptions = {}) => {
const id = ++nextId;
const item: ToastItem = {
id,
message,
type: options.type ?? "info",
duration: options.duration ?? 4000,
action: options.action,
dismissible: options.dismissible ?? true,
};
setToasts((prev) => [...prev.slice(-(maxToasts - 1)), item]);
return id;
},
[maxToasts]
);
return (
<ToastContext.Provider value={{ toast, dismiss, dismissAll }}>
{children}
{mounted &&
createPortal(
<div
aria-live="polite"
className={`fixed z-50 flex gap-2 pointer-events-none ${positionClasses[position]}`}
>
{toasts.map((t) => (
<ToastCard key={t.id} item={t} onDismiss={dismiss} />
))}
</div>,
document.body
)}
</ToastContext.Provider>
);
}Aspectos clave:
- API impulsada por contexto --
useToast()devuelve una funcióntoast()que puede ser llamada desde cualquier componente sin prop drilling. El proveedor renderiza el contenedor de notificaciones a través de un portal. - Pausa al pasar el ratón -- una bandera
useRefpausa el temporizador de cuenta regresiva cuando el usuario pasa el ratón sobre la notificación, usandoonMouseEnter/onMouseLeave. Se usa una ref en lugar de state para evitar re-renderizados en cada tick del intervalo. - Animación de deslizamiento -- cada notificación comienza con
translate-x-4 opacity-0y hace transición atranslate-x-0 opacity-100a través derequestAnimationFrame. Al descartar, la transición inversa se reproduce durante 200ms antes de que el elemento se elimine del state. - Límite máximo de notificaciones --
slice(-(maxToasts - 1))elimina las notificaciones más antiguas cuando se excede el límite, evitando que la pantalla se llene de notificaciones. - Barra de progreso por notificación -- cada
ToastCardgestiona su propia barra de progreso de forma independiente a través desetInterval. La barra se pausa al pasar el ratón. - Renderizado con portal --
createPortalrenderiza el contenedor endocument.bodypara evitar problemas de contexto de apilamiento. El contenedor espointer-events-nonemientras que las tarjetas individuales sonpointer-events-autopara que los clics pasen a través del espacio vacío. aria-live="polite"-- el contenedor anuncia nuevas notificaciones a los lectores de pantalla sin interrumpir el habla actual, haciendo que el componente sea accesible por defecto.
Problemas
-
Cierre obsoleto en devoluciones de llamada de temporizador -- si
onClosecambia de identidad entre renderizados (por ejemplo, una función de flecha en línea), la limpieza deuseEffecty la re-configuración pueden causar que el temporizador se reinicie. Envuelve elonClosedel componente padre enuseCallbackpara mantener una referencia estable. -
Múltiples notificaciones sobrescribiéndose entre sí -- si almacenas un
{ open, message }único en state, disparar una segunda notificación antes de que la primera termine reemplaza la primera silenciosamente. Usa un enfoque basado en arrays (como la variación apilada) para una cola confiable. -
Falta
role="alert"oaria-live-- sin estos atributos, los lectores de pantalla no anuncian la notificación. Usarole="alert"en notificaciones individuales oaria-live="polite"en el contenedor. -
Conflictos de z-index con modales -- las notificaciones fijas en
z-50pueden aparecer detrás de modales o cajones que usan valores de z-index más altos. Asegúrate de que el z-index del contenedor de notificaciones sea el más alto en tu orden de apilamiento. -
El temporizador no se borra al desmontar -- si el componente se desmonta antes de que
setTimeoutse active, la devolución de llamada se ejecuta en estado obsoleto. Siempre devuelve una función de limpieza desdeuseEffectpara borrar el temporizador. -
Entrecortado de la animación de la barra de progreso -- usar CSS
transitionen el ancho de la barra de progreso compite con las actualizaciones impulsadas por intervalo de JavaScript, causando movimiento entrecortado. Aplicatransition-none(Tailwind) a la barra de progreso y deja que JavaScript controle el ancho directamente. -
Notificación apareciendo detrás del teclado en móvil -- las notificaciones posicionadas en la parte inferior pueden estar ocultas por el teclado virtual en dispositivos móviles. Usa notificaciones posicionadas en la parte superior en flujos pesados en formularios, o escucha el evento de redimensionamiento de
visualViewportpara ajustar la colocación.