Agents (llms.txt)
Octocat

Toaster

A component for displaying transient messages to users, such as notifications or alerts.

import { Button } from '@/components/button';
import { toast } from '@/components/toaster';

const ToasterPreview = () => {
  return (
    <div className="flex flex-col gap-4">
      <Button
        type="button"
        size="sm"
        variant="outline"
        onClick={() => {
          toast({
            title: 'This is a toast notification!',
          });
        }}
      >
        Show Toast
      </Button>
    </div>
  );
};

export default ToasterPreview;

Dependencies

Source Code

import { CheckCircleIcon, XCircleIcon, XIcon } from '@phosphor-icons/react';
import { AnimatePresence, motion } from 'motion/react';
import type { ComponentPropsWithoutRef } from 'react';
import { useEffect, useSyncExternalStore } from 'react';
import { useTopLayer } from '@/foundations/hooks/use-top-layer';
import { cn } from '@/lib/utils/classnames';

const DEFAULT_TOAST_DURATION_MS = 7000;

type ToastVariant = 'default' | 'positive' | 'negative';

type Toast = {
  id: string;
  title: string;
  description?: string;
  duration?: number;
  variant?: ToastVariant;
};

type ToastStore = {
  toasts: Toast[];
  subscribe: (listener: () => void) => () => void;
  add: (config: Omit<Toast, 'id'>) => void;
  remove: (id: string) => void;
  pauseAll: () => void;
  resumeAll: () => void;
};

const createToastStore = (): ToastStore => {
  let toasts: Toast[] = [];
  const listeners = new Set<() => void>();

  const timers = new Map<
    string,
    {
      startTime: number;
      remainingTime: number;
      timeoutId: ReturnType<typeof setTimeout> | null;
    }
  >();

  const getToastId = () => {
    return Date.now().toString() + Math.random().toString(36).slice(2, 9);
  };

  const notifyListeners = () => {
    listeners.forEach((listener) => {
      listener();
    });
  };

  return {
    get toasts() {
      return toasts;
    },
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    add(toastData) {
      const id = getToastId();
      const newToast = { ...toastData, id };

      toasts = [...toasts, newToast];
      notifyListeners();

      const duration = toastData.duration ?? DEFAULT_TOAST_DURATION_MS;

      const timeout =
        duration !== Infinity
          ? setTimeout(() => this.remove(id), duration)
          : null;

      timers.set(id, {
        startTime: Date.now(),
        remainingTime: duration,
        timeoutId: timeout,
      });
    },
    remove(id) {
      toasts = toasts.filter((toast) => toast.id !== id);
      notifyListeners();

      const timer = timers.get(id);
      if (timer?.timeoutId) {
        clearTimeout(timer.timeoutId);
      }
      timers.delete(id);
    },
    pauseAll() {
      timers.forEach((timer) => {
        if (timer.timeoutId) {
          clearTimeout(timer.timeoutId);
          timer.timeoutId = null;

          const elapsed = Date.now() - timer.startTime;
          timer.remainingTime = Math.max(0, timer.remainingTime - elapsed);
        }
      });
    },
    resumeAll() {
      timers.forEach((timer, id) => {
        if (timer.timeoutId === null) {
          if (timer.remainingTime > 0) {
            timer.startTime = Date.now();

            if (timer.remainingTime !== Infinity) {
              timer.timeoutId = setTimeout(
                () => this.remove(id),
                timer.remainingTime
              );
            }
          } else {
            this.remove(id);
          }
        }
      });
    },
  };
};

// Create a singleton toast store that can be shared across the application.
// The store is attached to the global object to ensure it's shared across different modules and components.
//
// The main reason behind this is Astro's island architecture, where different parts of the UI can be rendered and hydrated independently,
// leading to multiple instances of the toast store. In other frameworks, this is not necessary but also doesn't cause any issues.
const STORE_KEY = '__significa_toast_store__';
const toastStore: ToastStore =
  ((globalThis as Record<string, unknown>)[STORE_KEY] as ToastStore) ??
  (() => {
    const store = createToastStore();
    (globalThis as Record<string, unknown>)[STORE_KEY] = store;
    return store;
  })();

// Hook to use the toast store
const useToastStore = () => {
  return useSyncExternalStore(
    toastStore.subscribe,
    () => toastStore.toasts,
    () => toastStore.toasts
  );
};

const toast = (toast: Omit<Toast, 'id'>) => {
  toastStore.add(toast);
};

const Toaster = ({ className }: { className?: string }) => {
  const toasts = useToastStore();
  const ref = useTopLayer<HTMLDivElement>(true);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    // Modals use the DOM top-layer (dialogs, drawers, etc.), where stacking order is
    // determined by open order and DOM position. To appear above a modal, the toaster
    // must be the last element in the DOM and opened after the modal.
    // To ensure this, we listen to modal open events and toggle the popover state of the toaster,
    // which moves it to the end of the top-layer and top of the stacking context.
    const onModalOpen = () => {
      element?.togglePopover();
      setTimeout(() => element?.togglePopover(), 0);
    };

    window.addEventListener('ui:modal-open', onModalOpen);

    return () => {
      window.removeEventListener('ui:modal-open', onModalOpen);
    };
  }, [ref]);

  return (
    <div
      ref={ref}
      data-toaster-provider
      className={cn(
        'fixed flex size-full flex-col items-end justify-end overflow-hidden bg-transparent px-4 py-3',
        'pointer-events-none',
        className
      )}
    >
      <AnimatePresence>
        {toasts.map((toast, index) => {
          return (
            <motion.div
              key={toast.id}
              onMouseEnter={() => toastStore.pauseAll()}
              onMouseLeave={() => toastStore.resumeAll()}
              style={{ zIndex: toasts.length - index }}
              initial={{ height: 0 }}
              animate={{ height: 'auto' }}
              exit={{ height: 0 }}
              transition={{ type: 'spring', bounce: 0.2, duration: 0.5 }}
              className="pointer-events-auto box-border *:my-1.5"
            >
              <ToasterItem
                toast={toast}
                onDismiss={() => toastStore.remove(toast.id)}
              />
            </motion.div>
          );
        })}
      </AnimatePresence>
    </div>
  );
};

