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.
Busca en todas las páginas de la documentació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.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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.
"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:
useToast() devuelve una función toast() que puede ser llamada desde cualquier componente sin prop drilling. El proveedor renderiza el contenedor de notificaciones a través de un portal.useRef pausa el temporizador de cuenta regresiva cuando el usuario pasa el ratón sobre la notificación, usando onMouseEnter/onMouseLeave. Se usa una ref en lugar de state para evitar re-renderizados en cada tick del intervalo.translate-x-4 opacity-0 y hace transición a translate-x-0 opacity-100 a través de requestAnimationFrame. Al descartar, la transición inversa se reproduce durante 200ms antes de que el elemento se elimine del state.slice(-(maxToasts - 1)) elimina las notificaciones más antiguas cuando se excede el límite, evitando que la pantalla se llene de notificaciones.ToastCard gestiona su propia barra de progreso de forma independiente a través de setInterval. La barra se pausa al pasar el ratón.createPortal renderiza el contenedor en document.body para evitar problemas de contexto de apilamiento. El contenedor es pointer-events-none mientras que las tarjetas individuales son pointer-events-auto para 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.Cierre obsoleto en devoluciones de llamada de temporizador -- si onClose cambia de identidad entre renderizados (por ejemplo, una función de flecha en línea), la limpieza de useEffect y la re-configuración pueden causar que el temporizador se reinicie. Envuelve el onClose del componente padre en useCallback para 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" o aria-live -- sin estos atributos, los lectores de pantalla no anuncian la notificación. Usa role="alert" en notificaciones individuales o aria-live="polite" en el contenedor.
Conflictos de z-index con modales -- las notificaciones fijas en z-50 pueden 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 setTimeout se active, la devolución de llamada se ejecuta en estado obsoleto. Siempre devuelve una función de limpieza desde useEffect para borrar el temporizador.
Entrecortado de la animación de la barra de progreso -- usar CSS transition en el ancho de la barra de progreso compite con las actualizaciones impulsadas por intervalo de JavaScript, causando movimiento entrecortado. Aplica transition-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 visualViewport para ajustar la colocación.
Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