{"slug":"animated-toast-stack","name":"Animated Toast Stack","description":"Stacked toasts with status morphs, swipe dismissal, actions and layout-aware motion.","category":"motion","source_url":"https://beui.saura3h.xyz/r/animated-toast-stack/raw","detail_url":"https://beui.saura3h.xyz/r/animated-toast-stack","raw_url":"https://beui.saura3h.xyz/r/animated-toast-stack/raw","page_url":"https://beui.saura3h.xyz/components/motion/animated-toast-stack","dependencies":["lucide-react","motion","react","react-dom"],"internal":["@/components/motion/animated-toast-stack","@/lib/utils"],"files":[{"path":"components/motion/animated-toast-stack.tsx","type":"component","content":"\"use client\";\n\nimport {\n  AlertCircle,\n  Bell,\n  Check,\n  Info,\n  LoaderCircle,\n  X,\n  type LucideIcon,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"motion/react\";\nimport {\n  memo,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ToastStatus = \"neutral\" | \"info\" | \"loading\" | \"success\" | \"error\";\nexport type ToastPosition =\n  | \"top-left\"\n  | \"top-center\"\n  | \"top-right\"\n  | \"bottom-left\"\n  | \"bottom-center\"\n  | \"bottom-right\";\n\nexport type AnimatedToastAction = {\n  label: ReactNode;\n  onClick: (toast: AnimatedToast) => void;\n};\n\nexport type AnimatedToast = {\n  id: string;\n  title: ReactNode;\n  description?: ReactNode;\n  status?: ToastStatus;\n  icon?: ReactNode;\n  action?: AnimatedToastAction;\n  duration?: number;\n  dismissible?: boolean;\n  createdAt?: number;\n};\n\nexport type ToastInput = Omit<AnimatedToast, \"id\" | \"createdAt\"> & {\n  id?: string;\n};\n\nexport type ToastClassNames = {\n  root?: string;\n  item?: string;\n  surface?: string;\n  iconWrap?: string;\n  content?: string;\n  title?: string;\n  description?: string;\n  action?: string;\n  close?: string;\n  progress?: string;\n};\n\nexport interface AnimatedToastStackProps {\n  toasts: AnimatedToast[];\n  onDismiss?: (id: string) => void;\n  position?: ToastPosition;\n  placement?: \"static\" | \"fixed\" | \"absolute\";\n  fixed?: boolean;\n  portal?: boolean;\n  portalRoot?: Element | null;\n  maxVisible?: number;\n  className?: string;\n  classNames?: ToastClassNames;\n  icons?: Partial<Record<ToastStatus, ReactNode>>;\n  renderToast?: (toast: AnimatedToast) => ReactNode;\n}\n\nexport interface UseAnimatedToastStackOptions {\n  initialToasts?: ToastInput[];\n  defaultDuration?: number;\n  limit?: number;\n}\n\nconst STACK_SPRING: Transition = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 34,\n  mass: 0.75,\n};\n\nconst CONTENT_TRANSITION = {\n  duration: 0.28,\n  ease: [0.16, 1, 0.3, 1],\n} as const;\n\nconst STATUS_ICON: Record<ToastStatus, LucideIcon> = {\n  neutral: Bell,\n  info: Info,\n  loading: LoaderCircle,\n  success: Check,\n  error: AlertCircle,\n};\n\nconst STATUS_CLASS: Record<ToastStatus, string> = {\n  neutral: \"text-(--color-fg-muted) bg-(--color-fg)/[0.05]\",\n  info: \"text-(--color-accent) bg-(--color-accent)/10\",\n  loading: \"text-(--color-violet) bg-(--color-violet)/10\",\n  success: \"text-(--color-success) bg-(--color-success)/10\",\n  error: \"text-(--color-danger) bg-(--color-danger)/10\",\n};\n\nconst POSITION_CLASS: Record<ToastPosition, string> = {\n  \"top-left\": \"left-4 top-4\",\n  \"top-center\": \"left-1/2 top-4 -translate-x-1/2\",\n  \"top-right\": \"right-4 top-4\",\n  \"bottom-left\": \"bottom-6 left-4\",\n  \"bottom-center\": \"bottom-6 left-1/2 -translate-x-1/2\",\n  \"bottom-right\": \"bottom-6 right-4\",\n};\n\nlet idSeed = 0;\n\nfunction createToast(input: ToastInput, defaultDuration: number): AnimatedToast {\n  return {\n    duration: defaultDuration,\n    dismissible: true,\n    ...input,\n    id: input.id ?? `toast-${Date.now()}-${idSeed++}`,\n    createdAt: Date.now(),\n  };\n}\n\nexport function useAnimatedToastStack({\n  initialToasts = [],\n  defaultDuration = 4200,\n  limit,\n}: UseAnimatedToastStackOptions = {}) {\n  const toastTimers = useRef<Map<string, { timer: number; signature: string }>>(new Map());\n  const [toasts, setToasts] = useState<AnimatedToast[]>(() =>\n    initialToasts.map((toast) => createToast(toast, defaultDuration)),\n  );\n\n  const dismissToast = useCallback((id: string) => {\n    setToasts((current) => current.filter((toast) => toast.id !== id));\n  }, []);\n\n  const clearToasts = useCallback(() => {\n    setToasts([]);\n  }, []);\n\n  const showToast = useCallback(\n    (input: ToastInput) => {\n      const toast = createToast(input, defaultDuration);\n      setToasts((current) => {\n        const next = [...current, toast];\n        return typeof limit === \"number\" ? next.slice(-limit) : next;\n      });\n      return toast.id;\n    },\n    [defaultDuration, limit],\n  );\n\n  const updateToast = useCallback((id: string, patch: Partial<ToastInput>) => {\n    setToasts((current) =>\n      current.map((toast) =>\n        toast.id === id\n          ? {\n              ...toast,\n              ...patch,\n              id,\n              createdAt: patch.duration === undefined ? toast.createdAt : Date.now(),\n            }\n          : toast,\n      ),\n    );\n  }, []);\n\n  useEffect(() => {\n    const activeIds = new Set(toasts.map((toast) => toast.id));\n\n    toastTimers.current.forEach((entry, id) => {\n      if (!activeIds.has(id)) {\n        window.clearTimeout(entry.timer);\n        toastTimers.current.delete(id);\n      }\n    });\n\n    toasts.forEach((toast) => {\n      const duration = toast.duration ?? defaultDuration;\n      const existing = toastTimers.current.get(toast.id);\n\n      if (duration <= 0) {\n        if (existing) {\n          window.clearTimeout(existing.timer);\n          toastTimers.current.delete(toast.id);\n        }\n        return;\n      }\n\n      const createdAt = toast.createdAt ?? Date.now();\n      const signature = `${createdAt}:${duration}`;\n\n      if (existing?.signature === signature) {\n        return;\n      }\n\n      if (existing) {\n        window.clearTimeout(existing.timer);\n      }\n\n      const elapsed = Date.now() - createdAt;\n      const remaining = Math.max(duration - elapsed, 0);\n      const timer = window.setTimeout(() => {\n        toastTimers.current.delete(toast.id);\n        dismissToast(toast.id);\n      }, remaining);\n\n      toastTimers.current.set(toast.id, { timer, signature });\n    });\n  }, [defaultDuration, dismissToast, toasts]);\n\n  useEffect(() => {\n    const timers = toastTimers.current;\n\n    return () => {\n      timers.forEach((entry) => window.clearTimeout(entry.timer));\n      timers.clear();\n    };\n  }, []);\n\n  return useMemo(\n    () => ({\n      toasts,\n      showToast,\n      updateToast,\n      dismissToast,\n      clearToasts,\n      setToasts,\n    }),\n    [clearToasts, dismissToast, showToast, toasts, updateToast],\n  );\n}\n\nexport function AnimatedToastStack({\n  toasts,\n  onDismiss,\n  position = \"bottom-right\",\n  placement,\n  fixed = false,\n  portal,\n  portalRoot,\n  maxVisible = 4,\n  className,\n  classNames,\n  icons,\n  renderToast,\n}: AnimatedToastStackProps) {\n  const [mounted, setMounted] = useState(false);\n  const visibleToasts = toasts.slice(-maxVisible);\n  const isBottom = position.startsWith(\"bottom\");\n  const resolvedPlacement = placement ?? (fixed ? \"fixed\" : \"static\");\n  const shouldPortal = portal ?? resolvedPlacement === \"fixed\";\n\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  const stack = (\n    <ol\n      aria-live=\"polite\"\n      aria-atomic=\"false\"\n      className={cn(\n        \"pointer-events-none flex w-[calc(100vw-2rem)] max-w-sm gap-2\",\n        isBottom ? \"flex-col-reverse\" : \"flex-col\",\n        resolvedPlacement === \"fixed\" && \"fixed z-[90]\",\n        resolvedPlacement === \"absolute\" && \"absolute z-20\",\n        resolvedPlacement !== \"static\" && POSITION_CLASS[position],\n        classNames?.root,\n        className,\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {visibleToasts.map((toast, index) => (\n          <ToastItem\n            key={toast.id}\n            toast={toast}\n            index={index}\n            onDismiss={onDismiss}\n            classNames={classNames}\n            icons={icons}\n            renderToast={renderToast}\n          />\n        ))}\n      </AnimatePresence>\n    </ol>\n  );\n\n  if (shouldPortal && !mounted) {\n    return null;\n  }\n\n  if (shouldPortal) {\n    return createPortal(stack, portalRoot ?? document.body);\n  }\n\n  return stack;\n}\n\nconst ToastItem = memo(function ToastItem({\n  toast,\n  index,\n  onDismiss,\n  classNames,\n  icons,\n  renderToast,\n}: {\n  toast: AnimatedToast;\n  index: number;\n  onDismiss?: (id: string) => void;\n  classNames?: ToastClassNames;\n  icons?: Partial<Record<ToastStatus, ReactNode>>;\n  renderToast?: (toast: AnimatedToast) => ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const status = toast.status ?? \"neutral\";\n  const Icon = STATUS_ICON[status];\n  const iconNode = icons?.[status] ?? toast.icon ?? <Icon className=\"h-3.5 w-3.5\" />;\n  const canDismiss = toast.dismissible !== false && Boolean(onDismiss);\n\n  return (\n    <motion.li\n      layout\n      initial={\n        reduce\n          ? { opacity: 0 }\n          : { opacity: 0, y: 22, scale: 0.96, filter: \"blur(10px)\" }\n      }\n      animate={\n        reduce\n          ? { opacity: 1 }\n          : { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }\n      }\n      exit={\n        reduce\n          ? { opacity: 0 }\n          : {\n              opacity: 0,\n              x: 32,\n              scale: 0.96,\n              filter: \"blur(8px)\",\n              transition: { duration: 0.18, ease: [0.16, 1, 0.3, 1] },\n            }\n      }\n      transition={STACK_SPRING}\n      drag={canDismiss && !reduce ? \"x\" : false}\n      dragConstraints={{ left: 0, right: 0 }}\n      dragElastic={0.18}\n      onDragEnd={(_, info) => {\n        if (!canDismiss || !onDismiss) return;\n        if (Math.abs(info.offset.x) > 72 || Math.abs(info.velocity.x) > 520) {\n          onDismiss(toast.id);\n        }\n      }}\n      className={cn(\"pointer-events-auto relative will-change-transform\", classNames?.item)}\n      style={{ zIndex: 20 - index }}\n    >\n      <div\n        className={cn(\n          \"relative overflow-hidden rounded-2xl border border-(--color-border) bg-(--color-bg-elev)/95 p-3 shadow-[0_18px_50px_-28px_rgb(0_0_0/0.65),0_1px_0_0_rgb(255_255_255/0.05)_inset] backdrop-blur-xl\",\n          classNames?.surface,\n        )}\n      >\n        {renderToast ? (\n          renderToast(toast)\n        ) : (\n          <div className=\"flex items-start gap-3\">\n            <motion.span\n              layout\n              className={cn(\n                \"mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full\",\n                STATUS_CLASS[status],\n                classNames?.iconWrap,\n              )}\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={status}\n                  initial={\n                    reduce\n                      ? { opacity: 0 }\n                      : { opacity: 0, y: 8, scale: 0.8, filter: \"blur(6px)\" }\n                  }\n                  animate={\n                    reduce\n                      ? { opacity: 1 }\n                      : { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }\n                  }\n                  exit={\n                    reduce\n                      ? { opacity: 0 }\n                      : { opacity: 0, y: -8, scale: 0.9, filter: \"blur(6px)\" }\n                  }\n                  transition={CONTENT_TRANSITION}\n                  className=\"inline-flex\"\n                >\n                  {status === \"loading\" ? (\n                    <span className=\"inline-flex animate-spin\">{iconNode}</span>\n                  ) : (\n                    iconNode\n                  )}\n                </motion.span>\n              </AnimatePresence>\n            </motion.span>\n\n            <div className={cn(\"min-w-0 flex-1\", classNames?.content)}>\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.div\n                  key={`${toast.id}-${status}-${String(toast.title)}`}\n                  initial={\n                    reduce\n                      ? { opacity: 0 }\n                      : { opacity: 0, y: 8, filter: \"blur(6px)\" }\n                  }\n                  animate={\n                    reduce\n                      ? { opacity: 1 }\n                      : { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                  }\n                  exit={\n                    reduce\n                      ? { opacity: 0 }\n                      : { opacity: 0, y: -8, filter: \"blur(6px)\" }\n                  }\n                  transition={CONTENT_TRANSITION}\n                >\n                  <p\n                    className={cn(\n                      \"truncate text-sm font-medium leading-5 text-(--color-fg)\",\n                      classNames?.title,\n                    )}\n                  >\n                    {toast.title}\n                  </p>\n                  {toast.description ? (\n                    <p\n                      className={cn(\n                        \"mt-0.5 line-clamp-2 text-xs leading-4 text-(--color-fg-muted)\",\n                        classNames?.description,\n                      )}\n                    >\n                      {toast.description}\n                    </p>\n                  ) : null}\n                </motion.div>\n              </AnimatePresence>\n\n              {toast.action ? (\n                <button\n                  type=\"button\"\n                  onClick={() => toast.action?.onClick(toast)}\n                  className={cn(\n                    \"mt-2 inline-flex h-7 items-center rounded-full bg-(--color-fg)/[0.06] px-3 text-xs font-medium text-(--color-fg) transition-colors hover:bg-(--color-fg)/[0.1]\",\n                    classNames?.action,\n                  )}\n                >\n                  {toast.action.label}\n                </button>\n              ) : null}\n            </div>\n\n            {canDismiss ? (\n              <button\n                type=\"button\"\n                onClick={() => onDismiss?.(toast.id)}\n                aria-label=\"Dismiss toast\"\n                className={cn(\n                  \"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-(--color-fg-muted) transition-colors hover:bg-(--color-fg)/[0.06] hover:text-(--color-fg)\",\n                  classNames?.close,\n                )}\n              >\n                <X className=\"h-3.5 w-3.5\" />\n              </button>\n            ) : null}\n          </div>\n        )}\n\n      </div>\n    </motion.li>\n  );\n});\n"},{"path":"components/previews/motion/animated-toast-stack.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { Check, LoaderCircle, Sparkles, X } from \"lucide-react\";\nimport { useState } from \"react\";\nimport {\n  AnimatedToastStack,\n  useAnimatedToastStack,\n  type ToastInput,\n  type ToastPosition,\n} from \"@/components/motion/animated-toast-stack\";\nimport { cn } from \"@/lib/utils\";\n\nconst POSITIONS: ToastPosition[] = [\n  \"top-left\",\n  \"top-center\",\n  \"top-right\",\n  \"bottom-left\",\n  \"bottom-center\",\n  \"bottom-right\",\n];\n\nconst EXAMPLES: Array<ToastInput & { label: string }> = [\n  {\n    label: \"Promise\",\n    status: \"loading\",\n    title: \"Publishing component\",\n    description: \"Bundling source, preview, and registry metadata.\",\n    duration: 0,\n  },\n  {\n    label: \"Success\",\n    status: \"success\",\n    title: \"Component published\",\n    description: \"Registry endpoint and raw source are available.\",\n  },\n  {\n    label: \"Error\",\n    status: \"error\",\n    title: \"Snapshot failed\",\n    description: \"Retry after the browser target settles.\",\n  },\n];\n\nexport function AnimatedToastStackPreview() {\n  const [position, setPosition] = useState<ToastPosition>(\"bottom-right\");\n  const {\n    toasts,\n    showToast,\n    updateToast,\n    dismissToast,\n    clearToasts,\n  } = useAnimatedToastStack({\n    defaultDuration: 3600,\n    limit: 5,\n  });\n\n  const openToast = (input: ToastInput) => {\n    const id = showToast(input);\n    if (input.status === \"loading\") {\n      window.setTimeout(() => {\n        updateToast(id, {\n          status: \"success\",\n          title: \"Publish complete\",\n          description: \"Toast updated in-place from loading to success.\",\n          duration: 3200,\n        });\n      }, 1800);\n    }\n  };\n\n  const moveStack = (nextPosition: ToastPosition) => {\n    setPosition(nextPosition);\n    showToast({\n      status: \"info\",\n      title: \"Position changed\",\n      description: `New toasts open from ${nextPosition}.`,\n    });\n  };\n\n  return (\n    <div className=\"flex min-h-72 w-full flex-col items-center justify-center gap-6\">\n      <AnimatedToastStack\n        toasts={toasts}\n        onDismiss={dismissToast}\n        position={position}\n        placement=\"fixed\"\n        maxVisible={4}\n        classNames={{\n          surface: \"bg-(--color-bg-elev)/95\",\n        }}\n        icons={{\n          neutral: <Sparkles className=\"h-3.5 w-3.5\" />,\n        }}\n      />\n\n      <div className=\"flex flex-col items-center gap-2 text-center\">\n        <p className=\"text-sm font-medium text-(--color-fg)\">Open a real toast</p>\n        <p className=\"max-w-sm text-xs leading-5 text-(--color-fg-muted)\">\n          Toasts render fixed on the screen. Change position to open a toast from that edge.\n        </p>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-center gap-2\">\n        {EXAMPLES.map((example) => {\n          return (\n            <button\n              key={example.label}\n              type=\"button\"\n              onClick={() => openToast(example)}\n              className=\"inline-flex h-9 items-center gap-2 rounded-full border border-(--color-border) bg-(--color-bg-elev) px-4 text-xs font-medium text-(--color-fg) transition-colors press hover:border-(--color-border-strong)\"\n            >\n              {example.status === \"loading\" ? (\n                <LoaderCircle className=\"h-3.5 w-3.5\" />\n              ) : example.status === \"success\" ? (\n                <Check className=\"h-3.5 w-3.5\" />\n              ) : (\n                <X className=\"h-3.5 w-3.5\" />\n              )}\n              {example.label}\n            </button>\n          );\n        })}\n        <button\n          type=\"button\"\n          onClick={clearToasts}\n          className=\"inline-flex h-9 items-center rounded-full px-4 text-xs font-medium text-(--color-fg-muted) press hover:bg-(--color-fg)/[0.06] hover:text-(--color-fg)\"\n        >\n          Clear\n        </button>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-center gap-1.5\">\n        {POSITIONS.map((positionOption) => (\n          <button\n            key={positionOption}\n            type=\"button\"\n            onClick={() => moveStack(positionOption)}\n            className={cn(\n              \"rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors press\",\n              position === positionOption\n                ? \"bg-(--color-fg) text-(--color-bg)\"\n                : \"bg-(--color-fg)/[0.04] text-(--color-fg-muted) hover:bg-(--color-fg)/[0.08] hover:text-(--color-fg)\",\n            )}\n          >\n            {positionOption}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}