Feedback & Data
ToastWave
Toasts ride in on a band of colour, and a small wave along the bottom drains as the time runs out. Hover or focus holds it. Call show() from the useToastWave hook.
Download
- React
- Tailwind CSS v4
- motion
components/ui/toast-wave.tsx
"use client";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
export type ToastWaveItem = {
id: number;
title: string;
description?: string;
tone?: "info" | "success" | "error";
};
type ToastWaveProps = {
/** From useToastWave. */
toasts: ToastWaveItem[];
/** From useToastWave. */
dismiss: (id: number) => void;
/** Milliseconds a toast stays. Hover or focus pauses it. */
duration?: number;
/** Keep the toasts inside the nearest positioned parent instead of the screen. */
contained?: boolean;
/** Replaces the default toast look. */
className?: string;
};
const TONES = { info: "#a78bfa", success: "#34d399", error: "#f87171" };
/* Holds the toast list. Give max to change how many show at once. */
export function useToastWave(max = 3) {
const nextId = useRef(0);
const [toasts, setToasts] = useState<ToastWaveItem[]>([]);
const show = useCallback(
(toast: Omit<ToastWaveItem, "id">) => {
const id = ++nextId.current;
setToasts((list) => [...list, { ...toast, id }].slice(-max));
},
[max],
);
const dismiss = useCallback((id: number) => setToasts((list) => list.filter((t) => t.id !== id)), []);
return { toasts, show, dismiss };
}
function ToastCard({
toast,
dismiss,
duration,
className,
}: { toast: ToastWaveItem } & Pick<ToastWaveProps, "dismiss"> & { duration: number; className: string }) {
const reduce = useReducedMotion();
const [paused, setPaused] = useState(false);
const left = useMotionValue(1);
const clip = useTransform(left, (share) => `inset(0 ${(1 - share) * 100}% 0 0)`);
// The wave line is the timer: it drains, and dismisses when empty. Pausing just stops it.
useEffect(() => {
if (paused) return;
const run = animate(left, 0, {
duration: (left.get() * duration) / 1000,
ease: "linear",
onComplete: () => dismiss(toast.id),
});
return () => run.stop();
}, [paused, left, duration, dismiss, toast.id]);
return (
<div
style={{ "--tw": TONES[toast.tone ?? "info"] } as CSSProperties}
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
className={`relative overflow-hidden ${className}`}
>
{!reduce && (
<motion.span
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 w-1/2 bg-linear-to-r from-transparent via-(--tw)/30 to-transparent"
initial={{ x: "-100%", opacity: 1 }}
animate={{ x: "200%", opacity: 0 }}
transition={{ duration: 0.9, ease: "easeOut" }}
/>
)}
<div className="relative flex items-start gap-2.5 pr-6">
<span aria-hidden className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-(--tw)" />
<div className="min-w-0">
<p className="text-sm font-medium">{toast.title}</p>
{toast.description && <p className="mt-0.5 text-[13px] text-white/55">{toast.description}</p>}
</div>
</div>
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(toast.id)}
className="absolute top-2.5 right-2.5 grid h-6 w-6 place-items-center rounded-md text-white/50 outline-none hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70"
>
<svg aria-hidden viewBox="0 0 12 12" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M2 2l8 8M10 2l-8 8" />
</svg>
</button>
<motion.div aria-hidden style={{ clipPath: clip }} className="absolute inset-x-0 bottom-0 h-1.5 overflow-hidden">
<motion.svg
viewBox="0 0 160 6"
preserveAspectRatio="none"
className="h-full w-[200%] text-(--tw)"
animate={reduce ? undefined : { x: ["0%", "-50%"] }}
transition={{ duration: 2.4, ease: "linear", repeat: Infinity }}
>
<path d="M0 3q10-5 20 0t20 0t20 0t20 0t20 0t20 0t20 0t20 0" fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</motion.svg>
</motion.div>
</div>
);
}
/* ToastWave: toasts ride in from the side on a band of colour, and a little wave along the bottom drains as the time runs out. Hover or focus holds it still. */
export function ToastWave({
toasts,
dismiss,
duration = 4000,
contained = false,
className = "rounded-2xl border border-white/10 bg-[#141026]/95 p-3.5 pb-4 text-white shadow-xl shadow-black/50 backdrop-blur-xl",
}: ToastWaveProps) {
const reduce = useReducedMotion();
const hidden = reduce ? { opacity: 0 } : { opacity: 0, x: 48, scale: 0.96 };
return (
<ol
aria-live="polite"
aria-label="Notifications"
className={`${contained ? "absolute" : "fixed"} right-4 bottom-4 z-50 flex w-[min(22rem,calc(100%-2rem))] flex-col gap-2`}
>
<AnimatePresence initial={false} mode="popLayout">
{toasts.map((toast) => (
<motion.li
key={toast.id}
layout={!reduce}
initial={hidden}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={hidden}
transition={reduce ? { duration: 0.15 } : { type: "spring", stiffness: 420, damping: 32 }}
>
<ToastCard toast={toast} dismiss={dismiss} duration={duration} className={className} />
</motion.li>
))}
</AnimatePresence>
</ol>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/toast-wave.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/toast-wave-demo.tsx
"use client"; import { ToastWave, useToastWave } from "@/components/ui/toast-wave"; const BUTTON = "rounded-xl border border-white/10 bg-white/5 px-3.5 py-2 text-sm text-white/80 outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70"; export default function ToastWaveExample() { const { toasts, show, dismiss } = useToastWave(2); // `contained` keeps the toasts inside this box. On a real page, leave it out. return ( <div className="relative flex h-full flex-wrap content-start justify-center gap-2 overflow-hidden bg-[#0b0a14] p-6"> <button type="button" className={BUTTON} onClick={() => show({ title: "Saved", description: "Your changes are safe.", tone: "success" })}> Save </button> <button type="button" className={BUTTON} onClick={() => show({ title: "New version ready", description: "Reload when you can." })}> Info </button> <button type="button" className={BUTTON} onClick={() => show({ title: "Upload failed", description: "The file is over 10 MB.", tone: "error" })}> Error </button> <ToastWave toasts={toasts} dismiss={dismiss} contained /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| toasts | { id, title, description?, tone? }[] | — | From useToastWave(max). tone = info, success or error. |
| dismiss | (id: number) => void | — | From useToastWave(max). |
| duration | number | 4000 | Milliseconds a toast stays. |
| contained | boolean | false | Stay inside the nearest positioned parent instead of the screen. |
| className | string | dark glass card | Replaces the toast look. |