Wensity Labs

Toast

Component

About

Toast notifications with success, error, warning and info variants, action buttons, stacking and configurable placement. Built on Base UI for accessible, swipe to dismiss feedback.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Toast to your project and fits it to your theme.

Code

How Toast is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { IconArrowRight } from "@tabler/icons-react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Toast, ToastProvider, ToastViewport, type ToastPlacement, type ToastVariant } from "@/components/ui/toast";

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

const Button = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement> & {
    variant?: "primary" | "secondary" | "ghost";
    leftIcon?: React.ReactNode;
  }
>(function Button({ variant = "primary", leftIcon, className, children, ...props }, ref) {
  return (
    <button
      ref={ref}
      type="button"
      className={cn(
        "inline-flex h-9 items-center justify-center gap-2 rounded-[10px] px-4 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] [&_svg]:size-4",
        variant === "primary" && "bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
        variant === "secondary" && "border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] hover:bg-[var(--muted)]",
        variant === "ghost" && "text-[var(--foreground)] hover:bg-[var(--muted)]",
        className,
      )}
      {...props}
    >
      {leftIcon}
      {children}
    </button>
  );
});

const primitivePreviewCardSurfaceFrameClass = cn(
  "relative isolate overflow-hidden rounded-2xl",
  "border",
);

const primitivePreviewCardSurfaceLightClass = cn(
  "border-black/[0.08] bg-[#f4f4f5]",
  "[box-shadow:0_1px_2px_rgba(0,0,0,.06),0_8px_24px_-12px_rgba(0,0,0,.08)]",
);

const primitivePreviewCardSurfaceClass = cn(
  primitivePreviewCardSurfaceFrameClass,
  primitivePreviewCardSurfaceLightClass,
  // Full static `dark:bg-[#0a0a0b]` kept for Tailwind content scan - same as
  // PRIMITIVE_PREVIEW_CANVAS_DARK_BG / PRIMITIVE_PREVIEW_CANVAS_DARK.
  "dark:border-white/[0.06] dark:bg-[#0a0a0b] dark:[box-shadow:0_1px_2px_rgba(0,0,0,.4),0_8px_24px_-12px_rgba(0,0,0,.6)]",
);

const cardSurface = primitivePreviewCardSurfaceClass;

function PreviewPanel({
  title,
  subtitle,
  children,
  className,
}: {
  title: string;
  subtitle?: string;
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <div className={cn(cardSurface, "w-full max-w-lg p-5", className)}>
      <div className="mb-4 border-b border-[var(--border)] pb-3">
        <p className="text-sm font-semibold tracking-[-0.015em] text-[var(--foreground)]">
          {title}
        </p>
        {subtitle ? (
          <p className="mt-1 text-[12px] text-[var(--muted-foreground)]">{subtitle}</p>
        ) : null}
      </div>
      {children}
    </div>
  );
}

type ToastDemoCopy = {
  variant: ToastVariant;
  title: string;
  description: string;
  actionLabel?: string;
};

type ActiveToast = ToastDemoCopy & {
  id: number;
  placement: ToastPlacement;
};

const previewPlacements: ToastPlacement[] = [
  "top-left",
  "top-right",
  "bottom-left",
  "bottom-right",
  "top-center",
];

let toastId = 0;
let activeToasts: ActiveToast[] = [];
const toastListeners = new Set<() => void>();
const toastTimers = new Map<number, number>();

let hostId = 0;
let primaryHostId: number | null = null;
const mountedHostIds: number[] = [];
const hostListeners = new Set<() => void>();

function emitToastChange() {
  toastListeners.forEach((listener) => listener());
}

function getToastSnapshot() {
  return activeToasts;
}

function subscribeToastStore(listener: () => void) {
  toastListeners.add(listener);
  return () => {
    toastListeners.delete(listener);
  };
}

function dismissPreviewToast(id: number) {
  const timer = toastTimers.get(id);

  if (timer) {
    window.clearTimeout(timer);
    toastTimers.delete(id);
  }

  activeToasts = activeToasts.filter((toast) => toast.id !== id);
  emitToastChange();
}

