Toast

PreviousNext

A customizable and responsive toast notification system...

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/toast.tsx
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
import { cva } from "class-variance-authority";
import { motion, AnimatePresence } from "framer-motion";

interface ToastAction {
  label: string;
  onClick: () => void;
}

interface ToastCancel {
  label: string;
  onClick: () => void;
}

interface ToastProps {
  id?: string;
  title?: string;
  description?: string;
  variant?: "default" | "success" | "destructive" | "warning" | "info" | "loading";
  position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
  duration?: number;
  action?: React.ReactNode | ToastAction;
  cancel?: ToastCancel;
  onClose?: () => void;
  stackIndex?: number;
  isVisible?: boolean;
  isStacked?: boolean;
  isHovered?: boolean;
  stackDirection?: "up" | "down";
  isExiting?: boolean;
  totalCount?: number;
}

interface ToastState extends ToastProps {
  id: string;
  timestamp: number;
}

interface ToastOptions {
  id?: string;
  title?: string;
  description?: string;
  variant?: "default" | "success" | "destructive" | "warning" | "info" | "loading";
  position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
  duration?: number;
  action?: React.ReactNode | ToastAction;
  cancel?: ToastCancel;
}

type ToastListener = (toasts: ToastState[]) => void;

class ToastManager {
  private toasts: ToastState[] = [];
  private listeners: Set<ToastListener> = new Set();

