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.
Props & Styling
Props
useToast() (call inside a <ToastProvider>) — returns { addToast(toastType, description), dismissToast(id) }.
| Concept | Type | Default | Description |
|---|---|---|---|
toastType | 'default' | 'description' | 'success' | 'info' | 'warning' | 'error' | 'custom' | — | Selects an icon + color treatment from toastMap. Only 'description' renders the optional header text. |
Toaster
| Prop | Type | Default | Description |
|---|---|---|---|
position | 'top-left' | 'top-right' | 'top-center' | 'bottom-left' | 'bottom-right' | 'bottom-center' | 'bottom-right' | Corner/edge the stack anchors to. |
expandedOnHover | boolean | true | Whether hovering the stack fans the toasts apart instead of leaving them collapsed. |
Styling
The queue caps at 3 toasts — adding a 4th drops the oldest. Stacked (non-hovered) toasts are pushed back by their distance from the newest: each one scales down an extra 5% and offsets 10px per position back in the stack. Hovering expands that offset to 64px so every toast becomes fully readable, using a spring(stiffness: 300, damping: 30) for the resulting movement. Hovering also pauses each toast's 4-second auto-dismiss timer, so you can't have a toast disappear out from under your cursor while reading it.
Code
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