Toast
Uma notificação breve e auto-dismissível que aparece na borda da tela para informar os usuários sobre o resultado de uma ação sem interromper seu fluxo de trabalho.
Busque em todas as páginas da documentação
Uma notificação breve e auto-dismissível que aparece na borda da tela para informar os usuários sobre o resultado de uma ação sem interromper seu fluxo de trabalho.
🤖 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>
);
}Um toast mínimo que se auto-descarte após duration milissegundos. A limpeza do useEffect cancela o timer se o toast for fechado cedo ou o componente for desmontado, evitando atualizações 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>
);
}Mapeia tipos de toast para classes de cores e ícones usando Record. O atributo role="alert" garante que leitores de tela anunciem o toast imediatamente quando ele 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>
);
}O botão de ação chama seu manipulador e então fecha o toast. A duração é definida para mais tempo (5 segundos) para dar aos usuários tempo suficiente para ler e agir. O botão de ação usa shrink-0 para evitar que ele seja comprimido em telas estreitas.
"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>
);
}A barra de progresso diminui de 100% para 0% ao longo da duração usando setInterval. A classe transition-none impede que as transições do Tailwind interfiram na animação suave controlada por JavaScript. O progresso é redefinido para 100% quando o toast fecha.
"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 toast gerencia seu próprio timer de auto-descarte independentemente. O contêiner usa flex-col-reverse para que novos toasts empilhem acima dos antigos. O hook useToast é colocado em um componente pai, e ToastContainer é renderizado uma vez no nível do 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>
);
}Um Record mapeia nomes de posição para classes de posicionamento do Tailwind. As posições centralizadas usam left-1/2 -translate-x-1/2 para centralização horizontal, independentemente da largura do toast.
"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>
);
}Adiciona um ícone de fechar que permite aos usuários descartar o toast manualmente. O aria-label="Dismiss" fornece um nome acessível para o botão que contém apenas um ícone. O timer de auto-descarte ainda é executado, então o toast fecha sozinho se o usuário o ignorar.
"use client";
import { createContext, useContext, useCallback, useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
// --- Tipos ---
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;
}
// --- Contexto ---
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;
}
// --- Card de Toast ---
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 Chave:
useToast() retorna uma função toast() que pode ser chamada de qualquer componente sem prop drilling. O provider renderiza o contêiner de toast através de um portal.useRef pausa o timer de contagem regressiva quando o usuário passa o mouse sobre o toast, usando onMouseEnter/onMouseLeave. Um ref é usado em vez de estado para evitar re-renderizações a cada tick do intervalo.translate-x-4 opacity-0 e transiciona para translate-x-0 opacity-100 via requestAnimationFrame. Ao descartar, o reverso é reproduzido por 200ms antes que o item seja removido do estado.slice(-(maxToasts - 1)) trunca os toasts mais antigos quando o limite é excedido, evitando que a tela se encha de notificações.ToastCard gerencia sua própria barra de progresso independentemente via setInterval. A barra pausa quando o mouse passa sobre ela.createPortal renderiza o contêiner em document.body para evitar problemas de empilhamento de contexto. O contêiner tem pointer-events-none enquanto os cartões individuais têm pointer-events-auto, permitindo que cliques passem pelo espaço vazio.aria-live="polite" -- o contêiner anuncia novos toasts para leitores de tela sem interromper a fala atual, tornando o componente acessível por padrão.Closure obsoleto em callbacks de timer -- se onClose mudar de identidade entre renderizações (por exemplo, uma função de seta inline), a limpeza e reconfiguração do useEffect podem fazer o timer reiniciar. Envolva o onClose do pai em useCallback para manter uma referência estável.
Múltiplos toasts se sobrepondo -- se você armazenar um único { open, message } no estado, disparar um segundo toast antes que o primeiro termine substitui o primeiro silenciosamente. Use uma abordagem baseada em array (como a variação empilhada) para enfileiramento confiável.
Falta de role="alert" ou aria-live -- sem esses atributos, leitores de tela não anunciam o toast. Use role="alert" em toasts individuais ou aria-live="polite" no contêiner.
Conflitos de z-index com modais -- toasts fixados em z-50 podem aparecer atrás de modais ou drawers que usam valores de z-index mais altos. Certifique-se de que o z-index do contêiner de toast seja o mais alto em sua ordem de empilhamento.
Timer não cancelado na desmontagem -- se o componente for desmontado antes que setTimeout dispare, o callback é executado em estado obsoleto. Sempre retorne uma função de limpeza de useEffect para cancelar o timer.
Animação de barra de progresso travando -- usar transition CSS na largura da barra de progresso compete com as atualizações impulsionadas pelo intervalo JavaScript, causando movimento irregular. Aplique transition-none (Tailwind) à barra de progresso e deixe o JavaScript controlar a largura diretamente.
Toast aparecendo atrás do teclado no celular -- toasts posicionados na parte inferior podem ser ocultos pelo teclado virtual em dispositivos móveis. Use toasts posicionados na parte superior em fluxos com muitos formulários, ou ouça o evento de redimensionamento visualViewport para ajustar o posicionamento.
Revisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