  subscribe(listener: ToastListener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  private notify() {
    this.listeners.forEach((listener) => listener([...this.toasts]));
  }

  add(props: ToastProps) {
    const id = props.id || Math.random().toString(36).substr(2, 9);

    const existingIndex = this.toasts.findIndex((toast) => toast.id === id);
    if (existingIndex !== -1) {
      this.toasts[existingIndex] = {
        ...this.toasts[existingIndex],
        ...props,
        id,
      };
      this.notify();
      return id;
    }

    const newToast: ToastState = {
      ...props,
      id,
      timestamp: Date.now(),
    };

    this.toasts = [newToast, ...this.toasts];

    if (this.toasts.length > 10) {
      this.toasts = this.toasts.slice(0, 10);
    }

    this.notify();
    return id;
  }

  update(id: string, props: Partial<ToastProps>) {
    const index = this.toasts.findIndex((toast) => toast.id === id);
    if (index !== -1) {
      this.toasts[index] = { ...this.toasts[index], ...props };
      this.notify();
    }
  }

  remove(id: string) {
    this.toasts = this.toasts.filter((toast) => toast.id !== id);
    this.notify();
  }

  clear() {
    this.toasts = [];
    this.notify();
  }

  getToasts() {
    return [...this.toasts];
  }
}

const toastManager = new ToastManager();

export function toast(message: string, options?: ToastOptions): string;
export function toast(options: ToastOptions & { title: string }): string;
export function toast(
  messageOrOptions: string | (ToastOptions & { title: string }),
  options?: ToastOptions
): string {
  let toastProps: ToastOptions & { title: string };

  if (typeof messageOrOptions === "string") {
    toastProps = {
      title: messageOrOptions,
      ...options,
    };
  } else {
    toastProps = messageOrOptions;
  }

  return toastManager.add(toastProps);
}

toast.success = (message: string, options?: ToastOptions) =>
  toast({ title: message, variant: "success", ...options });

toast.error = (message: string, options?: ToastOptions) =>
  toast({ title: message, variant: "destructive", ...options });

toast.warning = (message: string, options?: ToastOptions) =>
  toast({ title: message, variant: "warning", ...options });

toast.info = (message: string, options?: ToastOptions) =>
  toast({ title: message, variant: "info", ...options });

toast.loading = (message: string, options?: ToastOptions) =>
  toast({ title: message, variant: "loading", duration: Infinity, ...options });

toast.promise = <T,>(
  promise: Promise<T>,
  options: {
    loading: string;
    success: string;
    error: string;
  }
): Promise<T> => {
  const id = toast.loading(options.loading);

  promise
    .then(() => {
      toastManager.update(id, {
        title: options.success,
        variant: "success",
        duration: 5000,
      });
    })
    .catch(() => {
      toastManager.update(id, {
        title: options.error,
        variant: "destructive",
        duration: 5000,
      });
    });

  return promise;
};

toast.dismiss = (id?: string) => {
  if (id) {
    toastManager.remove(id);
  } else {
    toastManager.clear();
  }
};

const toastVariants = cva(
  "toast-base fixed z-[100] pointer-events-auto flex w-[calc(100%-2rem)] max-w-sm h-20 items-center justify-between space-x-4 rounded-lg p-4 pr-8 shadow-lg",
  {
    variants: {
      variant: {
        default: "bg-background text-foreground border border-border",
        success: "bg-green-100 text-green-900 border-green-200 dark:bg-green-950 dark:text-green-50 dark:border-green-800",
        destructive: "bg-red-100 text-red-900 border-red-200 dark:bg-red-950 dark:text-red-50 dark:border-red-800",
        warning: "bg-yellow-100 text-yellow-900 border-yellow-200 dark:bg-yellow-950 dark:text-yellow-50 dark:border-yellow-800",
        info: "bg-blue-100 text-blue-900 border-blue-200 dark:bg-blue-950 dark:text-blue-50 dark:border-blue-800",
        loading: "bg-blue-100 text-blue-900 border-blue-200 dark:bg-blue-950 dark:text-blue-50 dark:border-blue-800",
      },
      position: {
        "top-right": "top-4 right-4",
        "top-left": "top-4 left-4",
        "bottom-right": "bottom-4 right-4",
        "bottom-left": "bottom-4 left-4",
      },
    },
    defaultVariants: {
      variant: "default",
      position: "top-right",
    },
  }
);

const ToastIcons = {
  success: (
    <CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
  ),
  destructive: (
    <AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" />
  ),
  warning: (
    <AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
  ),
  info: (
    <Info className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
  ),
  loading: (
    <div className="w-5 h-5 flex-shrink-0 relative">
      <motion.div
        className="absolute inset-0 bg-blue-600 dark:bg-blue-400 shadow-[0_0_4px_rgba(59,130,246,0.6)] dark:shadow-[0_0_4px_rgba(96,165,250,0.6)]"
        animate={{ rotateX: [0, 180, 0], rotateY: [0, 180, 0] }}
        transition={{ repeat: Infinity, duration: 1.1, ease: "linear" }}
      />
    </div>
  ),
};

const ToastComponent: React.FC<ToastProps> = ({
  id,
  title,
  description,
  variant = "default",
  position = "top-right",
  duration = 5000,
  onClose,
  action,
  cancel,
  stackIndex = 0,
  isVisible = true,
  isStacked = false,
  isHovered = false,
  stackDirection = "down",
  isExiting = false,
  totalCount = 1,
}) => {
  const [translateX, setTranslateX] = useState(0);
  const toastRef = useRef<HTMLDivElement>(null);
  const closeButtonRef = useRef<HTMLButtonElement>(null);
  const startX = useRef(0);
  const isDragging = useRef(false);
  const isTouchAction = useRef(false);
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    const checkMobile = () => {
      setIsMobile(window.innerWidth < 768);
    };

    checkMobile();
    window.addEventListener("resize", checkMobile);
    return () => window.removeEventListener("resize", checkMobile);
  }, []);

  const handleClose = useCallback(
    (e?: React.UIEvent) => {
      if (e) {
        e.stopPropagation();
        e.preventDefault();
      }
      onClose?.();
    },
    [onClose]
  );

  const handleTouchStart = useCallback(
    (e: React.TouchEvent | React.MouseEvent) => {
      if (e.target instanceof Element) {
        if (
          closeButtonRef.current?.contains(e.target) ||
          e.target.closest('button[role="button"]')
        ) {
          isTouchAction.current = true;
          return;
        }
      }

      e.stopPropagation();

      const clientX =
        "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;

      startX.current = clientX;
      isDragging.current = true;
    },
    []
  );

  const handleTouchMove = useCallback(
    (e: React.TouchEvent | React.MouseEvent) => {
      if (isTouchAction.current || !isDragging.current || !toastRef.current)
        return;

      e.stopPropagation();
      e.preventDefault();

      const clientX =
        "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
      const diff = clientX - startX.current;

      if (isMobile) {
        setTranslateX(diff);
      } else {
        if (position.includes("right") && diff > 0) {
          setTranslateX(diff);
        } else if (position.includes("left") && diff < 0) {
          setTranslateX(diff);
        }
      }
    },
    [position, isMobile]
  );

  const handleTouchEnd = useCallback(
    (e: React.TouchEvent | React.MouseEvent) => {
      if (isTouchAction.current) {
        isTouchAction.current = false;
        return;
      }

      if (!isDragging.current || !toastRef.current) return;

      e.stopPropagation();

      const toastWidth = toastRef.current.offsetWidth;
      const swipeThreshold = toastWidth * 0.3;

      if (Math.abs(translateX) >= swipeThreshold) {
        handleClose();
      } else {
        setTranslateX(0);
      }

      isDragging.current = false;
    },
    [translateX, handleClose]
  );

  useEffect(() => {
    let timer: NodeJS.Timeout;
    if (!isHovered && duration !== Infinity && duration > 0 && !isExiting) {
      timer = setTimeout(() => {
        handleClose();
      }, duration);
    }
    return () => {
      if (timer) clearTimeout(timer);
    };
  }, [duration, isHovered, handleClose, isExiting]);

  useEffect(() => {
    const currentRef = toastRef.current;
    if (currentRef) {
      const touchStartOptions = { passive: false };

      currentRef.addEventListener(
        "touchstart",
        handleTouchStart as unknown as EventListener,
        touchStartOptions
      );
      window.addEventListener(
        "touchmove",
        handleTouchMove as unknown as EventListener,
        { passive: false }
      );
      window.addEventListener(
        "touchend",
        handleTouchEnd as unknown as EventListener
      );

      currentRef.addEventListener(
        "mousedown",
        handleTouchStart as unknown as EventListener
      );
      window.addEventListener(
        "mousemove",
        handleTouchMove as unknown as EventListener
      );
      window.addEventListener(
        "mouseup",
        handleTouchEnd as unknown as EventListener
      );
    }

    return () => {
      if (currentRef) {
        currentRef.removeEventListener(
          "touchstart",
          handleTouchStart as unknown as EventListener
        );
        window.removeEventListener(
          "touchmove",
          handleTouchMove as unknown as EventListener
        );
        window.removeEventListener(
          "touchend",
          handleTouchEnd as unknown as EventListener
        );

        currentRef.removeEventListener(
          "mousedown",
          handleTouchStart as unknown as EventListener
        );
        window.removeEventListener(
          "mousemove",
          handleTouchMove as unknown as EventListener
        );
        window.removeEventListener(
          "mouseup",
          handleTouchEnd as unknown as EventListener
        );
      }
    };
  }, [handleTouchStart, handleTouchMove, handleTouchEnd]);

  if (!isVisible) return null;

  const getTransform = () => {
    if (isStacked && stackIndex > 0) {
      const offset = stackIndex * 8;
      const scale = Math.max(0.85, 1 - stackIndex * 0.05);

      if (stackDirection === "up") {
        return `translateX(${translateX}px) translateY(-${offset}px) scale(${scale})`;
      } else {
        return `translateX(${translateX}px) translateY(${offset}px) scale(${scale})`;
      }
    } else if (!isStacked && stackIndex > 0) {
      const expandedOffset = stackIndex * 88;

      if (stackDirection === "up") {
        return `translateX(${translateX}px) translateY(-${expandedOffset}px)`;
      } else {
        return `translateX(${translateX}px) translateY(${expandedOffset}px)`;
      }
    }

    return `translateX(${translateX}px)`;
  };

  const getOpacity = () => {
    if (translateX !== 0) {
      return Math.max(
        0.3,
        1 - Math.abs(translateX) / (toastRef.current?.offsetWidth || 320)
      );
    }

    if (isStacked && stackIndex >= 3) {
      return 0.4;
    }

    return 1;
  };

  const getZIndex = () => {
    return 1100 - stackIndex;
  };

  const renderAction = () => {
    if (!action) return null;

    if (React.isValidElement(action)) {
      const actionElement = action as React.ReactElement<{
        onClick?: (e: React.MouseEvent) => void;
      }>;
      return (
        <div className="ml-2 flex-shrink-0">
          {React.cloneElement(actionElement, {
            onClick: (e: React.MouseEvent) => {
              e.stopPropagation();
              if (actionElement.props.onClick) {
                actionElement.props.onClick(e);
              }
              handleClose();
            },
          })}
        </div>
      );
    }

    if (
      typeof action === "object" &&
      action !== null &&
      "label" in action &&
      "onClick" in action
    ) {
      const actionObj = action as ToastAction;
      return (
        <div className="ml-2 flex-shrink-0">
          <button
            onClick={(e) => {
              e.stopPropagation();
              actionObj.onClick();
              handleClose();
            }}
            className="text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 px-3 py-1 rounded transition-colors"
          >
            {actionObj.label}
          </button>
        </div>
      );
    }

    return null;
  };

  return (
    <motion.div
      ref={toastRef}
      role="alert"
      aria-live="polite"
      className={toastVariants({ variant, position })}
      initial={{
        x: position.includes("right") ? 400 : -400,
        y: position.includes("top") ? -100 : 100,
        opacity: 0,
        scale: 0.9,
      }}
      animate={{
        x: 0,
        y: 0,
        opacity: getOpacity(),
        scale:
          isStacked && stackIndex > 0
            ? Math.max(0.85, 1 - stackIndex * 0.05)
            : 1,
        transform: getTransform(),
      }}
      exit={{
        x: position.includes("right") ? 400 : -400,
        opacity: 0,
        scale: 0.9,
        transition: { duration: 0.2, ease: "easeIn" },
      }}
      transition={{
        type: "spring",
        damping: 30,
        stiffness: 400,
        duration: 0.3,
      }}
      style={{
        zIndex: getZIndex(),
        pointerEvents: "auto",
      }}
    >
      <div className="flex items-center space-x-3 w-full min-w-0">
        {variant !== "default" &&
          ToastIcons[variant as keyof typeof ToastIcons]}
        <div className="flex-1 min-w-0">
          {title && (
            <div className="font-semibold text-sm truncate">{title}</div>
          )}
          {description && (
            <div className="text-xs opacity-70 truncate mt-1">
              {description}
            </div>
          )}
        </div>
      </div>

      {isStacked && stackIndex === 0 && totalCount > 3 && (
        <motion.div
          className="absolute -top-1 -right-1 bg-muted-foreground text-muted rounded-full w-5 h-5 flex items-center justify-center font-medium text-xs z-20"
          initial={{ scale: 0 }}
          animate={{ scale: 1 }}
          transition={{ delay: 0.2 }}
        >
          +{totalCount - 3}
        </motion.div>
      )}

      {renderAction()}

      {cancel && (
        <div className="ml-2 flex-shrink-0">
          <button
            onClick={(e) => {
              e.stopPropagation();
              cancel.onClick();
              handleClose();
            }}
            className="text-xs font-medium bg-muted text-muted-foreground hover:bg-muted/80 px-3 py-1 rounded transition-colors"
          >
            {cancel.label}
          </button>
        </div>
      )}

      <button
        ref={closeButtonRef}
        onClick={handleClose}
        className="absolute top-2 right-2 hover:opacity-75 transition-opacity z-10 p-1 rounded-full hover:bg-black hover:bg-opacity-10"
        aria-label="Close"
      >
        <X className="w-4 h-4" />
      </button>
    </motion.div>
  );
};

