Bright stationery arranged creatively on a green background, perfect for school themes.

Next.js Toast Notifications with Motion: Swipe, Stack, A11y

· 10 min read

A good toast system in the Next.js App Router comes down to four pieces: a small client-side store, a viewport that animates entries and exits, a swipe gesture that decides when a toast is gone, and a clean way to turn Server Action results into toasts. You can build all of it with Motion (currently 14.6.0) in about 200 lines, and your layout and pages stay Server Components.

This guide builds it step by step. Each step runs on its own, and I'll point out the spots where people usually get stuck.

Step 1: Install Motion and put the provider in your root layout

1npm install motion@14

Motion's React API is imported from motion/react (AnimatePresence docs). Every file that renders motion.* components or calls Motion hooks needs "use client".

The store is a React context holding an array of toasts. Two choices matter here. First, toast() takes an optional id, and if a toast with that id already exists it gets replaced instead of added again. That is how we stop duplicate toasts later. Second, the stack is capped at three. When a fourth toast arrives, the oldest one leaves with its exit animation instead of piling up off-screen.

1// components/toast/toast-provider.tsx
2"use client";
3
4import { createContext, useCallback, useContext, useMemo, useState } from "react";
5import { Toaster } from "./toaster";
6
7export type ToastVariant = "success" | "error" | "info";
8
9export type Toast = {
10  id: string;
11  title: string;
12  description?: string;
13  variant: ToastVariant;
14  duration: number;
15};
16
17export type ToastInput = {
18  id?: string;
19  title: string;
20  description?: string;
21  variant?: ToastVariant;
22  duration?: number;
23};
24
25type ToastContextValue = {
26  toasts: Toast[];
27  toast: (input: ToastInput) => string;
28  dismiss: (id: string) => void;
29};
30
31const MAX_TOASTS = 3;
32const ToastContext = createContext<ToastContextValue | null>(null);
33
34export function ToastProvider({ children }: { children: React.ReactNode }) {
35  const [toasts, setToasts] = useState<Toast[]>([]);
36
37  const dismiss = useCallback((id: string) => {
38    setToasts((current) => current.filter((t) => t.id !== id));
39  }, []);
40
41  const toast = useCallback((input: ToastInput) => {
42    const id = input.id ?? crypto.randomUUID();
43    const next: Toast = {
44      id,
45      title: input.title,
46      description: input.description,
47      variant: input.variant ?? "info",
48      duration: input.duration ?? 5000,
49    };
50    // Newest first; replacing by id makes repeated calls idempotent.
51    setToasts((current) =>
52      [next, ...current.filter((t) => t.id !== id)].slice(0, MAX_TOASTS)
53    );
54    return id;
55  }, []);
56
57  const value = useMemo(() => ({ toasts, toast, dismiss }), [toasts, toast, dismiss]);
58
59  return (
60    <ToastContext.Provider value={value}>
61      {children}
62      <Toaster />
63    </ToastContext.Provider>
64  );
65}
66
67export function useToast() {
68  const ctx = useContext(ToastContext);
69  if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
70  return ctx;
71}

Wrap your app in the root layout:

1// app/layout.tsx  (still a Server Component, no "use client")
2import { ToastProvider } from "@/components/toast/toast-provider";
3
4export default function RootLayout({ children }: { children: React.ReactNode }) {
5  return (
6    <html lang="en">
7      <body>
8        <ToastProvider>{children}</ToastProvider>
9      </body>
10    </html>
11  );
12}
Where people get stuck: a lot of developers assume that wrapping children in a client provider turns the whole tree into client code. It doesn't. children is rendered on the server and passed through as a prop. The mistake that actually costs you is importing a client component into a Server Component and then also importing server-only modules into that client file. Keep the provider, the viewport and anything that calls useToast in client files. Everything else can stay on the server.

Step 2: Build the viewport with AnimatePresence and layout animations

Exit animations are the whole reason to use AnimatePresence. It keeps removed children mounted until their exit animation finishes. The stacking behaviour comes from mode="popLayout". In that mode, exiting elements are taken out of the page layout so the toasts around them can reflow right away, and the docs say it pairs especially well with the layout prop so the remaining toasts animate to their new positions. See the layout animations docs for how layout works.

