Customize
Usage
import { ToastProvider, useToast } from "@/components/motionkit/toast";
// 1. Wrap your app once (e.g. in app/layout.tsx)
<ToastProvider>{children}</ToastProvider>
// 2. Call it from any client component inside
const toast = useToast();
toast({
title: "Changes saved",
description: "Your project is up to date.",
variant: "success",
});Motion
The exact curves this component runs, plotted with Motion's own maths.
Enter
A toast appears
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/toast.tsx.
"use client";
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ToastVariant = "success" | "error" | "info";
type ToastOptions = {
title: string;
description?: string;
variant?: ToastVariant;
/** Milliseconds before auto-dismiss. Use 0 to keep it open. */
duration?: number;
};
type ToastItem = Required<Omit<ToastOptions, "description">> & { id: number; description?: string };
const ToastContext = createContext<((options: ToastOptions) => void) | null>(null);
const accents: Record<ToastVariant, string> = {
success: "bg-[#7D9E9D]",
error: "bg-[#8C3216]",
info: "bg-[#0E7C86]",
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const nextId = useRef(0);
const dismiss = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const toast = useCallback((options: ToastOptions) => {
const id = nextId.current++;
setToasts((current) => [
...current.slice(-3),
{ variant: "info", duration: 4000, ...options, id },
]);
}, []);
return (
<ToastContext.Provider value={toast}>
{children}
<ol
aria-live="polite"
className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-[calc(100%-2rem)] max-w-sm flex-col gap-2"
>
<AnimatePresence initial={false}>
{toasts.map((item) => (
<ToastCard key={item.id} item={item} onDismiss={dismiss} />
))}
</AnimatePresence>
</ol>
</ToastContext.Provider>
);
}
function ToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: (id: number) => void }) {
const [paused, setPaused] = useState(false);
const reduceMotion = useReducedMotion();
const close = () => onDismiss(item.id);
useEffect(() => {
if (!item.duration || paused) return;
const timer = window.setTimeout(() => onDismiss(item.id), item.duration);
return () => window.clearTimeout(timer);
}, [item.id, item.duration, paused, onDismiss]);
return (
<motion.li
layout
role="status"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, x: 40, scale: 0.95, transition: { duration: 0.18 } }}
transition={{ type: "spring", bounce: 0.25, duration: 0.4 }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: 0, right: 0.6 }}
onDragEnd={(_, info) => info.offset.x > 80 && close()}
// hovering or tabbing into a toast pauses its timer, so nobody loses it mid-read
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
className="pointer-events-auto relative flex gap-3 overflow-hidden rounded-xl border border-white/10 bg-[#141B1B]/95 p-4 pr-10 text-sm text-[#F6F1E8] shadow-2xl shadow-black/40 backdrop-blur"
>
<span className={`mt-1.5 size-2 shrink-0 rounded-full ${accents[item.variant]}`} />
<div>
<p className="font-medium">{item.title}</p>
{item.description && <p className="mt-1 text-[#94A9A7]">{item.description}</p>}
</div>
<button
onClick={close}
aria-label="Dismiss notification"
className="absolute right-3 top-3 rounded-md px-1.5 text-[#94A9A7] transition-colors hover:text-[#F6F1E8] focus-visible:outline-2 focus-visible:outline-[#5FAEB4]"
>
×
</button>
</motion.li>
);
}
export function useToast() {
const toast = useContext(ToastContext);
if (!toast) throw new Error("useToast must be used inside <ToastProvider>");
return toast;
}
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | — | Main message. |
| description | string | — | Optional secondary text. |
| variant | "success" | "error" | "info" | "info" | Accent color. |
| duration | number | 4000 | Milliseconds before auto-dismiss; 0 keeps it open. |
Stepper
Progress through steps: the line fills, and each finished step draws its own tick.
We shipped our launch page in an afternoon.
Card 1 of 4
Card Stack
A deck you swipe through: fling the top card and it tucks in at the back.