interface ToastStackProps {
  toasts: ToastState[];
  position: string;
  onRemoveToast: (id: string) => void;
}

const ToastStack: React.FC<ToastStackProps> = ({
  toasts,
  position,
  onRemoveToast,
}) => {
  const [isHovered, setIsHovered] = useState(false);
  const [isTapped, setIsTapped] = useState(false);
  const [isMobile, setIsMobile] = useState(false);
  const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    const checkMobile = () => {
      setIsMobile(window.innerWidth < 768);
    };

    checkMobile();
    window.addEventListener("resize", checkMobile);

    return () => {
      window.removeEventListener("resize", checkMobile);
    };
  }, []);

  const handleMouseEnter = useCallback(() => {
    if (isMobile) return;

    if (hoverTimeoutRef.current) {
      clearTimeout(hoverTimeoutRef.current);
      hoverTimeoutRef.current = null;
    }

    setIsHovered(true);
  }, [isMobile]);

  const handleMouseLeave = useCallback(
    (e: React.MouseEvent) => {
      if (isMobile) return;

      const rect = e.currentTarget.getBoundingClientRect();
      const { clientX, clientY } = e;

      if (
        clientX >= rect.left &&
        clientX <= rect.right &&
        clientY >= rect.top &&
        clientY <= rect.bottom
      ) {
        return;
      }

      hoverTimeoutRef.current = setTimeout(() => {
        setIsHovered(false);
        hoverTimeoutRef.current = null;
      }, 150);
    },
    [isMobile]
  );

  useEffect(() => {
    return () => {
      if (hoverTimeoutRef.current) {
        clearTimeout(hoverTimeoutRef.current);
      }
    };
  }, []);

  const handleRemoveToast = useCallback(
    (id: string) => {
      const toastToRemove = toasts.find((t: ToastState) => t.id === id);
      if (
        toastToRemove &&
        toasts.filter((t: ToastState) => t.position === toastToRemove.position)
          .length === 1
      ) {
        setIsHovered(false);
        setIsTapped(false);
      }
      onRemoveToast(id);
    },
    [toasts, onRemoveToast]
  );

  const handleStackInteraction = () => {
    if (isMobile) {
      setIsTapped(!isTapped);
    }
  };

  const getVisibleToasts = () => {
    const maxVisible = 3;
    const shouldStack = toasts.length > 1;
    const isExpanded = isMobile ? isTapped : isHovered;

    if (shouldStack && !isExpanded) {
      return toasts.slice(0, maxVisible);
    }

    return toasts.slice(0, maxVisible);
  };

  const visibleToasts = getVisibleToasts();

  const getStackDirection = (pos: string) => {
    return pos.includes("bottom") ? "up" : "down";
  };

  const stackDirection = getStackDirection(position);
  const shouldStack = toasts.length > 1;
  const isExpanded = isMobile ? isTapped : isHovered;

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

  return (
    <div
      className="fixed pointer-events-none z-[100]"
      style={{
        [position.includes("top") ? "top" : "bottom"]: "1rem",
        [position.includes("right") ? "right" : "left"]: "1rem",
      }}
    >
      <div
        className="pointer-events-auto"
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        onClick={handleStackInteraction}
      >
        <AnimatePresence mode="popLayout">
          {visibleToasts.map((toastProps, index) => (
            <ToastComponent
              key={toastProps.id}
              {...toastProps}
              stackIndex={index}
              isVisible={true}
              isStacked={shouldStack && !isExpanded}
              isHovered={isHovered || isTapped}
              stackDirection={stackDirection}
              totalCount={toasts.length}
              onClose={() => handleRemoveToast(toastProps.id)}
            />
          ))}
        </AnimatePresence>
      </div>
    </div>
  );
};

