Toast
A brief message slides in and dismisses itself. Success, error, warning, and info feedback.
Variants: defaultsuccesserrorwarninginfo (default: default)
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
App-wide toaster + imperative API:
```tsx import { Toaster, toast } from '@/components/atoms/Toast';
// once in layout: <Toaster position="bottom-right" />
// on event: toast.success('Saved', { description: 'Profile updated' }); toast.error('Could not save', { duration: 6000 }); ```
## Accesibilidad
- Each toast is `role="status"` `aria-live="polite"`; default duration ~4000ms (`0` = sticky until dismiss). - Mount a single `Toaster` at the root — do not nest multiple hosts.
## Cuándo no usar
- Blocking confirms that require a decision → `AlertDialog`. - Inline field errors → `Field` error text, not a toast.
## Criterio de uso
- Usa Toast para confirmar brevemente un resultado que no requiere decisión ni interrumpe el flujo. - El mensaje debe nombrar la acción y el resultado; añade descripción o acción de recuperación sólo cuando aporte valor. - Usa duración sticky (`0`) sólo cuando el usuario necesita tiempo real para leer o actuar; no conviertas cada evento en una interrupción persistente.
## Gotchas
- Monta un solo `Toaster` en el root y dispara la API imperativa desde el evento; no montes árboles declarativos duplicados. - Para errores asociados a un campo o a una decisión irreversible, coloca el mensaje junto al contexto o usa `AlertDialog`.
Uso
import { Toaster, toast } from '@/components/atoms/Toast';
// once in layout:
<Toaster position="bottom-right" />
// on event:
toast.success('Saved', { description: 'Profile updated' });Props
position: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' = bottom-right
Gotchas
- react
Mount <Toaster position="bottom-right" /> once at app root. Call toast()/toast.success|error|warning|info — not a declarative Toast tree.
- a11y
Each toast is role=status aria-live=polite; default duration 4000ms (0 = sticky until dismiss).
Anatomía CSS
<div class="toaster"> </div>
| Clase | Propósito |
|---|---|
toaster | root |
toaster--bottom-center | modifier |
toaster--bottom-left | modifier |
toaster--bottom-right | modifier |
toaster--top-center | modifier |
toaster--top-left | modifier |
toaster--top-right | modifier |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #ffffff | popover |
| all | fg | inherit | (literal) |
| all | border | 1px | stroke.hairline |
| success | bg | #25d366 | success |
| success | fg | #0a0a0a | success.foreground |
| success | border | #25d366 | success |
| all | error-bg | #f84131 | destructive |
| all | error-fg | #0a0a0a | destructive.foreground |
| all | error-border | #f84131 | destructive |
| warning | bg | #fe9900 | warning |
| warning | fg | #471901 | warning.foreground |
| warning | border | #fe9900 | warning |
| info | bg | #22d3ee | info |
| info | fg | #0a0a0a | info.foreground |
| info | border | #22d3ee | info |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
@keyframes toast-in | | |
@keyframes toast-out | | |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Toast (Sonner)
Notification toasts. Fixed container, imperative API.
Auto-dismiss, close button, description, action support.
Variants: default, success, info, warning, error
Positions: top-left, top-center, top-right,
bottom-left, bottom-center, bottom-right
Parts: .toaster, .toast, .toast__content, .toast__title,
.toast__description, .toast__close, .toast__action
------------------------------------------------------------------------- */
/* ---- Container ---- */
.toaster {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
max-width: 420px;
width: 100%;
padding: 16px;
}
.toaster--top-left { top: 0; left: 0; align-items: flex-start; }
.toaster--top-center { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.toaster--top-right { top: 0; right: 0; align-items: flex-end; }
.toaster--bottom-left { bottom: 0; left: 0; align-items: flex-start; }
.toaster--bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.toaster--bottom-right { bottom: 0; right: 0; align-items: flex-end; }
/* ---- Toast ---- */
.toast {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
max-width: 380px;
padding: 16px;
border: 1px solid #e5e5e5;
border-radius: 12px;
background-color: #ffffff;
color: #0a0a0a;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
pointer-events: auto;
animation: toast-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.toast--exiting {
animation: toast-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
/* ---- Variants ---- */
.toast--success {
border-color: #25d366;
background-color: #25d366;
color: #0a0a0a;
}
.toast--error {
border-color: #f84131;
background-color: #f84131;
color: #0a0a0a;
}
.toast--warning {
border-color: #fe9900;
background-color: #fe9900;
color: #471901;
}
.toast--info {
border-color: #22d3ee;
background-color: #22d3ee;
color: #0a0a0a;
}
/* ---- Icon ---- */
.toast__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.25em;
height: 1.25em;
}
.toast__icon svg {
width: 100%;
height: 100%;
}
/* ---- Content ---- */
.toast__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
/* ---- Title ---- */
.toast__title {
font-size: 12.8px;
font-weight: 600;
line-height: 1.45;
}
/* ---- Description ---- */
.toast__description {
font-size: 12.8px;
line-height: 1.45;
opacity: 0.8;
}
/* ---- Close button (IconButton override) ---- */
.toast__close {
flex-shrink: 0;
color: inherit;
opacity: 0.5;
}
.toast__close:hover {
opacity: 1;
}
/* ---- Action button (Button override) ---- */
.toast__action {
align-self: flex-start;
margin-top: 4px;
}
/* ---- Animations ---- */
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes toast-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.toast,
.toast--exiting {
animation-duration: 0ms;
}
.toast__close,
.toast__action {
transition-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode, useState, useEffect, useCallback, useSyncExternalStore } from 'react'; import { Button } from '../atoms/Button'; import { IconButton } from '../atoms/IconButton'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } /* ---- Icons ---- */ const IconCheck = () => ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M20 6L9 17l-5-5" /> </svg> ); const IconX = () => ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> ); const IconAlertTriangle = () => ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> <line x1="12" y1="9" x2="12" y2="13" /> <line x1="12" y1="17" x2="12.01" y2="17" /> </svg> ); const IconInfo = () => ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <circle cx="12" cy="12" r="10" /> <line x1="12" y1="16" x2="12" y2="12" /> <line x1="12" y1="8" x2="12.01" y2="8" /> </svg> ); const variantIcons: Record<string, () => ReactNode> = { success: () => <IconCheck />, error: () => <IconX />, warning: () => <IconAlertTriangle />, info: () => <IconInfo />, }; /* ---- Store ---- */ type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'; type ToastData = { id: string; variant: ToastVariant; title: string; description?: string; action?: { label: string; onClick: () => void }; duration?: number; }; type ToastState = { toasts: ToastData[]; }; let state: ToastState = { toasts: [] }; const listeners = new Set<() => void>(); function emit() { listeners.forEach((l) => l()); } function subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); } function getSnapshot() { return state; } let nextId = 0; function addToast(data: Omit<ToastData, 'id'>) { const id = String(++nextId); state = { toasts: [...state.toasts, { ...data, id }] }; emit(); return id; } function removeToast(id: string) { state = { toasts: state.toasts.filter((t) => t.id !== id) }; emit(); } /* ---- Imperative API ---- */ type ToastOptions = { description?: string; action?: { label: string; onClick: () => void }; duration?: number; }; export function toast(title: string, options?: ToastOptions) { return addToast({ variant: 'default', title, ...options }); } toast.success = (title: string, options?: ToastOptions) => addToast({ variant: 'success', title, ...options }); toast.error = (title: string, options?: ToastOptions) => addToast({ variant: 'error', title, ...options }); toast.warning = (title: string, options?: ToastOptions) => addToast({ variant: 'warning', title, ...options }); toast.info = (title: string, options?: ToastOptions) => addToast({ variant: 'info', title, ...options }); /* ---- Single Toast ---- */ function ToastItem({ data, onRemove }: { data: ToastData; onRemove: (id: string) => void }) { const [exiting, setExiting] = useState(false); const duration = data.duration ?? 4000; const dismiss = useCallback(() => { setExiting(true); setTimeout(() => onRemove(data.id), 200); }, [data.id, onRemove]); useEffect(() => { if (duration <= 0) return; const timer = setTimeout(dismiss, duration); return () => clearTimeout(timer); }, [duration, dismiss]); const icon = variantIcons[data.variant]; return ( <div className={cn( 'toast', data.variant !== 'default' && `toast--${data.variant}`, exiting && 'toast--exiting', )} role="status" aria-live="polite" > {icon && ( <span className="toast__icon">{icon()}</span> )} <div className="toast__content"> <div className="toast__title">{data.title}</div> {data.description && ( <div className="toast__description">{data.description}</div> )} {data.action && ( <Button variant="primary" size="xs" className="toast__action" onClick={() => { data.action!.onClick(); dismiss(); }} > {data.action.label} </Button> )} </div> <IconButton variant="tertiary" size="xs" className="toast__close" icon={<IconX />} aria-label="Close" onClick={dismiss} /> </div> ); } /* ---- Toaster Container ---- */ export type ToasterProps = { position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'; className?: string; }; export function Toaster({ position = 'bottom-right', className }: ToasterProps) { const { toasts } = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const handleRemove = useCallback((id: string) => { removeToast(id); }, []); if (toasts.length === 0) return null; return ( <div className={cn('toaster', `toaster--${position}`, className)}> {toasts.map((t) => ( <ToastItem key={t.id} data={t} onRemove={handleRemove} /> ))} </div> ); }