Animated Toast

A global alert built with Radix UI and Framer Motion.

Usage

Add the Provider to your app:

import { ToastProvider } from "./toast";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <ToastProvider>{children}</ToastProvider>
      </body>
    </html>
  );
}

then use the Hook to render new messages:

"use client";

import { useToast } from "./toast";

export default function Form() {
  let { showToast } = useToast();

  return (
    <form
      action={() => {
        showToast("User saved!");
      }}
    >
      <button type="submit">Save</button>
    </form>
  );
}

toast.tsx

"use client";

import { XMarkIcon } from "@heroicons/react/20/solid";
import * as RadixToast from "@radix-ui/react-toast";
import { AnimatePresence, motion } from "framer-motion";
import {
  ElementRef,
  ReactNode,
  createContext,
  forwardRef,
  useContext,
  useState,
} from "react";

const ToastContext = createContext<{
  showToast: (text: string) => void;
}>({
  showToast: () => {
    throw new Error(
      "You can't call showToast() outside of a <ToastProvider> – add it to your tree."
    );
  },
});

export function useToast() {
  return useContext(ToastContext);
}

export function ToastProvider({ children }: { children: ReactNode }) {
  const [messages, setMessages] = useState<{ id: string; text: string }[]>([]);

  function showToast(text: string) {
    setMessages((toasts) => [
      ...toasts,
      {
        id: window.crypto.randomUUID(),
        text,
      },
    ]);
  }

  return (
    <RadixToast.Provider>
      <ToastContext.Provider value={{ showToast }}>
        {children}
      </ToastContext.Provider>

      <AnimatePresence mode="popLayout">
        {messages.map((toast) => (
          <Toast
            key={toast.id}
            text={toast.text}
            onClose={() =>
              setMessages((toasts) => toasts.filter((t) => t.id !== toast.id))
            }
          />
        ))}
      </AnimatePresence>

      <RadixToast.Viewport className="max-sm:top-20 fixed top-4 right-4 flex w-80 flex-col-reverse gap-3" />
    </RadixToast.Provider>
  );
}

const Toast = forwardRef<
  ElementRef<typeof RadixToast.Root>,
  {
    onClose: () => void;
    text: string;
  }
>(function Toast({ onClose, text }, forwardedRef) {
  let width = 320;
  let margin = 16;

  return (
    <RadixToast.Root
      ref={forwardedRef}
      asChild
      forceMount
      onOpenChange={onClose}
      duration={2500}
    >
      <motion.li
        layout
        initial={{ x: width + margin }}
        animate={{ x: 0 }}
        exit={{
          opacity: 0,
          zIndex: -1,
          transition: {
            opacity: {
              duration: 0.2,
            },
          },
        }}
        transition={{
          type: "spring",
          mass: 1,
          damping: 30,
          stiffness: 200,
        }}
        style={{ width, WebkitTapHighlightColor: "transparent" }}
      >
        <div className="flex items-center justify-between overflow-hidden whitespace-nowrap rounded-lg border border-gray-600 bg-gray-700 text-sm text-white shadow-sm backdrop-blur">
          <RadixToast.Description className="truncate p-4">
            {text}
          </RadixToast.Description>
          <RadixToast.Close className="border-l border-gray-600/50 p-4 text-gray-500 transition hover:bg-gray-600/30 hover:text-gray-300 active:text-white">
            <XMarkIcon className="h-5 w-5" />
          </RadixToast.Close>
        </div>
      </motion.li>
    </RadixToast.Root>
  );
});

Dependencies

Built with Radix UI, Framer Motion, Tailwind CSS, and Heroicons.

npm i @radix-ui/react-toast framer-motion @heroicons/react