Docs
Go back
Component by
OV
OblivionTSX
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>
)
}"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-stackDependencies
npm install framer-motion lucide-reactUsage Example
tsx
import ToastStack from "@/components/oblivion/toast-stack"
export default function Demo() {
return (
<div className="p-10 flex justify-center">
<ToastStack />
</div>
)
}import ToastStack from "@/components/oblivion/toast-stack"
export default function Demo() {
return (
<div className="p-10 flex justify-center">
<ToastStack />
</div>
)
}