Here's a first version of the viewport with no drag yet. It's fully working: toasts animate in, auto-dismiss, and the stack reflows smoothly.

1// components/toast/toaster.tsx
2"use client";
3
4import { AnimatePresence, MotionConfig } from "motion/react";
5import { useToast } from "./toast-provider";
6import { ToastItem } from "./toast-item";
7
8export function Toaster() {
9  const { toasts, dismiss } = useToast();
10
11  return (
12    <MotionConfig reducedMotion="user">
13      <section
14        aria-label="Notifications"
15        className="pointer-events-none fixed bottom-4 right-4 z-50 w-[min(360px,calc(100vw-2rem))]"
16      >
17        {/* Always mounted, so screen readers register the live region before toasts appear */}
18        <ol
19          aria-live="polite"
20          aria-relevant="additions"
21          className="relative flex flex-col-reverse gap-2"
22        >
23          <AnimatePresence mode="popLayout" initial={false}>
24            {toasts.map((t, index) => (
25              <ToastItem key={t.id} toast={t} index={index} onDismiss={dismiss} />
26            ))}
27          </AnimatePresence>
28        </ol>
29      </section>
30    </MotionConfig>
31  );
32}

The flex-col-reverse on the list puts the newest toast (index 0) at the bottom, nearest the corner, and pushes older ones up.

There are three traps here, and all three come straight from Motion's troubleshooting docs:

  1. Keys must be stable and unique. The docs specifically warn that using index as a key breaks exit animations. Use the toast id.
  2. AnimatePresence must stay mounted. If you write toasts.length > 0 && <AnimatePresence>…, the last toast vanishes with no animation, because the component that runs exits has already unmounted. Put the condition inside it, not around it.
  3. The parent needs position: relative. popLayout pulls exiting elements out using absolute positioning. The docs say the animating parent should have a position other than static or exiting toasts will jump. That's why the ol has the relative class.

There's also a fourth trap. With popLayout, any direct child of AnimatePresence that is a custom component has to forward its ref to the DOM node. Next.js 16 runs on React 19, where ref is an ordinary prop on function components, so you don't need forwardRef. You do still need to accept ref and pass it to the motion.li. If you forget, exiting toasts won't pop out of the layout.

Step 3: Add swipe-to-dismiss with drag gestures

Now the toast itself. Motion's drag gesture gives you drag="x" and an onDragEnd(event, info) callback, where info carries offset and velocity. Count it as a swipe if the toast has moved far enough or was flicked fast enough. Testing only distance feels sluggish on phones, and testing only velocity misses slow, deliberate drags.

The part most implementations get wrong is the exit direction. A swiped toast should keep going the way it was thrown. But once a component is removed, AnimatePresence only has its last-rendered exit props, so trying to compute a direction-aware exit at dismissal time doesn't work reliably. The simpler approach: animate the toast off-screen yourself with animate() on a motion value, and only call onDismiss once that's finished. After that, the exit animation just has to fade out something that's already off-screen.

Advertisement