export function ToastContainer() {
  const [toasts, setToasts] = useState(toastManager.getToasts());
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    const checkMobile = () => {
      setIsMobile(window.innerWidth < 768);
    };

    checkMobile();
    window.addEventListener("resize", checkMobile);
    return () => window.removeEventListener("resize", checkMobile);
  }, []);

  useEffect(() => {
    const unsubscribe = toastManager.subscribe(setToasts);
    return () => {
      unsubscribe();
    };
  }, []);

  const handleRemoveToast = useCallback((id: string) => {
    toastManager.remove(id);
  }, []);

  const processedToasts = toasts.map(toast => {
    if (isMobile && toast.variant !== 'info') {
      return { ...toast, position: 'top-right' as const };
    }
    return toast;
  });

  const toastsByPosition = processedToasts.reduce((acc, toast) => {
    const position = toast.position || "top-right";
    if (!acc[position]) {
      acc[position] = [];
    }
    acc[position].push(toast);
    return acc;
  }, {} as Record<string, ToastState[]>);

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

  return (
    <>
      {Object.entries(toastsByPosition).map(([position, positionToasts]) => (
        <ToastStack
          key={position}
          toasts={positionToasts}
          position={position}
          onRemoveToast={handleRemoveToast}
        />
      ))}
    </>
  );
}

export const useToast = () => {
  return { toast };
};

export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
  return (
    <>
      {children}
      <ToastContainer />
    </>
  );
};

export default ToastComponent;

Installation

npx shadcn@latest add @scrollxui/toast

Usage

import { Toast } from "@/components/toast"
<Toast />