Kosorikōsori alpha

Toast

A succinct message that is displayed temporarily.

Installation

Install the primitive

Install the @radix-ui/react-toast package.

npm install @radix-ui/react-toast

Copy-paste the component

Copy and paste the component code in a .tsx file.

'use client';
 
import type { VariantProps } from 'tailwind-variants';
import { forwardRef, useEffect, useState } from 'react';
import { Cross2Icon } from '@radix-ui/react-icons';
import {
  Action,
  Close,
  Description,
  Provider,
  Root,
  Title,
  Viewport,
} from '@radix-ui/react-toast';
import { clsx } from 'clsx/lite';
import { tv } from 'tailwind-variants';
 
const toastStyles = tv({
  slots: {
    base: clsx(
      'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-xl border p-4 pr-8 shadow-lg transition-all',
      'data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
    ),
    action: clsx(
      'inline-flex h-8 shrink-0 items-center justify-center rounded-lg border border-grey-border bg-grey-base px-3 text-xs font-semibold outline-none transition-colors duration-200',
      'hover:border-grey-border-hover hover:bg-grey-bg-subtle',
      'focus-visible:ring-4 focus-visible:ring-grey-focus-ring',
      'disabled:cursor-not-allowed disabled:border-grey-line disabled:bg-grey-base disabled:text-grey-solid',
      'group-[.info]:border-info-border group-[.info]:bg-info-base',
      'group-[.info]:hover:border-info-border-hover group-[.info]:hover:bg-info-bg-subtle',
      'group-[.info]:focus-visible:ring-info-focus-ring',
      'group-[.success]:border-success-border group-[.success]:bg-success-base',
      'group-[.success]:hover:border-success-border-hover group-[.success]:hover:bg-success-bg-subtle',
      'group-[.success]:focus-visible:ring-success-focus-ring',
      'group-[.warning]:border-warning-border group-[.warning]:bg-warning-base',
      'group-[.warning]:hover:border-warning-border-hover group-[.warning]:hover:bg-warning-bg-subtle',
      'group-[.warning]:focus-visible:ring-warning-focus-ring',
      'group-[.error]:border-error-border group-[.error]:bg-error-base',
      'group-[.error]:hover:border-error-border-hover group-[.error]:hover:bg-error-bg-subtle',
      'group-[.error]:focus-visible:ring-error-focus-ring',
    ),
    close: clsx(
      'absolute right-1 top-1 rounded-md p-1 text-grey-text opacity-0 outline-none transition duration-200',
      'hover:text-grey-text-contrast',
      'focus-visible:opacity-100 focus-visible:ring-4 focus-visible:ring-grey-focus-ring',
      'group-hover:opacity-100',
      'group-[.info]:text-info-focus-ring',
      'group-[.info]:hover:text-info-solid',
      'group-[.info]:focus-visible:ring-info-focusRing',
      'group-[.success]:text-success-focus-ring',
      'group-[.success]:hover:text-success-solid',
      'group-[.success]:focus-visible:ring-success-focus-ring',
      'group-[.warning]:text-warning-focus-ring',
      'group-[.warning]:hover:text-warning-solid',
      'group-[.warning]:focus-visible:ring-warning-focus-ring',
      'group-[.error]:text-error-focus-ring',
      'group-[.error]:hover:text-error-solid',
      'group-[.error]:focus:ring-error-focus-ring',
    ),
    description: 'text-sm text-grey-text',
    title: clsx('text-sm font-semibold', '[&+div]:text-xs'),
    viewport: clsx(
      'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4',
      'sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col',
      'md:max-w-md',
    ),
    toasterHeader: 'grid w-full gap-1',
  },
  variants: {
    intent: {
      default: { base: 'border-grey-line bg-grey-base' },
      info: { base: 'info border-info-line bg-info-base text-info-solid' },
      success: {
        base: 'success border-success-line bg-success-base text-success-solid',
      },
      warning: {
        base: 'warning border-warning-line bg-warning-base text-warning-solid',
      },
      error: { base: 'error border-error-line bg-error-base text-error-solid' },
    },
  },
  defaultVariants: {
    intent: 'default',
  },
});
 
const { base, action, close, description, title, viewport, toasterHeader } =
  toastStyles();
 
type ToastRef = React.ElementRef<typeof Root>;
type ToastRadixProps = React.ComponentPropsWithoutRef<typeof Root>;
type ToastVariants = VariantProps<typeof toastStyles>;
export type ToastProps = object & ToastRadixProps & ToastVariants;
 
/**
 * Toast component that displays a toast notification.
 *
 * @param {ToastProps} props - The props for the Toast component.
 * @param {string} [intent='default'] - The visual style of the toast (e.g., 'default', 'info', 'success', 'warning', 'error').
 *
 * @example
 * toast({
 *   title: 'Scheduled: Catch up',
 *   description: 'Friday, February 10, 2023 at 5:57 PM',
 * });
 *
 * @see {@link https://dub.sh/ui-toast Toast Docs} for further information.
 */
