GitHub

Toast

Lightweight notification surface for async feedback, confirmations, and background status updates. It supports four semantic variants, animated entry and exit, and portal rendering for reliable stacking.

AccessibleDark Mode4 VariantsAnimatedPortal

Install

$npx react-principles add toast
01

Theme Preview

All four variants are shown in forced light and dark surfaces so the semantic color differences remain visible regardless of the current app theme.

Light

Draft saved

Your local changes were stored automatically.

Dismiss

Published successfully

Your latest release is now visible to your team.

Dismiss

Action required

Reconnect your integration to keep sync running.

Dismiss

Upload failed

The file could not be processed. Try again in a moment.

Dismiss
Dark

Draft saved

Your local changes were stored automatically.

Dismiss

Published successfully

Your latest release is now visible to your team.

Dismiss

Action required

Reconnect your integration to keep sync running.

Dismiss

Upload failed

The file could not be processed. Try again in a moment.

Dismiss

Each trigger opens a dedicated toast variant. Watch the viewport corners to see the portal-mounted notification appear and dismiss.

03

Code Snippet

src/ui/Toast.tsx
import { Toast } from "@/ui/Toast";

<Toast open={open} onOpenChange={setOpen} variant="success">
  <Toast.Title>Saved successfully</Toast.Title>
  <Toast.Description>Your profile changes are now live.</Toast.Description>
  <Toast.Footer>
    <Toast.Close>Dismiss</Toast.Close>
  </Toast.Footer>
</Toast>
04

Copy-Paste (Single File)

Toast.tsx
"use client";

import { createContext, useContext, useEffect, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useAnimatedMount } from "@/hooks/use-animated-mount";
import { cn } from "@/lib/utils";

export type ToastVariant = "default" | "success" | "warning" | "error";
export type ToastPosition = "top-right" | "bottom-right" | "top-left" | "bottom-left";

interface ToastContextValue {
  onClose: () => void;
}

const ToastContext = createContext<ToastContextValue | null>(null);

function useToastContext() {
  const context = useContext(ToastContext);
  if (!context) throw new Error("Toast sub-components must be used inside <Toast>");
  return context;
}

export interface ToastProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  duration?: number;
  variant?: ToastVariant;
  position?: ToastPosition;
  children: ReactNode;
  className?: string;
}

const VARIANT_CLASSES: Record<ToastVariant, string> = {
  default: "border-slate-200 bg-white dark:border-[#1f2937] dark:bg-[#161b22]",
  success: "border-green-300 bg-green-50 dark:border-green-900 dark:bg-green-950/30",
  warning: "border-amber-300 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30",
  error: "border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30",
};

const POSITION_CLASSES: Record<ToastPosition, string> = {
  "top-right": "right-4 top-4",
  "bottom-right": "right-4 bottom-4",
  "top-left": "left-4 top-4",
  "bottom-left": "left-4 bottom-4",
};

export function Toast({
  open,
  onOpenChange,
  duration = 3000,
  variant = "default",
  position = "top-right",
  children,
  className,
}: ToastProps) {
  const { mounted, visible } = useAnimatedMount(open, 180);

  useEffect(() => {
    if (!open || duration <= 0) return;
    const timeout = setTimeout(() => onOpenChange(false), duration);
    return () => clearTimeout(timeout);
  }, [open, duration, onOpenChange]);

  useEffect(() => {
    if (!open) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") onOpenChange(false);
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [open, onOpenChange]);

  if (!mounted) return null;

  return createPortal(
    <ToastContext.Provider value={{ onClose: () => onOpenChange(false) }}>
      <div className={cn("fixed z-[70] w-full max-w-sm", POSITION_CLASSES[position])}>
        <div
          role="status"
          className={cn(
            "rounded-xl border p-4 shadow-xl transition-all",
            VARIANT_CLASSES[variant],
            visible ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0",
            className
          )}
        >
          {children}
        </div>
      </div>
    </ToastContext.Provider>,
    document.body
  );
}
05

Props

PropTypeDefaultDescription
openbooleanControls whether the toast is mounted and visible.
onOpenChange(open: boolean) => voidCalled when auto-dismiss, Escape, or close actions change visibility.
variant"default" | "success" | "warning" | "error""default"Changes the semantic surface colors of the toast.
position"top-right" | "bottom-right" | "top-left" | "bottom-left""top-right"Places the portal-mounted toast in one of four screen corners.
durationnumber3000Auto-dismiss delay in milliseconds. Use 0 or less to disable auto close.
classNamestringAdditional classes applied to the toast surface.
react-principles