DEV Community

Cover image for Add an animated toast stack to a Next.js app in 5 minutes (shadcn registry or npm)
Teodor Avadani
Teodor Avadani

Posted on

Add an animated toast stack to a Next.js app in 5 minutes (shadcn registry or npm)

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.

Toast Stack: the deck fans out, a promise toast resolves into a check

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
Enter fullscreen mode Exit fullscreen mode

Then add the component by URL:

npx shadcn@latest add https://teodoravadani.github.io/qodesign-free/r/toast-stack.json
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Import from the subpath, not the package root:

import { Toaster, toast } from 'qodesign-free/toast-stack';
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/page.tsx
import { SaveButton } from './save-button';

export default function Page() {
  return (
    <main style={{ padding: 40 }}>
      <h1>Settings</h1>
      <SaveButton />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
/>
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 use role="alert".
  • Alt+T jumps to the newest toast. Escape, Delete or Backspace dismisses 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)