Toasts apilados
Notificaciones con barra de progreso drenándose y pausa al pasar el ratón.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
/**
* Toasts apilados: hook declarativo useToasts() y componente ToastViewport. Cada toast entra
* con resorte desde la esquina inferior derecha, drena una barra de progreso con CSS y se cierra
* automáticamente. Al pasar el ratón se pausa el progreso; al hacer clic fuera del toast la barra
* continúa. Cumple prefers-reduced-motion.
*
* const toasts = useToasts();
* toasts.show({ title: "Listo", variant: "success" });
*
* <ToastViewport toasts={toasts.list} onDismiss={toasts.dismiss} />
*/
import { useEffect, useRef, useState } from "react";
export type ToastVariant = "default" | "info" | "success" | "error";
export type Toast = {
id: string;
title: string;
description?: string;
variant?: ToastVariant;
duration?: number;
action?: { label: string; onClick: () => void };
};
let _counter = 0;
function nextId() {
_counter += 1;
return `t-${Date.now().toString(36)}-${_counter}`;
}
export function useToasts() {
const [list, setList] = useState<Toast[]>([]);
function show(input: Omit<Toast, "id">) {
const item = { ...input, id: nextId() };
setList((curr) => [...curr, item]);
return item.id;
}
function dismiss(id: string) {
setList((curr) => curr.filter((t) => t.id !== id));
}
function update(id: string, patch: Partial<Toast>) {
setList((curr) => curr.map((t) => (t.id === id ? { ...t, ...patch } : t)));
}
function clear() {
setList([]);
}
return { list, show, dismiss, update, clear };
}
const CSS = `
.gtst-viewport { position: fixed; bottom: 24px; right: 24px; z-index: 100; display: flex; flex-direction: column; align-items: flex-end; gap: 10px; pointer-events: none; max-width: min(380px, calc(100vw - 32px)); font-family: inherit; }
.gtst-toast { pointer-events: auto; position: relative; display: grid; grid-template-columns: 1fr; gap: 6px; width: 100%; padding: 14px 16px 16px; border-radius: 10px; background: #13151b; color: #f0f2f6; box-shadow: 0 10px 30px -10px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.05); overflow: hidden; animation: gtst-in .42s cubic-bezier(.34,1.36,.64,1) both; }
.gtst-toast[data-state="leaving"] { animation: gtst-out .28s cubic-bezier(.4,0,.2,1) both; }
.gtst-toast[data-variant="success"] { background: linear-gradient(180deg, #0f3324 0%, #13151b 70%); }
.gtst-toast[data-variant="error"] { background: linear-gradient(180deg, #3a151b 0%, #13151b 70%); }
.gtst-toast[data-variant="info"] { background: linear-gradient(180deg, #122847 0%, #13151b 70%); }
.gtst-bar { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: rgba(255,255,255,.15); }
.gtst-toast[data-variant="success"] .gtst-bar { background: #22c55e; }
.gtst-toast[data-variant="error"] .gtst-bar { background: #ef4444; }
.gtst-toast[data-variant="info"] .gtst-bar { background: #3b82f6; }
.gtst-toast[data-variant="default"] .gtst-bar { background: rgba(255,255,255,.25); }
.gtst-header { display: flex; align-items: flex-start; gap: 10px; }
.gtst-mark { flex-shrink: 0; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; font-size: 14px; font-weight: 700; line-height: 1; color: #fff; background: rgba(255,255,255,.1); }
.gtst-toast[data-variant="success"] .gtst-mark { background: rgba(34, 197, 94, .22); color: #4ade80; }
.gtst-toast[data-variant="error"] .gtst-mark { background: rgba(239, 68, 68, .22); color: #fb7185; }
.gtst-toast[data-variant="info"] .gtst-mark { background: rgba(59, 130, 246, .22); color: #60a5fa; }
.gtst-body { flex: 1; min-width: 0; }
.gtst-title { font-size: 14.5px; font-weight: 600; line-height: 1.3; letter-spacing: -.005em; }
.gtst-desc { margin-top: 3px; font-size: 13px; line-height: 1.45; color: rgba(240, 242, 246, .7); }
.gtst-close { background: transparent; border: 0; padding: 0; width: 22px; height: 22px; display: grid; place-items: center; color: rgba(240, 242, 246, .55); cursor: pointer; border-radius: 6px; transition: background-color .12s ease, color .12s ease; }
.gtst-close:hover { color: #fff; background: rgba(255,255,255,.08); }
.gtst-close:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
.gtst-foot { display: flex; align-items: center; gap: 12px; margin-top: 2px; }
.gtst-action { background: transparent; border: 0; padding: 0; font: inherit; font-size: 13px; font-weight: 600; color: #f0f2f6; cursor: pointer; border-bottom: 1px solid currentColor; padding-bottom: 1px; }
.gtst-action:hover { color: #93c5fd; }
.gtst-action:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
.gtst-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: currentColor; opacity: .35; transform-origin: left; transform: scaleX(1); }
.gtst-toast[data-variant="success"] .gtst-progress { color: #22c55e; }
.gtst-toast[data-variant="error"] .gtst-progress { color: #ef4444; }
.gtst-toast[data-variant="info"] .gtst-progress { color: #3b82f6; }
.gtst-toast[data-variant="default"] .gtst-progress { color: rgba(255,255,255,.4); }
@keyframes gtst-in { from { opacity: 0; transform: translateY(20px) scale(.96); } 70% { transform: translateY(-2px) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes gtst-out { to { opacity: 0; transform: translateX(20px) scale(.96); } }
@media (prefers-reduced-motion: reduce) {
.gtst-toast { animation: none; }
.gtst-toast[data-state="leaving"] { animation: none; opacity: 0; transition: opacity .15s ease; }
}
`;
const VARIANT_GLYPH: Record<ToastVariant, string> = {
default: "•",
info: "i",
success: "✓",
error: "!",
};
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const [state, setState] = useState<"in" | "leaving">("in");
const [paused, setPaused] = useState(false);
const duration = toast.duration ?? 4500;
const dismissedRef = useRef(false);
useEffect(() => {
if (state === "leaving") return;
let remaining = duration;
let start = Date.now();
let timer: number | null = null;
const progress = () => {
if (dismissedRef.current) return;
if (paused) {
start = Date.now() - (duration - remaining);
timer = window.setTimeout(progress, 100);
return;
}
const elapsed = Date.now() - start;
remaining = Math.max(0, duration - elapsed);
const ratio = remaining / duration;
const bar = document.querySelector(`[data-progress="${toast.id}"]`) as HTMLElement | null;
if (bar) bar.style.transform = `scaleX(${ratio})`;
if (remaining <= 0) {
setState("leaving");
window.setTimeout(onDismiss, 280);
return;
}
timer = window.setTimeout(progress, 32);
};
timer = window.setTimeout(progress, 32);
return () => {
if (timer !== null) window.clearTimeout(timer);
};
}, [duration, paused, state, toast.id, onDismiss]);
function close() {
if (dismissedRef.current) return;
dismissedRef.current = true;
setState("leaving");
window.setTimeout(onDismiss, 280);
}
return (
<div className="gtst-toast" data-variant={toast.variant ?? "default"} data-state={state} role="status" aria-live="polite" onPointerEnter={() => setPaused(true)} onPointerLeave={() => setPaused(false)}>
<span className="gtst-bar" aria-hidden="true" />
<div className="gtst-header">
<span className="gtst-mark" aria-hidden="true">{VARIANT_GLYPH[toast.variant ?? "default"]}</span>
<div className="gtst-body">
<div className="gtst-title">{toast.title}</div>
{toast.description ? <div className="gtst-desc">{toast.description}</div> : null}
{toast.action ? (
<div className="gtst-foot">
<button
type="button"
className="gtst-action"
onClick={() => {
toast.action!.onClick();
close();
}}
>
{toast.action.label}
</button>
</div>
) : null}
</div>
<button type="button" className="gtst-close" onClick={close} aria-label="Cerrar">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M2 2l10 10M12 2L2 12" />
</svg>
</button>
</div>
<span className="gtst-progress" data-progress={toast.id} aria-hidden="true" />
</div>
);
}
export function ToastViewport({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
return (
<>
<style>{CSS}</style>
<div className="gtst-viewport" aria-label="Notificaciones">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={() => onDismiss(toast.id)} />
))}
</div>
</>
);
}
/** Pequeño wrapper de vitrina: una hilera de botones para disparar las cuatro variantes. */
export function ToastStackDemo() {
const toasts = useToasts();
function fire(variant: ToastVariant) {
const messages: Record<ToastVariant, { title: string; description?: string }> = {
default: { title: "Cambios guardados", description: "Tu trabajo ya está al día." },
info: { title: "Tienes 3 invitaciones pendientes", description: "Revisa tu bandeja de entrada." },
success: { title: "Pago confirmado", description: "Recibirás el comprobante por correo." },
error: { title: "No pudimos enviar el archivo", description: "Verifica tu conexión e inténtalo de nuevo." },
};
toasts.show({ ...messages[variant], variant, duration: 4200, action: variant === "error" ? { label: "Reintentar", onClick: () => {} } : undefined });
}
return (
<div className="lib-stage-row" style={{ gap: 10, flexWrap: "wrap", justifyContent: "center" }}>
{(["default", "info", "success", "error"] as ToastVariant[]).map((variant) => (
<button
key={variant}
type="button"
onClick={() => fire(variant)}
style={{
padding: "10px 16px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "rgba(255,255,255,.06)",
color: "#e8eaef",
font: "inherit",
fontSize: 14,
cursor: "pointer",
textTransform: "capitalize",
}}
>
{variant}
</button>
))}
<ToastViewport toasts={toasts.list} onDismiss={toasts.dismiss} />
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/toast-stackEjemplo de uso de Toasts apilados
Después de copiar toast-stack.tsx a tu proyecto, impórtalo y colócalo donde lo necesites. Exporta 2 componentes: ToastViewport, ToastStackDemo.
import { ToastViewport } from "./toast-stack";
export default function Ejemplo() {
return <ToastViewport />;
}Accesibilidad y rendimiento
El archivo tiene 235 líneas (10 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Toasts apilados por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: status.
- Es un componente de cliente de Next.js: incluye la directiva "use client".
Acerca del producto
Pila de notificaciones que aparecen desde la esquina inferior derecha con resorte. Cada toast lleva título, descripción opcional, variante (info, éxito, error), acción opcional y una barra de progreso que drena mientras se cumple su tiempo de vida. Al pasar el ratón sobre un toast, su progreso se pausa.
Incluye
Un solo archivo React + TypeScript, con los estilos dentro. No usa Tailwind ni bibliotecas externas.
- Hook useToasts() y componente ToastViewport declarativos
- Cuatro variantes: default, info, éxito y error
- Barra de progreso drenándose con CSS, pausa al pasar el ratón
- Autocierre y acción opcional por toast
- Pila apilada arriba del contenido con resorte en la entrada
Cómo usarlo
Llama a useToasts() para obtener show, dismiss, update y clear; monta <ToastViewport toasts={list} onDismiss={dismiss} /> cerca del final del árbol.