Oblivion
Oblivion
Docs
Go back
Component by
OV
Oblivion
TSX
toast-stack
"use client"

import { useState, useRef, useId } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { CheckCircle2, AlertCircle, Info, AlertTriangle, X } from "lucide-react"
import { cn } from "@/lib/utils"

export const componentMeta = {
  name: "Toast Stack",
  description: "An animated, stacking toast notification system with four semantic variants.",
  props: {
    position: {
      type: "'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center'",
      default: '"bottom-right"',
      description: "Screen position for the toast stack"
    }
  }
}

/**
 * Oblivion — ToastStack
 *
 * Mechanic: a local notification queue managed with useState.
 * Each toast enters from the side (x: 20px → 0) and exits by
 * collapsing its height (height: auto → 0) while fading out —
 * a two-phase exit that prevents layout jump.
 *
 * All colors reference --ob-* tokens or semantic Tailwind classes.
 * No Sonner, no Radix Toast, no external notification library.
 */

type Variant = "success" | "error" | "info" | "warning"
type Position =
  | "top-right" | "top-left"
  | "bottom-right" | "bottom-left"
  | "top-center" | "bottom-center"

interface Toast {
  id:      string
  message: string
  variant: Variant
  title?:  string
}

const VARIANT_CONFIG: Record<Variant, {
  icon:     React.ElementType
  iconCls:  string
  trackCls: string
  barCls:   string
  label:    string
}> = {
  success: {
    icon:     CheckCircle2,
    iconCls:  "text-emerald-500 dark:text-emerald-400",
    trackCls: "border-emerald-500/20 dark:border-emerald-400/20",
    barCls:   "bg-emerald-500 dark:bg-emerald-400",
    label:    "Success",
  },
  error: {
    icon:     AlertCircle,
    iconCls:  "text-red-500 dark:text-red-400",
    trackCls: "border-red-500/20 dark:border-red-400/20",
    barCls:   "bg-red-500 dark:bg-red-400",
    label:    "Error",
  },
  info: {
    icon:     Info,
    iconCls:  "text-[var(--ob-accent-2)]",
    trackCls: "border-[var(--ob-accent-2)]/20",
    barCls:   "bg-[var(--ob-accent-2)]",
    label:    "Info",
  },
  warning: {
    icon:     AlertTriangle,
    iconCls:  "text-amber-500 dark:text-amber-400",
    trackCls: "border-amber-500/20 dark:border-amber-400/20",
    barCls:   "bg-amber-500 dark:bg-amber-400",
    label:    "Warning",
  },
}

const POSITION_CLS: Record<Position, string> = {
  "top-right":    "top-4 right-4 items-end",
  "top-left":     "top-4 left-4 items-start",
  "bottom-right": "bottom-4 right-4 items-end",
  "bottom-left":  "bottom-4 left-4 items-start",
  "top-center":   "top-4 left-1/2 -translate-x-1/2 items-center",
  "bottom-center":"bottom-4 left-1/2 -translate-x-1/2 items-center",
}

const DURATION = 4000 // ms

function ToastItem({
  toast,
  onDismiss,
  position,
}: {
  toast:     Toast
  onDismiss: (id: string) => void
  position:  Position
}) {
  const cfg      = VARIANT_CONFIG[toast.variant]
  const Icon     = cfg.icon
  const fromRight = position.includes("right")

  return (
    <motion.div
      layout
      initial={{ opacity: 0, x: fromRight ? 20 : -20, scale: 0.96 }}
      animate={{ opacity: 1, x: 0, scale: 1 }}
      exit={{
        opacity: 0,
        x: fromRight ? 20 : -20,
        height: 0,
        marginBottom: 0,
        filter: "blur(4px)",
        transition: { duration: 0.25, ease: "easeIn" }
      }}
      transition={{ type: "spring", stiffness: 400, damping: 30 }}
      className={cn(
        "relative w-[340px] overflow-hidden rounded-2xl",
        "bg-white/90 dark:bg-zinc-900/90 backdrop-blur-xl",
        "border border-black/5 dark:border-white/8",
        "shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)]",
        cfg.trackCls,
        "mb-2"
      )}
    >
      {/* Left accent bar */}
      <div className={cn("absolute left-0 top-0 h-full w-[3px] rounded-l-2xl", cfg.barCls)} />

      <div className="flex items-start gap-3 px-4 py-3.5 pl-5">
        <Icon className={cn("mt-0.5 h-4 w-4 shrink-0", cfg.iconCls)} strokeWidth={2} />

        <div className="flex-1 min-w-0">
          <p className="text-xs font-semibold text-neutral-900 dark:text-white leading-none mb-1">
            {toast.title ?? cfg.label}
          </p>
          <p className="text-xs text-neutral-500 dark:text-zinc-400 leading-relaxed">
            {toast.message}
          </p>
        </div>

        <button
          onClick={() => onDismiss(toast.id)}
          className="mt-0.5 shrink-0 text-neutral-400 hover:text-neutral-700 dark:hover:text-white transition-colors"
        >
          <X size={13} strokeWidth={2.5} />
        </button>
      </div>

      {/* Auto-dismiss progress bar */}
      <motion.div
        className={cn("absolute bottom-0 left-[3px] right-0 h-[2px] origin-left", cfg.barCls, "opacity-25")}
        initial={{ scaleX: 1 }}
        animate={{ scaleX: 0 }}
        transition={{ duration: DURATION / 1000, ease: "linear" }}
      />
    </motion.div>
  )
}

