Baktiyar Ablaikhan

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).