Toast stack
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Add toasts to the stack, hover to fan them out, swipe one away to dismiss it.
Code
"use client";
import { AnimatePresence, motion } from "motion/react";
import { CircleCheck, CircleX, Info, X } from "lucide-react";
import { useState } from "react";
import { Surface } from "../ui";
const KINDS = [
{ Icon: CircleX, tone: "text-[#d93b3b]", title: "Payment declined", body: "Try another card.", action: "Retry" },
{ Icon: CircleCheck, tone: "text-[#2f9e5b]", title: "Invoice sent", body: "Ana will get it in a minute.", action: "View" },
{ Icon: Info, tone: "text-[#2B46B8]", title: "New version ready", body: "Restart to update.", action: "Restart" },
];
type Toast = { id: number; kind: number };
export default function ToastStack() {
const [toasts, setToasts] = useState<Toast[]>([
{ id: 1, kind: 1 },
{ id: 2, kind: 0 },
]);
const [nextId, setNextId] = useState(3);
const [open, setOpen] = useState(false);
const push = () => {
setToasts((t) => [...t, { id: nextId, kind: nextId % KINDS.length }].slice(-3));
setNextId((n) => n + 1);
};
const remove = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
return (
<Surface>
<div className="relative h-[13em] w-[19em]">
<div
className="absolute inset-x-0 bottom-[3.6em]"
onPointerEnter={() => setOpen(true)}
onPointerLeave={() => setOpen(false)}
onClick={() => setOpen(true)}
>
<AnimatePresence initial={false}>
{toasts.map((t, idx) => {
const i = toasts.length - 1 - idx; // 0 = newest
const k = KINDS[t.kind];
const hidden = !open && i > 2;
return (
<motion.div
key={t.id}
layout={false}
drag="x"
dragSnapToOrigin
dragElastic={0.5}
onDragEnd={(_, info) => {
if (Math.abs(info.offset.x) > 80 || Math.abs(info.velocity.x) > 500) remove(t.id);
}}
initial={{ y: 40, opacity: 0, scale: 0.95 }}
animate={{
y: open ? `${-i * 4.1}em` : `${-i * 0.7}em`,
scale: open ? 1 : 1 - i * 0.05,
opacity: hidden ? 0 : 1,
}}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
transition={{ type: "spring", stiffness: 320, damping: 30 }}
style={{ zIndex: 50 - i, transformOrigin: "50% 100%" }}
className="absolute inset-x-0 bottom-0 flex h-[3.6em] cursor-grab touch-pan-y items-center gap-[0.7em] rounded-[0.9em] bg-white px-[0.9em] shadow-[0_8px_24px_-12px_rgba(0,0,0,0.3),0_0_0_1px_rgba(0,0,0,0.04)] active:cursor-grabbing"
>
<motion.div animate={{ opacity: open || i === 0 ? 1 : 0 }} className="flex flex-1 items-center gap-[0.7em]">
<k.Icon className={`h-[1.2em] w-[1.2em] shrink-0 ${k.tone}`} strokeWidth={2} />
<div className="min-w-0 flex-1 leading-tight">
<p className="truncate text-[0.85em] font-medium">{k.title}</p>
<p className="truncate text-[0.75em] text-[#8a8984]">{k.body}</p>
</div>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
remove(t.id);
}}
className="rounded-full bg-[#f1f0ec] px-[0.8em] py-[0.3em] text-[0.75em] font-medium hover:bg-[#e4e3de]"
>
{k.action}
</button>
<button
type="button"
aria-label="Dismiss"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
remove(t.id);
}}
className="text-[#8a8984] hover:text-[#151515]"
>
<X className="h-[1em] w-[1em]" />
</button>
</motion.div>
</motion.div>
);
})}
</AnimatePresence>
</div>
<motion.button
type="button"
whileTap={{ scale: 0.95 }}
onClick={push}
className="absolute bottom-0 left-1/2 -translate-x-1/2 rounded-full bg-[#151515] px-[1.2em] py-[0.55em] text-[0.8em] font-medium text-white"
>
Show toast
</motion.button>
</div>
</Surface>
);
}
Prompt
Build "Toast stack", a other animation. What it does: Add toasts to the stack, hover to fan them out, swipe one away to dismiss it. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using lucide-react, motion. Technique: an HTML canvas, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).