// ─── Demo Component ───────────────────────────────────────────────────────────

const DEMO_TOASTS: Array<{ label: string; variant: Variant; message: string; title?: string }> = [
  { label: "Success", variant: "success", title: "Deployed!", message: "Your component is now live in production." },
  { label: "Error",   variant: "error",   title: "Build failed", message: "TypeScript found 3 errors. Check the console." },
  { label: "Info",    variant: "info",    title: "Update ready", message: "Oblivion v0.2.0 is available. Run npm update." },
  { label: "Warning", variant: "warning", title: "Heads up",     message: "This component uses an experimental API." },
]

export default function ToastStack({
  position = "bottom-right"
}: {
  position?: Position
}) {
  const [toasts, setToasts] = useState<Toast[]>([])
  const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})

  const addToast = (variant: Variant, message: string, title?: string) => {
    const id = `${Date.now()}-${Math.random()}`
    setToasts(prev => [...prev, { id, variant, message, title }])
    timers.current[id] = setTimeout(() => dismiss(id), DURATION + 300)
  }

  const dismiss = (id: string) => {
    clearTimeout(timers.current[id])
    delete timers.current[id]
    setToasts(prev => prev.filter(t => t.id !== id))
  }

  return (
    <div className="relative flex h-full min-h-[500px] w-full items-center justify-center bg-neutral-50 dark:bg-transparent overflow-hidden">

      {/* ── Trigger buttons ── */}
      <div className="flex flex-wrap gap-3 justify-center px-6">
        {DEMO_TOASTS.map(({ label, variant, message, title }) => (
          <button
            key={variant}
            onClick={() => addToast(variant, message, title)}
            className={cn(
              "px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200",
              "border shadow-sm active:scale-95",
              variant === "success" && "bg-emerald-50  border-emerald-200  text-emerald-700 hover:bg-emerald-100  dark:bg-emerald-900/20 dark:border-emerald-800 dark:text-emerald-400",
              variant === "error"   && "bg-red-50      border-red-200      text-red-700     hover:bg-red-100      dark:bg-red-900/20    dark:border-red-800   dark:text-red-400",
              variant === "info"    && "bg-indigo-50   border-indigo-200   text-indigo-700  hover:bg-indigo-100   dark:bg-indigo-900/20 dark:border-indigo-800 dark:text-indigo-400",
              variant === "warning" && "bg-amber-50    border-amber-200    text-amber-700   hover:bg-amber-100    dark:bg-amber-900/20  dark:border-amber-800 dark:text-amber-400",
            )}
          >
            {label}
          </button>
        ))}
      </div>

      {/* ── Toast stack portal ── */}
      <div className={cn("fixed z-[999] flex flex-col", POSITION_CLS[position])}>
        <AnimatePresence mode="popLayout" initial={false}>
          {toasts.map(toast => (
            <ToastItem
              key={toast.id}
              toast={toast}
              onDismiss={dismiss}
              position={position}
            />
          ))}
        </AnimatePresence>
      </div>
    </div>
  )
}
Save to favoritesCopy to Figma
React

Toast Stack

An animated, stacking toast notification system with four semantic variants.

Installation

$ npx @cosmoo/oblivion add toast-stack
Dependenciesnpm install framer-motion lucide-react

Usage Example

tsx
import ToastStack from "@/components/oblivion/toast-stack"

export default function Demo() {
  return (
    <div className="p-10 flex justify-center">
      <ToastStack />
    </div>
  )
}