Glass ToastFrosted glassmorphism toast notification stack with 4 variants, auto-dismiss progress bar, and spring-animated stacking.
.png)
'use client'
// npm install @phosphor-icons/react framer-motion
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
import {
CheckCircle,
XCircle,
Warning,
Info,
X,
} from '@phosphor-icons/react'
// ─── Constants ────────────────────────────────────────────────────────────────
const GLASS_BLUR = {
backdropFilter: 'blur(24px) saturate(1.8)',
WebkitBackdropFilter: 'blur(24px) saturate(1.8)',
} as const
const GLASS_PANEL = {
background: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow:
'0 8px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.08)',
} as const
const BACKGROUND_IMAGE =
'https://ik.imagekit.io/aitoolkit/bg%20images/Ethereal%20pink%20Flower%20%20(1).png'
const TOAST_DURATION = 4000
const MAX_TOASTS = 3
// ─── Types ────────────────────────────────────────────────────────────────────
type ToastVariant = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: string
variant: ToastVariant
title: string
description?: string
}
interface VariantConfig {
color: string
gradient: string
icon: typeof CheckCircle
label: string
}
const VARIANTS: Record<ToastVariant, VariantConfig> = {
success: { color: '#06D6A0', gradient: '#06D6A0, #00BFA5', icon: CheckCircle, label: 'Success' },
error: { color: '#FF5C8A', gradient: '#FF5C8A, #FF1744', icon: XCircle, label: 'Error' },
warning: { color: '#FFBE0B', gradient: '#FFBE0B, #FF9800', icon: Warning, label: 'Warning' },
info: { color: '#3A86FF', gradient: '#3A86FF, #2962FF', icon: Info, label: 'Info' },
}
const DEMO_TOASTS: Record<ToastVariant, { title: string; description: string }> = {
success: { title: 'Changes saved', description: 'Your settings have been updated successfully' },
error: { title: 'Upload failed', description: 'The file exceeds the maximum size limit' },
warning: { title: 'Low storage', description: 'You have less than 100MB remaining' },
info: { title: 'New update', description: 'Version 2.4 is now available' },
}
// ─── Spring configs ───────────────────────────────────────────────────────────
const ENTER_SPRING = { type: 'spring' as const, stiffness: 300, damping: 26 }
const BUTTON_SPRING = { type: 'spring' as const, stiffness: 300, damping: 20 }
// ─── Progress bar — RAF-based with pause/resume ──────────────────────────────
function useToastProgress(
id: string,
isPaused: boolean,
onComplete: (id: string) => void,
) {
const progressRef = useRef<HTMLDivElement>(null)
const elapsedRef = useRef(0)
const lastTimeRef = useRef(0)
const rafRef = useRef(0)
useEffect(() => {
let alive = true
lastTimeRef.current = performance.now()
function tick(now: number) {
if (!alive) return
if (!isPaused) {
const delta = now - lastTimeRef.current
elapsedRef.current += delta
}
lastTimeRef.current = now
const fraction = Math.max(0, 1 - elapsedRef.current / TOAST_DURATION)
if (progressRef.current) {
progressRef.current.style.transform = `scaleX(${fraction})`
}
if (fraction <= 0) {
onComplete(id)
return
}
rafRef.current = requestAnimationFrame(tick)
}
rafRef.current = requestAnimationFrame(tick)
return () => {
alive = false
cancelAnimationFrame(rafRef.current)
}
}, [id, isPaused, onComplete])
return progressRef
}
// ─── Individual toast card ───────────────────────────────────────────────────
function ToastCard({
toast,
onDismiss,
prefersReduced,
}: {
toast: Toast
onDismiss: (id: string) => void
prefersReduced: boolean | null
}) {
const [hovered, setHovered] = useState(false)
const variant = VARIANTS[toast.variant]
const Icon = variant.icon
const handleComplete = useCallback(
(id: string) => onDismiss(id),
[onDismiss],
)
const progressRef = useToastProgress(toast.id, hovered, handleComplete)
const enterAnim = prefersReduced
? { opacity: 0 }
: { opacity: 0, x: 80, scale: 0.95 }
const showAnim = prefersReduced
? { opacity: 1 }
: { opacity: 1, x: 0, scale: 1 }
const exitAnim = prefersReduced
? { opacity: 0 }
: { opacity: 0, x: 80, scale: 0.95 }
return (
<motion.div
layout
initial={enterAnim}
animate={{
...showAnim,
scale: hovered && !prefersReduced ? 1.01 : 1,
}}
exit={exitAnim}
transition={ENTER_SPRING}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className="relative w-full overflow-hidden rounded-2xl"
style={{ ...GLASS_PANEL }}
>
{/* Blur layer — non-animating */}
<div
className="pointer-events-none absolute inset-0 z-0 rounded-2xl"
style={GLASS_BLUR}
/>
{/* Content row */}
<div className="relative z-10 flex items-center gap-3 py-3.5 pl-4 pr-10">
{/* Icon tile — notification-style tinted badge */}
<div
className="flex shrink-0 items-center justify-center rounded-xl"
style={{
width: 36,
height: 36,
background: `${variant.color}18`,
border: `1px solid ${variant.color}22`,
}}
>
<Icon size={18} weight="regular" style={{ color: variant.color }} />
</div>
{/* Text area */}
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-white/90 font-sans">
{toast.title}
</span>
{toast.description && (
<span className="mt-0.5 truncate text-xs text-white/50 font-sans">
{toast.description}
</span>
)}
</div>
</div>
{/* Close button — matches glass-search-bar X button */}
{/* Outer div provides 44px touch target; inner styled circle is 20×20 visual */}
<div
className="absolute right-0 top-1/2 z-20 flex -translate-y-1/2 cursor-pointer items-center justify-center"
style={{ width: 44, height: 44 }}
onClick={() => onDismiss(toast.id)}
>
<motion.div
whileHover={{ backgroundColor: 'rgba(255, 255, 255, 0.14)' }}
whileTap={{ scale: 0.88 }}
className="flex items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
background: 'rgba(255, 255, 255, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.12)',
}}
role="button"
aria-label="Dismiss toast"
>
<X size={10} weight="regular" className="text-white/60" />
</motion.div>
</div>
{/* Progress bar */}
<div className="absolute bottom-0 left-0 right-0 h-[2px]">
<div
ref={progressRef}
className="h-full w-full origin-left"
style={{ background: `${variant.color}99` }}
/>
</div>
</motion.div>
)
}
// ─── Trigger button ──────────────────────────────────────────────────────────
function TriggerButton({
variant,
onTrigger,
}: {
variant: ToastVariant
onTrigger: () => void
}) {
const config = VARIANTS[variant]
const Icon = config.icon
return (
<motion.button
onClick={onTrigger}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.90 }}
transition={BUTTON_SPRING}
className="relative isolate flex cursor-pointer items-center gap-2.5 overflow-hidden rounded-2xl px-3 py-2 font-sans"
style={{
...GLASS_PANEL,
outline: 'none',
minHeight: 44,
}}
>
{/* Blur layer — non-animating */}
<div
className="pointer-events-none absolute inset-0 z-[-1] rounded-2xl"
style={GLASS_BLUR}
/>
{/* Icon badge — notification-style tinted */}
<div
className="flex shrink-0 items-center justify-center rounded-xl"
style={{
width: 32,
height: 32,
background: `${config.color}18`,
border: `1px solid ${config.color}22`,
}}
>
<Icon size={16} weight="regular" style={{ color: config.color }} />
</div>
<span className="text-sm font-semibold text-white/70">{config.label}</span>
</motion.button>
)
}
// ─── Main component ──────────────────────────────────────────────────────────
export default function GlassToast() {
const [toasts, setToasts] = useState<Toast[]>([])
const prefersReduced = useReducedMotion()
const idCounter = useRef(0)
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const addToast = useCallback((variant: ToastVariant) => {
const demo = DEMO_TOASTS[variant]
const id = `toast-${++idCounter.current}`
setToasts((prev) => {
const next = [...prev, { id, variant, ...demo }]
// Enforce max visible — remove oldest first
if (next.length > MAX_TOASTS) {
return next.slice(next.length - MAX_TOASTS)
}
return next
})
}, [])
return (
<div className="flex min-h-screen w-full items-center justify-center bg-[#1A1A19]">
{/* Background image */}
<img
src={BACKGROUND_IMAGE}
alt=""
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-60"
/>
{/* Trigger buttons — centered, wrap on mobile */}
<div className="relative z-10 flex flex-wrap items-center justify-center gap-3 px-4">
{(Object.keys(VARIANTS) as ToastVariant[]).map((variant) => (
<TriggerButton
key={variant}
variant={variant}
onTrigger={() => addToast(variant)}
/>
))}
</div>
{/* Toast container — full width on mobile, 380px on desktop */}
<div className="fixed bottom-4 left-4 right-4 z-50 flex flex-col-reverse gap-3 sm:bottom-6 sm:left-auto sm:right-6 sm:w-[380px]">
<AnimatePresence mode="popLayout" initial={false}>
{toasts.map((toast) => (
<ToastCard
key={toast.id}
toast={toast}
onDismiss={dismissToast}
prefersReduced={prefersReduced}
/>
))}
</AnimatePresence>
</div>
</div>
)
}
Add to your project
One command adds this component to your project.
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/glass-toastFor dark mode, add the dark class to your <html> element:
<html class="dark">Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for “a navigation component from AI Canvas” inside Claude Code, Codex, or Cursor and it can suggest you a few options, then install the one you like. Less typing, lower token cost, modern way to build.
Get MCPAbout Glass Toast
Glass Toast is a frosted-glass toast notification system with four variants (success, info, warning, error), an auto-dismiss progress bar inside the card itself, and spring-animated stacking when multiple toasts queue up. Motion handles both the entrance/exit timing and the stack reflow when one disappears. It is the canonical toast component for SaaS dashboards, mobile apps, and any product that needs short-lived, low-friction feedback after an action.