function pushPreviewToast(
  toast: ToastDemoCopy,
  placement: ToastPlacement = "top-right",
  persist = false,
) {
  const id = ++toastId;
  const nextToast = { ...toast, id, placement };
  const placementCounts = new Map<ToastPlacement, number>();
  const nextToasts = [nextToast, ...activeToasts].filter((item) => {
    const count = placementCounts.get(item.placement) ?? 0;

    if (count >= 4) return false;

    placementCounts.set(item.placement, count + 1);
    return true;
  });
  const nextToastIds = new Set(nextToasts.map((item) => item.id));

  activeToasts.forEach((item) => {
    if (!nextToastIds.has(item.id)) {
      const timer = toastTimers.get(item.id);

      if (timer) {
        window.clearTimeout(timer);
        toastTimers.delete(item.id);
      }
    }
  });

  activeToasts = nextToasts;
  emitToastChange();

  if (persist) return;

  const timer = window.setTimeout(() => {
    dismissPreviewToast(id);
  }, 4600);

  toastTimers.set(id, timer);
}

function emitHostChange() {
  hostListeners.forEach((listener) => listener());
}

function syncPrimaryHost() {
  const nextPrimaryHostId =
    primaryHostId && mountedHostIds.includes(primaryHostId)
      ? primaryHostId
      : (mountedHostIds[0] ?? null);

  if (primaryHostId !== nextPrimaryHostId) {
    primaryHostId = nextPrimaryHostId;
    emitHostChange();
  }
}

function usePrimaryToastHost() {
  const [id] = React.useState(() => ++hostId);
  const [isPrimary, setIsPrimary] = React.useState(false);

  React.useEffect(() => {
    const update = () => setIsPrimary(primaryHostId === id);

    mountedHostIds.push(id);
    hostListeners.add(update);
    syncPrimaryHost();
    update();

    return () => {
      const index = mountedHostIds.indexOf(id);

      if (index !== -1) {
        mountedHostIds.splice(index, 1);
      }

      hostListeners.delete(update);
      syncPrimaryHost();
    };
  }, [id]);

  return isPrimary;
}

function ToastDemoStackHost() {
  const isPrimary = usePrimaryToastHost();
  const shouldReduceMotion = useReducedMotion();
  const [portalRoot, setPortalRoot] = React.useState<HTMLElement | null>(null);
  const toasts = React.useSyncExternalStore(
    subscribeToastStore,
    getToastSnapshot,
    getToastSnapshot,
  );

  React.useEffect(() => {
    setPortalRoot(document.body);
  }, []);

  if (!isPrimary || !portalRoot) return null;

  return createPortal(
    <ToastProvider>
      {previewPlacements.map((placement) => (
        <ToastPlacementStack
          key={placement}
          placement={placement}
          shouldReduceMotion={shouldReduceMotion}
          toasts={toasts.filter((toast) => toast.placement === placement)}
        />
      ))}
    </ToastProvider>,
    portalRoot,
  );
}

