Toast

Heavily inspired by sonner and I wanted to recreate this component to understand why it is structured the way it is and what goes into the interaction design here since Emil is an amazing interaction designer.

Code

// Toast.tsx
import { CheckCircle, Info, TriangleAlert, CircleAlert } from 'lucide-react'
import type { toastType } from './types'
import React, { useEffect } from 'react'
import { cn } from 'src/lib/utils'

interface ToastProps {
    toastType: toastType
    description: string
    id: string
    header?: string
    onDismiss: (id:string) => void
    isHovered: boolean
    index: number
    isTop: boolean
    total: number
}

interface ToastVariants {
    styles: string,
    icon?: React.ReactNode
    headerRequired?: boolean
}

const toastMap : Record<ToastProps['toastType'], ToastVariants> = {
    default: {
        styles: "border-gray-200 text-gray-500",
    },
    description: {
        styles: "border-gray-200 text-gray-500",
        headerRequired: true
    } ,
    success: {
        styles: "bg-green-50 border-green-200 text-green-800",
        icon: <CheckCircle className="h-5 w-5" />,
        headerRequired: false
    } ,
    info: {
        styles: "border-gray-200 text-gray-500",
        icon: <Info className = "h-5 w-5 text-gray-700"/>

    } ,
    warning: {
        styles: "border-gray-200 text-yellow-700",
        icon: <TriangleAlert className = "h-5 w-5"/>
    } ,
    error: {
        styles: "border-gray-200 text-red-700",
        icon: <CircleAlert className = "h-5 w-5"/>
    } ,
    custom: {
        styles: "border-gray-200 text-gray-500",
    } ,

}


const Toast = ({toastType, description, id, header, onDismiss, isHovered, index, isTop, total} : ToastProps) => {

  const variants = toastMap[toastType];
  const toastHeader = variants.headerRequired === true;
  const toastIcon = variants.icon;

  useEffect(() => {
        if(isHovered){
            return
        }

       const timer =  setTimeout((
            () => onDismiss(id)
        ), 4000)

        return(
           () => clearTimeout(timer)
        )
  },[isHovered, id, onDismiss])

  return (
    <div 
      style={{ 
        zIndex: 100 + index, 
        transformOrigin: isTop ? "top center" : "bottom center"
      }}
      id={id}
      className= {cn('p-4 border rounded-lg bg-white', variants.styles)}
    >
      {toastHeader && <h3>{header}</h3>}
      <div className="flex flex-row gap-2 items-start">
        {toastIcon && toastIcon}
        <p>{description}</p>
      </div>
    </div>
  )
}

export default Toast

// ToastContext.tsx
import React, { createContext, useContext, useMemo, useState } from 'react'
import type { toastType } from './types'

interface Toast {
    id:string
    description: string
    toastType: toastType
}

interface ToastContextValue {
    toasts: Toast[]
    addToast: (type: toastType, description:string) => void
    dismissToast: (id:string) => void
}

interface ToastProviderProps {
    children: React.ReactNode
}

export const ToastProvider = ({
    children
} : ToastProviderProps) => {

    const [toasts, setToasts] = useState<Toast[]>([]);

    const addToast = (toastType: toastType, description: string) => {
        const id = crypto.randomUUID();

        setToasts(prev => {
                const newToast = { id, toastType, description };
                
                if (prev.length >= 3) {
                  return [...prev.slice(1), newToast];
                }
                return [...prev, newToast];
        });
    }

    const dismissToast = (toastId: string) => {
        setToasts(prev => prev.filter((toast) => (
            toast.id !== toastId
        )))
    }

    return (
        <toastContext.Provider value = {{
            addToast,
            dismissToast,
            toasts
        }}>
            {children}
        </toastContext.Provider>
    )
}

const toastContext = createContext<ToastContextValue | undefined>(undefined);

export const useToast = () => {
    const context = useContext(toastContext);
    if (!context) {
        throw new Error('useToast must be used within a ToastProvider');
    }
    return context;
};

// Toaster.tsx
import React, {useState} from 'react'
import { useToast } from './ToastContext'
import Toast from './Toast'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from 'src/lib/utils'

interface ToasterProps {
    position: "top-left" | "top-right" | "top-center" | "bottom-left" | "bottom-right" | "bottom-center"
    expandedOnHover?: boolean
}

const positionMap: Record<NonNullable<ToasterProps['position']>, string> = {
  "top-left": "top-20 left-0 p-4",
  "top-right": "top-20 right-0 p-4",
  "top-center": "top-20 left-1/2 -translate-x-1/2 p-4",
  "bottom-left": "bottom-0 left-0 p-4",
  "bottom-right": "bottom-0 right-0 p-4",
  "bottom-center": "bottom-0 left-1/2 -translate-x-1/2 p-4"
}

const Toaster = ({
  position = "bottom-right", 
  expandedOnHover = true
}: ToasterProps) => {

  const {toasts, dismissToast} = useToast();

  const [isHovered, setIsHovered] = useState<boolean>(false);

  const isTop = position.startsWith('top');

return (
  <div 
    className={cn('fixed z-50 w-full max-w-sm pointer-events-none', positionMap[position])}
    onMouseEnter={() => { if (expandedOnHover) setIsHovered(true) }}
    onMouseLeave={() => { if (expandedOnHover) setIsHovered(false) }}
  >
    <div className="relative w-full flex flex-col justify-end items-end">
      <AnimatePresence mode="popLayout">
        {toasts.map((toast, index) => {
          const distanceFromNewest = (toasts.length - 1) - index;

          return(
                  <motion.div
                  key={toast.id}
                  layout
                  initial={{ opacity: 0, y: isTop ? -20 : 20, scale: 0.9 }}
                  animate={{
                    scale: isHovered ? 1 : 1 - distanceFromNewest * 0.05,
                    y: isHovered 
                      ? distanceFromNewest * (isTop ? 64 : -64) 
                      : distanceFromNewest * (isTop ? 10 : -10),
                    opacity: 1
                  }}
                  exit={{ 
                    opacity: 0, 
                    scale: 0.85,
                    y: isTop ? -40 : 40 
                  }}
                  transition={{ type: "spring", stiffness: 300, damping: 30 }}
                  className="w-full absolute left-0 right-0 bottom-0 pointer-events-auto p-2"
                >
                  <Toast
                    id={toast.id}
                    toastType={toast.toastType}
                    description={toast.description}
                    onDismiss={dismissToast}
                    index={index}
                    isHovered={isHovered}
                    isTop={isTop}
                    total={toasts.length}
                  />
                </motion.div>
          )
        })}
      </AnimatePresence>
    </div>
  </div>
)
}

export default Toaster