export const Toast = forwardRef<ToastRef, ToastProps>(
  ({ intent, className, ...props }, ref) => (
    <Root ref={ref} className={base({ className, intent })} {...props} />
  ),
);
 
Toast.displayName = Root.displayName;
 
type ToastActionElement = React.ReactElement<typeof Action>;
type ToastActionRef = React.ElementRef<typeof Action>;
type ToastActionProps = React.ComponentProps<typeof Action>;
 
/**
 * ToastAction component that represents an action button within the toast.
 *
 * @param {ToastActionProps} props - The props for the ToastAction component.
 *
 * @example
 * <ToastAction onClick={handleClick}>Undo</ToastAction>
 */
export const ToastAction = forwardRef<ToastActionRef, ToastActionProps>(
  ({ className, ...props }, ref) => (
    <Action ref={ref} className={action({ className })} {...props} />
  ),
);
 
ToastAction.displayName = Action.displayName;
 
type ToastCloseRef = React.ElementRef<typeof Close>;
type ToastCloseProps = React.ComponentPropsWithoutRef<typeof Close>;
 
/**
 * ToastClose component that allows the user to close the toast.
 *
 * @param {ToastCloseProps} props - The props for the ToastClose component.
 *
 * @example
 * <ToastClose />
 */
export const ToastClose = forwardRef<ToastCloseRef, ToastCloseProps>(
  ({ className, ...props }, ref) => (
    <Close ref={ref} className={close({ className })} {...props}>
      <Cross2Icon className='h-4 w-4' />
    </Close>
  ),
);
 
ToastClose.displayName = Close.displayName;
 
type ToastDescriptionRef = React.ElementRef<typeof Description>;
type ToastDescriptionProps = React.ComponentPropsWithoutRef<typeof Description>;
 
/**
 * ToastDescription component that provides additional information about the toast.
 *
 * @param {ToastDescriptionProps} props - The props for the ToastDescription component.
 *
 * @example
 * <ToastDescription>Details about the toast notification.</ToastDescription>
 */
export const ToastDescription = forwardRef<
  ToastDescriptionRef,
  ToastDescriptionProps
>(({ className, ...props }, ref) => (
  <Description ref={ref} className={description({ className })} {...props} />
));
 
ToastDescription.displayName = Description.displayName;
 
/**
 * ToastProvider component that provides context for managing toasts.
 *
 * @param {React.ComponentProps<typeof Provider>} props - The props for the ToastProvider component.
 *
 * @example
 * <ToastProvider>{Toasts will be rendered here}</ToastProvider>
 */
export const ToastProvider = Provider;
 
type ToastTitleRef = React.ElementRef<typeof Title>;
type ToastTitleProps = React.ComponentPropsWithoutRef<typeof Title>;
 
/**
 * ToastTitle component that displays the title of the toast.
 *
 * @param {ToastTitleProps} props - The props for the ToastTitle component.
 *
 * @example
 * <ToastTitle>Notification Title</ToastTitle>
 */
export const ToastTitle = forwardRef<ToastTitleRef, ToastTitleProps>(
  ({ className, ...props }, ref) => (
    <Title ref={ref} className={title({ className })} {...props} />
  ),
);
 
ToastTitle.displayName = Title.displayName;
 
type ToastViewportRef = React.ElementRef<typeof Viewport>;
type ToastViewportProps = React.ComponentPropsWithoutRef<typeof Viewport>;
 
/**
 * ToastViewport component that defines the area where toasts are displayed.
 *
 * @param {ToastViewportProps} props - The props for the ToastViewport component.
 *
 * @example
 * <ToastViewport />
 */
export const ToastViewport = forwardRef<ToastViewportRef, ToastViewportProps>(
  ({ className, ...props }, ref) => (
    <Viewport ref={ref} className={viewport({ className })} {...props} />
  ),
);
 
ToastViewport.displayName = Viewport.displayName;
 
/**
 * Toaster component that renders toast notifications.
 *
 * @example
 * <Toaster />
 */
export const Toaster = () => {
  const { toasts } = useToast();
 
  return (
    <ToastProvider>
      {toasts.map(function ({ id, title, description, action, ...props }) {
        return (
          <Toast key={id} {...props}>
            <div className={toasterHeader()}>
              {title && <ToastTitle>{title}</ToastTitle>}
              {description && (
                <ToastDescription>{description}</ToastDescription>
              )}
            </div>
            {action}
            <ToastClose />
          </Toast>
        );
      })}
      <ToastViewport />
    </ToastProvider>
  );
};
 
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
 
type ToasterToast = ToastProps & {
  id: string;
  title?: React.ReactNode;
  description?: React.ReactNode;
  action?: ToastActionElement;
};
 