type ToasterItemProps = {
  toast: Toast;
  onDismiss: () => void;
};

const ToasterItem = ({ toast, onDismiss }: ToasterItemProps) => {
  const { title, description, variant = 'default' } = toast;

  const Icon = {
    default: null,
    positive: CheckCircleIcon,
    negative: XCircleIcon,
  }[variant];

  return (
    <motion.div
      className={cn(
        'relative flex max-w-88 items-center gap-2 rounded-lg border p-3 pl-4 shadow-lg',
        variant === 'default' && 'border-border bg-background',
        variant === 'positive' && 'border-green-200 bg-green-50 text-green-900',
        variant === 'negative' && 'border-red-200 bg-red-50 text-red-900'
      )}
      role="status"
      aria-live="polite"
      initial={{ opacity: 0, scale: 0.95 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0.8, y: '-100%' }}
      transition={{ type: 'spring', bounce: 0.2, duration: 0.5 }}
    >
      {Icon && <Icon weight="fill" className="size-5" />}
      <div className={cn('pr-6 text-sm')}>
        <p className="font-medium">{title}</p>
        {description && (
          <p className="mt-0.5 text-pretty text-xs opacity-70">{description}</p>
        )}
      </div>
      <ToastCloseButton onClick={onDismiss} />
    </motion.div>
  );
};

const ToastCloseButton = ({
  onClick,
}: Omit<ComponentPropsWithoutRef<'button'>, 'children' | 'type'>) => {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label="Dismiss notification"
      className={cn(
        'relative mb-auto flex size-6 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm border-inherit bg-inherit',
        'ring-ring focus-visible:outline-none focus-visible:ring-4',
        'after:absolute after:inset-0 after:bg-[currentColor] after:opacity-0 hover:after:opacity-4 active:after:opacity-8'
      )}
    >
      <XIcon className="size-3 opacity-50" />
    </button>
  );
};

export { Toaster, toast };

This component relies on custom events emitted by the Modal component. Ensure your Modal implementation dispatches the ui:modal-open and ui:modal-close events accordingly.

Usage

Render the Toaster component at the root of your application.


          import { Toaster } from "@/components/ui/toaster";

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

Then, you can use the toast function to display messages.


          import { toast } from "@/components/ui/toaster";

toast({ title: "This is a toast message!" });
        

Example

With Description

import { Button } from '@/components/button';
import { toast } from '@/components/toaster';

const ToasterDescriptionPreview = () => {
  return (
    <div className="flex flex-col gap-4">
      <Button
        type="button"
        size="sm"
        variant="outline"
        onClick={() => {
          toast({
            title: 'This is a toast notification!',
            description:
              'A lightweight notification that appears temporarily to provide feedback about an action or event.',
          });
        }}
      >
        Show Toast
      </Button>
    </div>
  );
};

export default ToasterDescriptionPreview;

Variants

import { Button } from '@/components/button';
import { toast } from '@/components/toaster';

const ToasterVariantsPreview = () => {
  return (
    <div className="flex gap-2">
      <Button
        type="button"
        size="sm"
        variant="outline"
        onClick={() =>
          toast({ title: 'This is a toast notification!', variant: 'default' })
        }
      >
        Default
      </Button>
      <Button
        type="button"
        size="sm"
        variant="outline"
        onClick={() =>
          toast({
            title: 'This is a positive toast notification!',
            variant: 'positive',
          })
        }
      >
        Positive
      </Button>
      <Button
        type="button"
        size="sm"
        variant="outline"
        onClick={() =>
          toast({
            title: 'This is a negative toast notification!',
            variant: 'negative',
          })
        }
      >
        Negative
      </Button>
    </div>
  );
};

export default ToasterVariantsPreview;

Duration

import { Button } from '@/components/button';
import { toast } from '@/components/toaster';

const ToasterDurationPreview = () => {
  return (
    <div className="flex gap-2">
      <Button
        type="button"
        size="sm"
        variant="outline"
        onClick={() => toast({ title: 'This is a 7 second toast!' })}
      >
        Default
      </Button>
      <Button
        type="button"
        size="sm"
        variant="outline"
        onClick={() =>
          toast({ title: 'This is a 3 second toast!', duration: 3000 })
        }
      >
        Short
      </Button>
      <Button
        type="button"
        size="sm"
        variant="outline"
        onClick={() =>
          toast({
            title: 'This is an infinity toast!',
            description: 'It will not disappear until you dismiss it.',
            duration: Infinity,
          })
        }
      >
        Infinite
      </Button>
    </div>
  );
};

export default ToasterDurationPreview;

Previous

Textarea

Next

Tooltip