function ToastPlacementStack({
  placement,
  shouldReduceMotion,
  toasts,
}: {
  placement: ToastPlacement;
  shouldReduceMotion: boolean | null;
  toasts: ActiveToast[];
}) {
  const [expanded, setExpanded] = React.useState(false);
  const isBottom = placement.startsWith("bottom");
  const isLeft = placement.endsWith("left");
  const origin = `${isBottom ? "bottom" : "top"} ${isLeft ? "left" : "right"}`;

  if (toasts.length === 0) return null;

  const collapsedHeight = 72 + Math.max(0, toasts.length - 1) * 12;
  const expandedHeight = 72 + Math.max(0, toasts.length - 1) * 82;
  const stackHeight = expanded || shouldReduceMotion ? expandedHeight : collapsedHeight;

  return (
    <ToastViewport
      placement={placement}
      className={cn("z-[9999] block h-[360px]", isBottom ? "bottom-6" : "top-24")}
    >
      <motion.div
        animate={{ height: stackHeight }}
        transition={{
          type: "spring",
          stiffness: shouldReduceMotion ? 1000 : 520,
          damping: shouldReduceMotion ? 100 : 38,
        }}
        className={cn("pointer-events-auto absolute inset-x-0 w-full", isBottom ? "bottom-0" : "top-0")}
        onHoverStart={() => setExpanded(true)}
        onHoverEnd={() => setExpanded(false)}
        onFocusCapture={() => setExpanded(true)}
        onBlurCapture={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
            setExpanded(false);
          }
        }}
      >
        <AnimatePresence initial={false}>
          {toasts.map((toast, index) => {
            const direction = isBottom ? -1 : 1;
            const collapsedY = direction * index * 12;
            const expandedY = direction * index * 82;
            const collapsedScale = Math.max(0.9, 1 - index * 0.035);
            const isExpanded = expanded || shouldReduceMotion;

            return (
              <motion.div
                key={toast.id}
                initial={{
                  opacity: 0,
                  y: shouldReduceMotion ? 0 : -12,
                  scale: 0.98,
                }}
                animate={{
                  opacity: isExpanded ? 1 : Math.max(0.72, 1 - index * 0.1),
                  y: isExpanded ? expandedY : collapsedY,
                  scale: isExpanded ? 1 : collapsedScale,
                }}
                exit={{
                  opacity: 0,
                  y: shouldReduceMotion ? 0 : -8,
                  scale: 0.98,
                }}
                transition={{
                  opacity: { duration: shouldReduceMotion ? 0 : 0.16 },
                  y: {
                    type: "spring",
                    stiffness: shouldReduceMotion ? 1000 : 520,
                    damping: shouldReduceMotion ? 100 : 38,
                  },
                  scale: { duration: shouldReduceMotion ? 0 : 0.18, ease: [0.22, 1, 0.36, 1] },
                }}
                style={{
                  zIndex: toasts.length - index,
                  transformOrigin: origin,
                }}
                className={cn("absolute inset-x-0 w-full", isBottom ? "bottom-0" : "top-0")}
              >
                <Toast
                  variant={toast.variant}
                  title={toast.title}
                  description={toast.description}
                  action={
                    toast.actionLabel ? (
                      <Button
                        type="button"
                        size="sm"
                        variant="secondary"
                        rightIcon={<IconArrowRight />}
                        rightIconMotion="arrow"
                      >
                        {toast.actionLabel}
                      </Button>
                    ) : null
                  }
                />
              </motion.div>
            );
          })}
        </AnimatePresence>
      </motion.div>
    </ToastViewport>
  );
}

function ToastTrigger({
  buttonLabel,
  toast,
  placement = "top-right",
}: {
  buttonLabel: string;
  toast: ToastDemoCopy;
  placement?: ToastPlacement;
}) {
  // Show one toast on mount so the preview is never empty.
  React.useEffect(() => {
    const timer = window.setTimeout(() => pushPreviewToast(toast, placement, true), 400);
    return () => window.clearTimeout(timer);
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  return (
    <>
      <ToastDemoStackHost />
      <Button
        type="button"
        variant="secondary"
        rightIcon={<IconArrowRight />}
        rightIconMotion="arrow"
        onClick={() => pushPreviewToast(toast, placement)}
      >
        {buttonLabel}
      </Button>
    </>
  );
}

export function ToastPreview() {
  return (
    <PreviewPanel title="Notifications" subtitle="Transient status cards with smooth stacked delivery.">
      <div className="flex flex-wrap items-center gap-2.5">
        <ToastTrigger
          buttonLabel="Show toast"
          toast={{
            variant: "success",
            title: "Template archive ready",
            description: "The buyer ZIP was generated and uploaded.",
          }}
        />
      </div>
    </PreviewPanel>
  );
}

export default function Demo() {
  return <ToastPreview />;
}

Dependencies

More by Wensity Labs

View all
  • Sidebar

    Sidebar

    234
  • Calendar

    Calendar

    173
  • Button

    Button

    76
  • Color Picker

    Color Picker

    64
  • Image Comparison Slider

    Image Comparison Slider

    55
  • AI Prompt Input

    AI Prompt Input

    50
  • Navigation Menu

    Navigation Menu

    36
  • Text Morphing

    Text Morphing

    36