Ir al contenido
Evaluar mi proyecto
Componentes · MicrointeraccionesDisponible

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.

toast-stack.tsx235 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/toast-stack

Ejemplo 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.

ejemplo.tsx5 líneas
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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React · Next.js (quita la directiva use client fuera de Next.js) · Sin Tailwind

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.