Talisson Costa
← All components

Toast

Stacked toasts that fan out on hover, swipe to dismiss, pause while you read, and morph from loading to done.

  • AnimatePresence
  • drag
  • useAnimate
  • useSyncExternalStore
  • ResizeObserver
  • aria-live

Toasts appear bottom-right. Hover to expand · swipe right or press Esc to dismiss.

    Install

    npx shadcn@latest add https://tcosta.dev/r/toast.json

    Or copy the files below into components/toast/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need motion.

    Source

    toaster.tsx
    'use client';
    
    import { AnimatePresence, motion } from 'motion/react';
    import { useState, type FocusEvent } from 'react';
    import { stackTransition } from './config';
    import { ToastItem } from './toast-item';
    import { useToasts } from './toast-store';
    import { usePageHidden } from './use-page-hidden';
    import { useStackLayout } from './use-stack-layout';
    
    export function Toaster({ visibleToasts = 3 }: { visibleToasts?: number }) {
      const toasts = useToasts();
      const isPageHidden = usePageHidden();
      const [isHovered, setIsHovered] = useState(false);
      const [isFocused, setIsFocused] = useState(false);
    
      const isExpanded = (isHovered || isFocused) && toasts.length > 0;
      const { onHeight, stackHeight, layoutOf } = useStackLayout(toasts, visibleToasts, isExpanded);
    
      const onBlur = (event: FocusEvent<HTMLOListElement>) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setIsFocused(false);
      };
    
      return (
        <section
          aria-label="Notifications"
          className="pointer-events-none fixed right-4 bottom-4 z-50 w-[min(356px,calc(100vw-2rem))]"
        >
          <motion.ol
            aria-live="polite"
            initial={false}
            animate={{ height: stackHeight }}
            transition={stackTransition}
            onMouseEnter={() => setIsHovered(true)}
            onMouseLeave={() => setIsHovered(false)}
            onFocus={() => setIsFocused(true)}
            onBlur={onBlur}
            className="pointer-events-auto relative"
          >
            <AnimatePresence initial={false}>
              {toasts.map((toast, index) => {
                const isInStack = index < visibleToasts;
                return (
                  <ToastItem
                    key={toast.id}
                    toast={toast}
                    index={index}
                    total={toasts.length}
                    isInStack={isInStack}
                    isExpanded={isExpanded}
                    isPaused={isExpanded || isPageHidden || !isInStack}
                    layout={layoutOf(index)}
                    onHeight={onHeight}
                  />
                );
              })}
            </AnimatePresence>
          </motion.ol>
        </section>
      );
    }
    
    toast-item.tsx
    import { motion, useAnimate, type PanInfo } from 'motion/react';
    import { cn } from '@/lib/utils';
    import { DEFAULT_DURATION, SCALE_STEP, stackTransition } from './config';
    import { ToastContent } from './toast-content';
    import { dismiss, type ToastData } from './toast-store';
    import { useReportHeight } from './use-stack-layout';
    import { useToastTimer } from './use-toast-timer';
    
    type ToastItemProps = {
      toast: ToastData;
      index: number;
      total: number;
      isInStack: boolean;
      isExpanded: boolean;
      isPaused: boolean;
      layout: { y: number; height: number | undefined };
      onHeight: (id: number, height: number) => void;
    };
    
    const itemClassName = cn(
      'group absolute inset-x-0 bottom-0 cursor-grab touch-pan-y overflow-hidden outline-none active:cursor-grabbing',
      'rounded-xl border bg-popover text-popover-foreground shadow-[0_4px_16px_rgb(0_0_0/0.08)]',
      'focus-visible:ring-[3px] focus-visible:ring-ring/50',
    );
    
    export function ToastItem({
      toast,
      index,
      total,
      isInStack,
      isExpanded,
      isPaused,
      layout,
      onHeight,
    }: ToastItemProps) {
      const [scope, animate] = useAnimate<HTMLLIElement>();
      const content = useReportHeight<HTMLDivElement>(toast.id, onHeight);
      const isFront = index === 0;
    
      useToastTimer({
        id: toast.id,
        type: toast.type,
        duration: toast.duration ?? DEFAULT_DURATION,
        isPaused,
      });
    
      // Removing a focused element doesn't fire `blur`, which would leave the stack expanded and paused.
      const dismissSelf = () => {
        const focused = document.activeElement;
        if (focused instanceof HTMLElement && scope.current?.contains(focused)) focused.blur();
        dismiss(toast.id);
      };
    
      // Swiped far or fast enough: fly out, then dismiss. Otherwise it springs back.
      const onDragEnd = async (_: unknown, info: PanInfo) => {
        if (info.offset.x < 80 && info.velocity.x < 400) return;
        await animate(scope.current, { x: 420, opacity: 0 }, { duration: 0.18, ease: 'easeOut' });
        dismissSelf();
      };
    
      return (
        <motion.li
          ref={scope}
          tabIndex={0}
          aria-hidden={!isInStack || undefined}
          initial={{ opacity: 0, y: 32 }}
          animate={{
            opacity: isInStack ? 1 : 0,
            y: layout.y,
            scale: isExpanded ? 1 : 1 - index * SCALE_STEP,
            height: layout.height ?? 'auto',
          }}
          exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.15, ease: 'easeIn' } }}
          transition={stackTransition}
          drag="x"
          dragConstraints={{ left: 0, right: 0 }}
          dragElastic={{ left: 0.04, right: 0.8 }}
          onDragEnd={onDragEnd}
          onKeyDown={(event) => event.key === 'Escape' && dismissSelf()}
          style={{ zIndex: total - index, transformOrigin: 'top center' }}
          className={cn(itemClassName, !isInStack && 'pointer-events-none')}
        >
          {/* Toasts behind the front one hide their content, so only their edge peeks. */}
          <motion.div
            ref={content}
            initial={false}
            animate={{ opacity: isExpanded || isFront ? 1 : 0 }}
            transition={{ duration: 0.2 }}
            className="flex items-start gap-3 p-4"
          >
            <ToastContent toast={toast} onDismiss={dismissSelf} />
          </motion.div>
        </motion.li>
      );
    }
    
    toast-content.tsx
    import { AnimatePresence, motion } from 'motion/react';
    import { cn } from '@/lib/utils';
    import { swapTransition } from './config';
    import { CloseIcon, ToastIcon } from './icons';
    import type { ToastData } from './toast-store';
    
    const actionClassName = cn(
      'shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground',
      'hover:bg-primary/90 active:scale-[0.97]',
    );
    
    const closeClassName = cn(
      '-mt-1 -mr-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity',
      'group-hover:opacity-100 group-focus-visible:opacity-100 focus-visible:opacity-100',
      'hover:bg-accent hover:text-accent-foreground',
    );
    
    // Icon and title cross-fade when a promise toast turns into success/error.
    const swap = {
      icon: {
        initial: { opacity: 0, scale: 0.5, filter: 'blur(3px)' },
        animate: { opacity: 1, scale: 1, filter: 'blur(0px)' },
        exit: { opacity: 0, scale: 0.5, filter: 'blur(3px)' },
      },
      title: {
        initial: { opacity: 0, y: 4 },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: -4 },
      },
    };
    
    type ToastContentProps = {
      toast: ToastData;
      onDismiss: () => void;
    };
    
    export function ToastContent({ toast, onDismiss }: ToastContentProps) {
      const { type, title, description, action } = toast;
    
      return (
        <>
          <span className="relative mt-0.5 flex size-4 shrink-0">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span key={type} {...swap.icon} transition={swapTransition} className="flex">
                <ToastIcon type={type} />
              </motion.span>
            </AnimatePresence>
          </span>
    
          <div className="min-w-0 flex-1">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.p
                key={type}
                {...swap.title}
                transition={swapTransition}
                className="text-sm font-medium"
              >
                {title}
              </motion.p>
            </AnimatePresence>
            {description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
          </div>
    
          {action && (
            <button
              type="button"
              onClick={() => {
                action.onClick();
                onDismiss();
              }}
              className={actionClassName}
            >
              {action.label}
            </button>
          )}
    
          <button
            type="button"
            aria-label="Dismiss notification"
            onClick={onDismiss}
            className={closeClassName}
          >
            <CloseIcon />
          </button>
        </>
      );
    }
    
    toast-store.ts
    import { useSyncExternalStore, type ReactNode } from 'react';
    import { DEFAULT_DURATION } from './config';
    
    export type ToastType = 'default' | 'success' | 'error' | 'loading';
    
    export type ToastOptions = {
      description?: ReactNode;
      /** ms before auto-dismiss. `Infinity` keeps it until dismissed. */
      duration?: number;
      action?: { label: string; onClick: () => void };
    };
    
    export type ToastData = ToastOptions & { id: number; type: ToastType; title: ReactNode };
    
    type Message<T> = ReactNode | ((value: T) => ReactNode);
    
    // Module-level store, so `toast()` works from anywhere without a provider.
    const EMPTY: ToastData[] = [];
    let toasts = EMPTY;
    let nextId = 1;
    const listeners = new Set<() => void>();
    
    function emit() {
      listeners.forEach((listener) => listener());
    }
    
    function subscribe(listener: () => void) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    }
    
    function create(type: ToastType, title: ReactNode, options: ToastOptions = {}) {
      const id = nextId++;
      toasts = [{ id, type, title, ...options }, ...toasts];
      emit();
      return id;
    }
    
    function update(id: number, patch: Partial<ToastData>) {
      toasts = toasts.map((toast) => (toast.id === id ? { ...toast, ...patch } : toast));
      emit();
    }
    
    export function dismiss(id?: number) {
      toasts = id === undefined ? EMPTY : toasts.filter((toast) => toast.id !== id);
      emit();
    }
    
    function resolve<T>(message: Message<T>, value: T) {
      return typeof message === 'function' ? (message as (value: T) => ReactNode)(value) : message;
    }
    
    function promise<T>(
      promise: Promise<T>,
      messages: { loading: ReactNode; success: Message<T>; error: Message<unknown> },
      options?: ToastOptions,
    ) {
      const id = create('loading', messages.loading, { ...options, duration: Infinity });
      const duration = options?.duration ?? DEFAULT_DURATION;
      promise.then(
        (data) => update(id, { type: 'success', title: resolve(messages.success, data), duration }),
        (error) => update(id, { type: 'error', title: resolve(messages.error, error), duration }),
      );
      return promise;
    }
    
    export const toast = Object.assign(
      (title: ReactNode, options?: ToastOptions) => create('default', title, options),
      {
        success: (title: ReactNode, options?: ToastOptions) => create('success', title, options),
        error: (title: ReactNode, options?: ToastOptions) => create('error', title, options),
        promise,
        dismiss,
      },
    );
    
    export function useToasts() {
      return useSyncExternalStore(
        subscribe,
        () => toasts,
        () => EMPTY,
      );
    }
    
    use-stack-layout.ts
    import { useCallback, useLayoutEffect, useRef, useState } from 'react';
    import { GAP, PEEK } from './config';
    import type { ToastData } from './toast-store';
    
    type OnHeight = (id: number, height: number) => void;
    
    /** Position of each toast in the stack, from their measured heights. */
    export function useStackLayout(toasts: ToastData[], visibleToasts: number, isExpanded: boolean) {
      const [heights, setHeights] = useState<Record<number, number>>({});
    
      const onHeight = useCallback<OnHeight>((id, height) => {
        setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }));
      }, []);
    
      const shown = toasts.slice(0, visibleToasts);
      const heightOf = (toast: ToastData) => heights[toast.id] ?? 0;
      const frontHeight = shown[0] ? heightOf(shown[0]) : 0;
      const gaps = Math.max(shown.length - 1, 0);
    
      const stackHeight = isExpanded
        ? shown.reduce((sum, toast) => sum + heightOf(toast), 0) + GAP * gaps
        : frontHeight + PEEK * gaps;
    
      // Expanded: stacked above the newer toasts in front. Collapsed: peeking behind the front one.
      const layoutOf = (index: number) => {
        const offset = shown.slice(0, index).reduce((sum, toast) => sum + heightOf(toast) + GAP, 0);
        return {
          y: isExpanded ? -offset : -index * PEEK,
          height: isExpanded || index === 0 ? heights[toasts[index].id] : frontHeight,
        };
      };
    
      return { onHeight, stackHeight, layoutOf };
    }
    
    /** Reports the element's natural height, now and whenever it resizes. */
    export function useReportHeight<T extends HTMLElement>(id: number, onHeight: OnHeight) {
      const ref = useRef<T>(null);
    
      useLayoutEffect(() => {
        const element = ref.current;
        if (!element) return;
        const observer = new ResizeObserver(() => onHeight(id, element.offsetHeight));
        observer.observe(element);
        onHeight(id, element.offsetHeight);
        return () => observer.disconnect();
      }, [id, onHeight]);
    
      return ref;
    }
    
    use-toast-timer.ts
    import { useEffect, useRef } from 'react';
    import { dismiss, type ToastType } from './toast-store';
    
    type UseToastTimerOptions = {
      id: number;
      type: ToastType;
      duration: number;
      isPaused: boolean;
    };
    
    /** Auto-dismiss countdown that pauses, and remembers the time left, while `isPaused`. */
    export function useToastTimer({ id, type, duration, isPaused }: UseToastTimerOptions) {
      const remaining = useRef(duration);
    
      // A promise toast that settles gets a fresh countdown.
      useEffect(() => {
        remaining.current = duration;
      }, [duration, type]);
    
      useEffect(() => {
        if (isPaused || !Number.isFinite(duration)) return;
        const started = Date.now();
        const timeout = setTimeout(() => dismiss(id), remaining.current);
        return () => {
          clearTimeout(timeout);
          remaining.current -= Date.now() - started;
        };
      }, [isPaused, duration, id, type]);
    }
    
    use-page-hidden.ts
    import { useEffect, useState } from 'react';
    
    export function usePageHidden() {
      const [isHidden, setIsHidden] = useState(false);
    
      useEffect(() => {
        const onChange = () => setIsHidden(document.hidden);
        document.addEventListener('visibilitychange', onChange);
        return () => document.removeEventListener('visibilitychange', onChange);
      }, []);
    
      return isHidden;
    }
    
    config.ts
    import type { Transition } from 'motion/react';
    
    export const DEFAULT_DURATION = 4000;
    
    export const GAP = 12; // between toasts when expanded
    export const PEEK = 10; // how much each toast behind peeks out when collapsed
    export const SCALE_STEP = 0.05;
    
    // Low bounce: the stack should settle, not wobble.
    export const stackTransition: Transition = { type: 'spring', duration: 0.45, bounce: 0.12 };
    export const swapTransition: Transition = { type: 'spring', duration: 0.3, bounce: 0 };
    
    icons.tsx
    import type { ToastType } from './toast-store';
    
    function Spinner() {
      return (
        <svg
          width="16"
          height="16"
          viewBox="0 0 24 24"
          fill="none"
          className="animate-spin text-muted-foreground"
          aria-hidden
        >
          <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
          <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
        </svg>
      );
    }
    
    const glyphs = {
      default: { className: 'text-muted-foreground', path: 'M12 11v5.5M12 7.5v.01' },
      success: { className: 'text-emerald-500', path: 'm8 12.5 2.5 2.5L16 9.5' },
      error: { className: 'text-destructive', path: 'M12 7.5v5.5M12 16.5v.01' },
    };
    
    export function ToastIcon({ type }: { type: ToastType }) {
      if (type === 'loading') return <Spinner />;
      const { className, path } = glyphs[type];
    
      return (
        <svg width="16" height="16" viewBox="0 0 24 24" className={className} aria-hidden>
          <circle cx="12" cy="12" r="10" fill="currentColor" />
          <path
            d={path}
            fill="none"
            stroke="white"
            strokeWidth="2.2"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      );
    }
    
    export function CloseIcon() {
      return (
        <svg
          width="12"
          height="12"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2.5"
          strokeLinecap="round"
          aria-hidden
        >
          <path d="M18 6 6 18M6 6l12 12" />
        </svg>
      );
    }
    
    index.ts
    export { Toaster } from './toaster';
    export { toast, type ToastOptions, type ToastType } from './toast-store';