ATOM
Components (registry)Surfaces

Toast

A brief message slides in and dismisses itself. Success, error, warning, and info feedback.

Preview

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>
ClasePropósito
toasterroot
toaster--bottom-centermodifier
toaster--bottom-leftmodifier
toaster--bottom-rightmodifier
toaster--top-centermodifier
toaster--top-leftmodifier
toaster--top-rightmodifier

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allbg#ffffffpopover
allfginherit(literal)
allborder1pxstroke.hairline
successbg#25d366success
successfg#0a0a0asuccess.foreground
successborder#25d366success
allerror-bg#f84131destructive
allerror-fg#0a0a0adestructive.foreground
allerror-border#f84131destructive
warningbg#fe9900warning
warningfg#471901warning.foreground
warningborder#fe9900warning
infobg#22d3eeinfo
infofg#0a0a0ainfo.foreground
infoborder#22d3eeinfo

Animaciones

PropiedadDuraciónEasing
@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

components-react
components/atoms/Toast.tsx
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>
  );
}

Componentes relacionados

On this page

Detalles

Publicado14 de mayo de 2026
Categoriasurfaces
Lectura...
Visitas...
Ayuda?Slack

Componente

Toast

Source

components-react / cssDisponible via MCP: atom_uikit_source("toast")
Abrir en Storybook