1// components/toast/toast-item.tsx
2"use client";
3
4import { useEffect, useState } from "react";
5import {
6  animate,
7  motion,
8  useMotionValue,
9  useReducedMotion,
10  useTransform,
11  type PanInfo,
12} from "motion/react";
13import type { Toast } from "./toast-provider";
14
15const SWIPE_DISTANCE = 100; // px
16const SWIPE_VELOCITY = 500; // px/s
17
18type Props = {
19  toast: Toast;
20  index: number;
21  onDismiss: (id: string) => void;
22  ref?: React.Ref<HTMLLIElement>; // React 19: ref is a regular prop
23};
24
25export function ToastItem({ toast, index, onDismiss, ref }: Props) {
26  const reduceMotion = useReducedMotion();
27  const x = useMotionValue(0);
28  const contentOpacity = useTransform(x, [-200, 0, 200], [0, 1, 0]);
29  const [paused, setPaused] = useState(false);
30
31  // Auto-dismiss, paused while hovered, focused or dragged.
32  useEffect(() => {
33    if (paused) return;
34    const timer = setTimeout(() => onDismiss(toast.id), toast.duration);
35    return () => clearTimeout(timer);
36  }, [paused, toast.id, toast.duration, onDismiss]);
37
38  async function handleDragEnd(_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) {
39    setPaused(false);
40    const isSwipe =
41      Math.abs(info.offset.x) > SWIPE_DISTANCE ||
42      Math.abs(info.velocity.x) > SWIPE_VELOCITY;
43
44    if (!isSwipe) {
45      animate(x, 0, { type: "spring", stiffness: 500, damping: 35 });
46      return;
47    }
48
49    const direction = Math.sign(info.offset.x || info.velocity.x) || 1;
50    await animate(x, direction * 420, { duration: reduceMotion ? 0 : 0.18 });
51    onDismiss(toast.id);
52  }
53
54  const isError = toast.variant === "error";
55
56  return (
57    <motion.li
58      ref={ref}
59      layout
60      drag="x"
61      dragMomentum={false}
62      style={{ x }}
63      onDragStart={() => setPaused(true)}
64      onDragEnd={handleDragEnd}
65      onPointerEnter={() => setPaused(true)}
66      onPointerLeave={() => setPaused(false)}
67      onFocus={() => setPaused(true)}
68      onBlur={() => setPaused(false)}
69      onKeyDown={(e) => e.key === "Escape" && onDismiss(toast.id)}
70      initial={{ opacity: 0, y: 24, scale: 0.96 }}
71      animate={{
72        opacity: 1,
73        y: 0,
74        scale: reduceMotion ? 1 : 1 - index * 0.04,
75      }}
76      exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15 } }}
77      transition={{ type: "spring", stiffness: 400, damping: 32 }}
78      className={`pointer-events-auto cursor-grab touch-pan-y rounded-lg border p-4 shadow-lg active:cursor-grabbing ${
79        isError ? "border-red-300 bg-red-50" : "border-zinc-200 bg-white"
80      }`}
81    >
82      <motion.div style={{ opacity: contentOpacity }} className="flex items-start gap-3">
83        <div className="flex-1">
84          <p className="font-medium">{toast.title}</p>
85          {toast.description && (
86            <p className="mt-1 text-sm text-zinc-600">{toast.description}</p>
87          )}
88        </div>
89        <button
90          type="button"
91          onClick={() => onDismiss(toast.id)}
92          aria-label="Dismiss notification"
93          className="rounded p-1 text-zinc-500 hover:text-zinc-900"
94        >
95          ×
96        </button>
97      </motion.div>
98    </motion.li>
99  );
100}

Some details in that code exist for a reason:

  • dragMomentum={false} and the manual snap-back. With momentum switched on, Motion starts its own inertia animation on x when you release. That races with your animate() call. Turning it off means you decide what happens after release every time, which makes the behaviour predictable.
  • The fade is on the inner div. animate already drives opacity on the li. If you also bind style={{ opacity }} to a motion value on the same element, the two fight. Fading the content with useTransform avoids that.
  • The close button is required. WCAG 2.2's Dragging Movements criterion asks for a single-pointer alternative to any drag action. Swipe is a nice extra, and the button is what actually makes dismissing accessible. Escape covers keyboard users who tab into the toast.
  • The timer pauses on hover and focus. If a toast disappears while someone is reading it or reaching for its button, they lose the message. This version restarts the full duration when the pointer leaves. Tracking the remaining time is possible, but in practice you won't notice the difference.

Step 4: Turn Server Action results into toasts

A Server Action runs on the server, so it can't call toast(). What it can do is return a result that a client component reacts to. React 19's useActionState is the right tool for this. It gives you the action's latest return value plus a pending flag. Next.js documents the pattern in its updating data guide.

