Free kit: animated pieces + 8 AI prompts → https://qodesign.dev/free?utm_source=devto&utm_medium=tutorial&utm_campaign=tutorial7&utm_content=top
Most apps built with Cursor, v0 or Lovable end up with toasts sooner or later: "Saved", "Copied", "Something went wrong". The default is usually a flat box in a corner that appears and disappears, and if three fire at once they pile on top of each other.
This post adds a toast stack that behaves more like a deck of cards. New toasts slide in at the front and older ones tuck behind. Hover the deck (or tab into it) and it fans out into a list while the page behind it blurs. You can swipe a toast sideways to throw it away. And a promise toast shows a spinner that turns into a check with a small burst when the work finishes.
It's free and MIT, from my repo qodesign-free. Live demo here, and its page on qodesign.dev. I tested every step below in a fresh Next.js 16 app (React 19, App Router, TypeScript) today, and the code blocks are the ones that ran.
Pick a route
There are two ways in, and both take about a minute:
| shadcn CLI | npm package | |
|---|---|---|
| What lands in your repo |
ToastStack.tsx + toast-stack.css, yours to edit |
nothing, it lives in node_modules
|
| Good when | you want to change the code or let your AI agent restyle it | you just want toasts and updates |
| Needs Tailwind | only for shadcn init
|
no |
The toast stack ships its own CSS file, so the component itself doesn't use Tailwind. The shadcn CLI is a different story: shadcn init expects a project that already has Tailwind set up. No Tailwind in your app? Take the npm route. If you do have Tailwind, nothing clashes, because every selector in that CSS file is scoped to a qd- class.
Route A: the shadcn registry
If your project doesn't have a components.json yet, run this once:
npx shadcn@latest init
Then add the component by URL:
npx shadcn@latest add https://teodoravadani.github.io/qodesign-free/r/toast-stack.json
In my test that created two files and added one dependency:
✔ Created 2 files:
- components/toast-stack/ToastStack.tsx
- components/toast-stack/toast-stack.css
package.json got "framer-motion": "^11.18.2". That's the version I test with. The .tsx file imports its own stylesheet, so there's nothing to add to globals.css.
Your import path is @/components/toast-stack/ToastStack.
Route B: npm
npm i qodesign-free framer-motion@11.18.2
Import from the subpath, not the package root:
import { Toaster, toast } from 'qodesign-free/toast-stack';
The root ('qodesign-free') also works, but it pulls in the CSS for every piece in the package, the dashboards included. The subpath ships only the toast stack's styles.
Step 1: render the Toaster once
Put <Toaster /> in your root layout. The component file starts with 'use client', so you can render it straight from app/layout.tsx, which stays a server component:
// app/layout.tsx
import { Toaster } from 'qodesign-free/toast-stack';
// shadcn route: import { Toaster } from '@/components/toast-stack/ToastStack';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Toaster />
</body>
</html>
);
}
The page still built as a static prerender with no hydration warnings.
You don't need a provider. The toasts live in a tiny module-level store that the Toaster reads with useSyncExternalStore, so toast() works from any client component, a plain event handler or a utility file. This is the part of the source that makes that work:
let items: ToastData[] = [];
let seq = 0;
const listeners = new Set<() => void>();
const emit = () => { items = [...items]; listeners.forEach((l) => l()); };
const subscribe = (l: () => void) => { listeners.add(l); return () => { listeners.delete(l); }; };
const getItems = () => items;
// inside <Toaster />
const all = useSyncExternalStore(subscribe, getItems, getItems);
Step 2: fire a toast
Here's a settings page with a Save button that shows a spinner while it saves, and an Archive button with an Undo action. The page itself is a server component; only the buttons are client code:
// app/save-button.tsx
'use client';
import { toast } from 'qodesign-free/toast-stack';
async function saveSettings() {
await new Promise((r) => setTimeout(r, 1200));
return { fields: 3 };
}
export function SaveButton() {
return (
<>
<button
onClick={() =>
toast.promise(saveSettings(), {
loading: { title: 'Saving settings' },
success: (r) => ({ title: 'Saved', description: `${r.fields} fields updated` }),
error: () => ({ title: 'Could not save', description: 'Try again in a moment' }),
})
}
>
Save
</button>
<button
onClick={() =>
toast.success('Project archived', {
description: 'acme-landing',
action: { label: 'Undo', onClick: () => toast('Restored') },
})
}
>
Archive
</button>
</>
);
}
// app/page.tsx
import { SaveButton } from './save-button';
export default function Page() {
return (
<main style={{ padding: 40 }}>
<h1>Settings</h1>
<SaveButton />
</main>
);
}
saveSettings is a stand-in. Swap in your fetch, your Supabase call or a server action; toast.promise takes any promise. In the browser the toast read "Saving settings", then "Saved / 3 fields updated" once the promise resolved. Clicking Undo removed the archived toast and showed "Restored".
Two details I like in the promise toast. The spinner stays up for at least 600 ms, so a fast request doesn't flash. And the auto-dismiss timer doesn't run while a toast is loading or while the deck is open, so nothing vanishes while someone is reading it.
That's the whole install. The rest of this post is optional.
The full API
| Call | What it does |
|---|---|
toast(title, options?) / toast.info(...)
|
info toast (violet) |
toast.success(title, options?) |
success toast (lime) |
toast.error(title, options?) |
error toast (red, role="alert") |
toast.promise(promise, { loading, success, error? }, options?) |
loading toast that turns into success or error when the promise settles |
toast.dismiss(id, direction?) |
remove one toast (-1 or 1 throws it sideways) |
toast.clear() |
remove all of them, one after another |
Options: description, action ({ label, onClick, keepOpen? }) and duration in ms.
Coming from sonner? toast, toast.success, toast.error, toast.info, toast.promise and toast.dismiss have the same names, so most call sites keep working. There's no toast.warning or toast.loading. The other difference is toast.promise: sonner takes strings for loading and success, and this one takes { title, description } objects, with success and error as functions of the result.
Toaster props worth knowing
<Toaster
width={432} // px; on phones it goes full width minus 24 px
visibleToasts={3} // cards visible in the collapsed deck
duration={5000} // auto-dismiss in ms
backdrop={true} // blur the page behind the open deck
/>
It sits bottom right and moves to the bottom of the screen on phones, above the iPhone home bar. Colours are four CSS variables at the top of toast-stack.css: --volt (success), --err, --ultra (info) and --cyan (loading). On the npm route you can override them from your own CSS. Use a selector one step more specific than the package's, so load order doesn't matter:
body .qd-toaster { --volt: #22c55e; --ultra: #6366f1; }
Using framer-motion 12 or motion
The package pins framer-motion 11.18.2 because that's what I test against. If your app is already on framer-motion 12, leave it: the toast stack also ran clean on framer-motion 12.43.0 in my tests. If you've moved to the newer motion package, take the shadcn route and change the one import at the top of ToastStack.tsx:
// before
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
// after
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
I ran that swap with motion 12.43.0 in the same Next.js 16 app: it built, and the promise and Undo toasts behaved the same in the browser. I've only tested this for the toast stack, not the other pieces in the repo.
A prompt for Cursor or v0
If you'd rather have your agent do it, open your project in Cursor (or paste this into v0) and use this. It's adapted from the PROMPT.md that ships with the component:
Add the qodesign Toast Stack to my Next.js App Router project.
1. Install it with the shadcn CLI:
npx shadcn@latest add https://teodoravadani.github.io/qodesign-free/r/toast-stack.json
(If there's no components.json, run npx shadcn@latest init first.)
It adds components/toast-stack/ToastStack.tsx and toast-stack.css and
framer-motion 11.18.2. The component's styles don't use Tailwind.
2. Render <Toaster /> once in app/layout.tsx, after {children}.
3. If I already use sonner, react-hot-toast or react-toastify, list every call
site and switch it to toast.success / toast.error / toast.info /
toast.promise. Keep the messages the same. For toast.promise, pass
{ title, description } objects and make success/error functions.
4. Wire one real async action of mine (a save, an upload, a form submit) to
toast.promise.
5. Remove the old toast library and its provider if nothing uses it anymore.
6. Run my type-check and next build.
Done when the build passes, the console is clean, toasts stack into a deck,
fan out on hover and on keyboard focus, swipe away sideways, and the promise
toast turns into a check. Tell me which files you changed.
If your agent can't run the CLI (v0 in the browser, for example), tell it to read the registry URL instead. That JSON holds the full source of both files.
Accessibility, briefly
- The list is an
aria-live="polite"region, and errors userole="alert". -
Alt+Tjumps to the newest toast.Escape,DeleteorBackspacedismisses the focused one. - Keyboard focus opens the deck the same way hover does, and the cards hidden in the collapsed deck are
inert. - Buttons are at least 44 px tall.
- With Reduce Motion on, toasts fade in and out in place and the spinner pulses instead of turning.
About me, and the links. I'm Teodor, and I make animated UI components at qodesign, so the toast stack in this post is mine and this post is partly about my own work. It's free and MIT, along with the Pricing Switch, Upload Dropzone, Day/Night Toggle and the Orbit and Ledger dashboards, on GitHub and on npm as qodesign-free. It's also in a free kit on my site with the Day/Night Toggle, the Pricing Switch and eight copy-paste prompts for Cursor, v0, Lovable or Claude Code. The kit asks for an email and sends a few follow-ups about the paid pieces, so you know what you're signing up for: the free kit. The paid pieces (an OTP gate, a liquid glass tab bar, login screens and more) are at qodesign.dev/components. Each comes with a React + Tailwind version and a PROMPT.md for AI coding tools. Browse the catalogue.
If you install it and something breaks in your setup, open an issue on the repo. I'd like to know which stacks I haven't tried.
I wrote this post with help from an AI assistant. Every install command and code block was run in a fresh Next.js 16 app and checked in a browser before publishing.

Top comments (0)