const _ACTION_TYPES = {
  ADD_TOAST: 'ADD_TOAST',
  UPDATE_TOAST: 'UPDATE_TOAST',
  DISMISS_TOAST: 'DISMISS_TOAST',
  REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
 
let count = 0;
 
const genId = () => {
  count = (count + 1) % Number.MAX_VALUE;
  return count.toString();
};
 
type ActionType = typeof _ACTION_TYPES;
 
type Action =
  | {
      type: ActionType['ADD_TOAST'];
      toast: ToasterToast;
    }
  | {
      type: ActionType['UPDATE_TOAST'];
      toast: Partial<ToasterToast>;
    }
  | {
      type: ActionType['DISMISS_TOAST'];
      toastId?: ToasterToast['id'];
    }
  | {
      type: ActionType['REMOVE_TOAST'];
      toastId?: ToasterToast['id'];
    };
 
type State = {
  toasts: ToasterToast[];
};
 
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
 
const addToRemoveQueue = (toastId: string) => {
  if (toastTimeouts.has(toastId)) {
    return;
  }
 
  const timeout = setTimeout(() => {
    toastTimeouts.delete(toastId);
    dispatch({
      type: 'REMOVE_TOAST',
      toastId: toastId,
    });
  }, TOAST_REMOVE_DELAY);
 
  toastTimeouts.set(toastId, timeout);
};
 
export const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'ADD_TOAST':
      return {
        ...state,
        toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
      };
 
    case 'UPDATE_TOAST':
      return {
        ...state,
        toasts: state.toasts.map((t) =>
          t.id === action.toast.id ? { ...t, ...action.toast } : t,
        ),
      };
 
    case 'DISMISS_TOAST': {
      const { toastId } = action;
 
      // ! Side effects ! - This could be extracted into a dismissToast() action,
      // but I'll keep it here for simplicity
      if (toastId) {
        addToRemoveQueue(toastId);
      } else {
        state.toasts.forEach((toast) => {
          addToRemoveQueue(toast.id);
        });
      }
 
      return {
        ...state,
        toasts: state.toasts.map((t) =>
          t.id === toastId || toastId === undefined
            ? {
                ...t,
                open: false,
              }
            : t,
        ),
      };
    }
    case 'REMOVE_TOAST':
      if (action.toastId === undefined) {
        return {
          ...state,
          toasts: [],
        };
      }
      return {
        ...state,
        toasts: state.toasts.filter((t) => t.id !== action.toastId),
      };
  }
};
 
const listeners: ((state: State) => void)[] = [];
 
let memoryState: State = { toasts: [] };
 
const dispatch = (action: Action) => {
  memoryState = reducer(memoryState, action);
  listeners.forEach((listener) => {
    listener(memoryState);
  });
};
 
type Toast = Omit<ToasterToast, 'id'>;
 
/**
 * toast function that triggers a toast notification.
 *
 * @param {Toast} props - The properties for the toast notification, including title and description.
 *
 * @example
 * const { toast } = useToast();
 * toast({
 *   title: 'Scheduled: Catch up',
 *   description: 'Friday, February 10, 2023 at 5:57 PM',
 * });
 */
export const toast = ({ ...props }: Toast) => {
  const id = genId();
 
  const update = (props: ToasterToast) =>
    dispatch({
      type: 'UPDATE_TOAST',
      toast: { ...props, id },
    });
  const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
 
  dispatch({
    type: 'ADD_TOAST',
    toast: {
      ...props,
      id,
      open: true,
      onOpenChange: (open) => {
        if (!open) dismiss();
      },
    },
  });
 
  return {
    id: id,
    dismiss,
    update,
  };
};
 
/**
 * useToast hook that provides access to the current toast state and methods to manage toasts.
 *
 * @example
 * const { toast } = useToast();
 * toast({
 *   title: 'New message!',
 *   description: 'You have received a new message.',
 * });
 */
export const useToast = () => {
  const [state, setState] = useState<State>(memoryState);
 
  useEffect(() => {
    listeners.push(setState);
    return () => {
      const index = listeners.indexOf(setState);
      if (index > -1) {
        listeners.splice(index, 1);
      }
    };
  }, [state]);
 
  return {
    ...state,
    toast,
    dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
  };
};

Update import paths

Update the @kosori/ui import paths to fit your project structure, for example, using ~/components/ui.

Add Toaster component

Add the <Toaster /> component to the root of your app.

app/layout.tsx
import { Toaster } from '~/components/ui/toast'; 
 
export default const RootLayout = ({ children }) => {
  return (
    <html lang='en'>
      <head />
      <body>
        <main>{children}</main>
        <Toaster />
      </body>
    </html>
  );
}

Usage

import { useToast } from '~/components/ui/toast';
export const ToastDemo = () => {
  const { toast } = useToast(); 
 
  return (
    <Button
      onClick={() => {

        toast({
          title: 'Scheduled: Catch up',
          description: 'Friday, February 10, 2023 at 5:57 PM',
        });
      }}
    >
      Show Toast
    </Button>
  );
};

To display multiple toasts at the same time, you can update the TOAST_LIMIT in toast.tsx.

Reference

PropTypeDefault
intent
enum
default

Examples

Intents

Simple

With Title

With Action

On this page