1// app/settings/actions.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5
6export type ActionResult =
7  | { status: "idle" }
8  | { status: "success" | "error"; message: string; nonce: string };
9
10export async function saveProfile(
11  _prev: ActionResult,
12  formData: FormData
13): Promise<ActionResult> {
14  const name = String(formData.get("name") ?? "").trim();
15
16  if (name.length < 2) {
17    return { status: "error", message: "Name must be at least 2 characters.", nonce: crypto.randomUUID() };
18  }
19
20  // await db.user.update({ ... })
21  revalidatePath("/settings");
22  return { status: "success", message: `Profile saved as ${name}.`, nonce: crypto.randomUUID() };
23}
1// app/settings/profile-form.tsx
2"use client";
3
4import { useActionState, useEffect } from "react";
5import { saveProfile, type ActionResult } from "./actions";
6import { useToast } from "@/components/toast/toast-provider";
7
8const initialState: ActionResult = { status: "idle" };
9
10export function ProfileForm() {
11  const [state, formAction, isPending] = useActionState(saveProfile, initialState);
12  const { toast } = useToast();
13
14  useEffect(() => {
15    if (state.status === "idle") return;
16    toast({
17      id: state.nonce, // same result => same toast, never a duplicate
18      title: state.status === "success" ? "Saved" : "Couldn't save",
19      description: state.message,
20      variant: state.status,
21    });
22  }, [state, toast]);
23
24  return (
25    <form action={formAction} className="space-y-3">
26      <input name="name" aria-label="Display name" className="rounded border px-3 py-2" />
27      {state.status === "error" && (
28        <p role="alert" className="text-sm text-red-700">{state.message}</p>
29      )}
30      <button type="submit" disabled={isPending}>
31        {isPending ? "Saving…" : "Save"}
32      </button>
33    </form>
34  );
35}

app/settings/page.tsx can stay a Server Component that fetches data and renders <ProfileForm />.

The nonce solves two problems. In development, Strict Mode runs effects twice, and the fixed id means the second toast() call replaces the first instead of stacking a copy. It also handles the case where a user submits the same invalid value twice in a row. Each submission returns a new object with a new nonce, so the effect runs again and a fresh toast appears.

Don't fire the toast during render, for example by calling toast() in the component body when state changes. That updates the provider's state while React is rendering the form, and React will warn about it. Keep it in an effect.

Look at the inline role="alert" error, too. I'd keep validation errors next to the field even when you also show a toast. A toast is temporary and sits in a corner away from the form, and the live region on the viewport is polite, so screen readers may announce it late. The toast confirms what happened, and the inline message is the one users can rely on.

Where people get stuck: if your action calls redirect(), nothing gets returned, so there's no result for the client to read. For "saved, now go to the list" flows, set a short-lived cookie in the action, read it in the destination page (a Server Component), and pass it to a small client component that calls toast() once on mount.

Step 5: Respect prefers-reduced-motion everywhere, including the imperative bits

<MotionConfig reducedMotion="user"> in the viewport makes Motion follow the operating system setting. When reduced motion is on, the MotionConfig docs say transform and layout animations are disabled, while opacity and other non-transform values still animate. Your toasts still fade in and out, but they no longer slide, scale or reflow with motion. The accessibility guide explains the reasoning.

MotionConfig covers declarative props like animate, exit and layout. Imperative animate() calls are a different code path, so for those the component reads useReducedMotion() directly:

  • The swipe-out uses duration: 0 when reduced motion is on, so a swiped toast disappears immediately instead of flying across the screen.
  • The scale-down on older toasts is turned off, so the stack is a plain list.

Swiping still works with reduced motion. The toast follows your finger, since that movement comes directly from the user and isn't animation playing by itself. Only the motion added after you let go is removed.

To test it, turn on "Reduce motion" in macOS Accessibility settings, or open the Rendering panel in Chrome DevTools and emulate prefers-reduced-motion: reduce. Then submit the form a few times and swipe a toast.

You now have a stack that animates in, reflows when toasts leave, can be swiped in either direction, pauses while being read, works with keyboard and screen readers, and gets its data from Server Actions without moving any page into client code. If you also need promise-based toasts that show loading and then success, add an update(id, patch) method to the provider. Since each toast is matched by key, updating its content in place won't trigger an exit and re-entry.

Advertisement

Share this page

Article by Marcus Rodriguez

Marcus Rodriguez is the ReactLibraries.com byline for Next.js, server components, data fetching and full-stack TypeScript. Articles under this byline are written with AI assistance from current documentation, changelogs and release notes, and are reviewed by the ReactLibraries.com editor before publishing.

Related Content

Continue learning with these related articles