Components

Toast

A temporary notification that appears on screen to inform users.

Loading...

Installation

CLI

npx shadcn@latest add https://exawizards.com/exabase/design/registry/toast.json

Manual

Install the following dependencies:

npm install @base-ui/react

Copy and paste the following code into your project.

// Based on coss ui (https://github.com/cosscom/coss/blob/main/apps/ui/registry/default/ui/toast.tsx)
// Copyright (c) coss.com - MIT License

"use client"

import type React from "react"
import { Toast } from "@base-ui/react/toast"
import {
  CheckmarkCircleFillIcon,
  ExclamationmarkCircleFillIcon,
  ExclamationmarkTriangleFillIcon,
  InformationmarkCircleFillIcon,
  XmarkLargeIcon,
} from "@exawizards/exabase-design-system-icons-react"

import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"

const TOAST_ICONS: Record<
  string,
  React.ComponentType<{ className?: string }>
> = {
  success: CheckmarkCircleFillIcon,
  info: InformationmarkCircleFillIcon,
  warning: ExclamationmarkTriangleFillIcon,
  error: ExclamationmarkCircleFillIcon,
  destructive: ExclamationmarkCircleFillIcon,
}

const TOAST_ICON_CLASSES: Record<string, string> = {
  success: "text-success",
  info: "text-info",
  warning: "text-warning",
  error: "text-destructive",
  destructive: "text-destructive-foreground",
}

const TOAST_TYPE_CLASSES: Record<string, string> = {
  default: "light outline-border",
  dark: "dark outline-border",
  info: "info outline-info-text/10 bg-info-muted",
  success: "success outline-success-text/10 bg-success-muted",
  warning: "warning outline-warning-text/10 bg-warning-muted",
  error: "error outline-destructive-text/10 bg-destructive-muted",
  destructive:
    "destructive outline-destructive bg-destructive text-destructive-foreground",
}

function renderToastIcon(
  customIcon: React.ReactNode | undefined,
  Icon: React.ComponentType<{ className?: string }> | null,
  iconClass: string
): React.ReactNode {
  // Explicit null means no icon
  if (customIcon === null) {
    return null
  }

  // Custom icon provided
  if (customIcon !== undefined) {
    return customIcon
  }

  // Default icon from type
  if (Icon) {
    return (
      <div
        className="[&_svg]:pointer-events-none [&_svg]:shrink-0 [&>svg]:size-5"
        data-slot="toast-icon"
      >
        <Icon className={iconClass} />
      </div>
    )
  }

  return null
}

type SwipeDirection = "up" | "down" | "left" | "right"

function getSwipeDirection(position: ToastPosition): SwipeDirection[] {
  const verticalDirection: SwipeDirection = position.startsWith("top")
    ? "up"
    : "down"

  if (position.includes("center")) {
    return [verticalDirection]
  }

  if (position.includes("left")) {
    return ["left", verticalDirection]
  }

  return ["right", verticalDirection]
}

function Toasts({ position }: { position: ToastPosition }): React.ReactElement {
  const { toasts } = Toast.useToastManager()
  const swipeDirection = getSwipeDirection(position)

  return (
    <Toast.Portal data-slot="toast-portal">
      <Toast.Viewport
        className={cn(
          "fixed z-100 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-[420px] [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
          "data-[position*=top]:top-(--toast-inset)",
          "data-[position*=bottom]:bottom-(--toast-inset)",
          "data-[position*=left]:left-(--toast-inset)",
          "data-[position*=right]:right-(--toast-inset)",
          "data-[position*=center]:left-1/2 data-[position*=center]:-translate-x-1/2"
        )}
        data-position={position}
        data-slot="toast-viewport"
      >
        {toasts.map((toast) => {
          const toastType = (toast.type as string) ?? "default"
          const Icon = TOAST_ICONS[toastType] ?? null
          const iconClass = TOAST_ICON_CLASSES[toastType] ?? ""
          const typeClass = TOAST_TYPE_CLASSES[toastType] ?? ""
          const customIcon = (toast.data as { icon?: React.ReactNode })?.icon

          return (
            <Toast.Root
              className={cn(
                "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full rounded-xl bg-background text-foreground shadow-lg outline [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s,background-color_.5s]",
                // Base positioning
                "data-[position*=right]:right-0 data-[position*=right]:left-auto",
                "data-[position*=left]:right-auto data-[position*=left]:left-0",
                "data-[position*=center]:right-0 data-[position*=center]:left-0",
                "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top",
                "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom",
                // Gap fill for hover
                "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
                "data-[position*=top]:after:top-full",
                "data-[position*=bottom]:after:bottom-full",
                // Variables
                "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]",
                // Offset-y
                "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
                "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]",
                // Default transform
                "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
                "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
                // Limited state
                "data-limited:opacity-0",
                // Expanded state
                "data-expanded:h-(--toast-height)",
                "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
                // Starting and ending animations
                "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
                "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]",
                "data-ending-style:opacity-0",
                // Ending (direction-aware)
                "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]",
                "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
                "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
                "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
                "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
                // Ending (expanded)
                "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
                "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
                "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
                "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
                typeClass
              )}
              data-position={position}
              key={toast.id}
              swipeDirection={swipeDirection}
              toast={toast}
            >
              <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-4 py-3 text-sm transition-opacity duration-250 data-behind:opacity-0 data-behind:not-data-expanded:pointer-events-none data-expanded:opacity-100">
                <div className="flex items-center gap-3">
                  {renderToastIcon(customIcon, Icon, iconClass)}

                  <div className="flex flex-col gap-0.5">
                    <Toast.Title
                      className="font-medium"
                      data-slot="toast-title"
                    />
                    <Toast.Description
                      className="opacity-80"
                      data-slot="toast-description"
                    />
                  </div>
                </div>
                <div className="flex items-center gap-1.5">
                  {toast.actionProps && (
                    <Toast.Action
                      className={buttonVariants({
                        variant: "outline",
                        size: "sm",
                      })}
                      data-slot="toast-action"
                    >
                      {toast.actionProps.children}
                    </Toast.Action>
                  )}
                  <Toast.Close
                    data-slot="toast-close"
                    render={
                      <Button
                        variant="ghost"
                        size="icon-sm"
                        className={
                          toastType === "destructive" ? "dark" : undefined
                        }
                      />
                    }
                  >
                    <XmarkLargeIcon />
                    <span className="sr-only">Close</span>
                  </Toast.Close>
                </div>
              </Toast.Content>
            </Toast.Root>
          )
        })}
      </Toast.Viewport>
    </Toast.Portal>
  )
}

function AnchoredToasts(): React.ReactElement {
  const { toasts } = Toast.useToastManager()

  return (
    <Toast.Portal data-slot="toast-portal-anchored">
      <Toast.Viewport
        data-slot="toast-viewport-anchored"
        className="outline-none"
      >
        {toasts.map((toast) => {
          const positionerProps = toast.positionerProps

          if (!positionerProps?.anchor) {
            return null
          }

          return (
            <Toast.Positioner
              data-slot="toast-positioner"
              key={toast.id}
              sideOffset={positionerProps.sideOffset ?? 4}
              alignOffset={positionerProps.alignOffset ?? 0}
              collisionPadding={positionerProps.collisionPadding ?? 4}
              toast={toast}
              className="isolate z-50"
            >
              <Toast.Root
                data-slot="toast-popup"
                className={cn(
                  "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-2 py-1.5 text-xs text-background shadow-md data-ending-style:animate-out data-ending-style:fade-out-0 data-ending-style:zoom-out-95 data-starting-style:animate-in data-starting-style:fade-in-0 data-starting-style:zoom-in-95 forced-colors:outline"
                )}
                toast={toast}
              >
                <Toast.Content
                  data-slot="toast-content"
                  className="flex items-center gap-1.5"
                >
                  <Toast.Title data-slot="toast-title" />
                </Toast.Content>
                <Toast.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5 forced-colors:hidden" />
              </Toast.Root>
            </Toast.Positioner>
          )
        })}
      </Toast.Viewport>
    </Toast.Portal>
  )
}

export const toastManager: ReturnType<typeof Toast.createToastManager> =
  Toast.createToastManager()
export const anchoredToastManager: ReturnType<typeof Toast.createToastManager> =
  Toast.createToastManager()

export type ToastPosition =
  | "top-left"
  | "top-center"
  | "top-right"
  | "bottom-left"
  | "bottom-center"
  | "bottom-right"

export interface ToastProviderProps extends Toast.Provider.Props {
  position?: ToastPosition
}

export function ToastProvider({
  children,
  position = "bottom-right",
  ...props
}: ToastProviderProps): React.ReactElement {
  return (
    <Toast.Provider toastManager={toastManager} {...props}>
      {children}
      <Toasts position={position} />
    </Toast.Provider>
  )
}

export function AnchoredToastProvider({
  children,
  ...props
}: Toast.Provider.Props): React.ReactElement {
  return (
    <Toast.Provider toastManager={anchoredToastManager} {...props}>
      {children}
      <AnchoredToasts />
    </Toast.Provider>
  )
}

export { Toast as ToastPrimitive }

Then, add the ToastProvider and AnchoredToastProvider to your app layout:

import { AnchoredToastProvider, ToastProvider } from "@/components/ui/toast"

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head />
      <body>
        <ToastProvider>
          <AnchoredToastProvider>
            <main>{children}</main>
          </AnchoredToastProvider>
        </ToastProvider>
      </body>
    </html>
  )
}

Usage

import { toastManager } from "@/components/ui/toast"
toastManager.add({
  title: "Event has been created",
  description: "Monday, January 3rd at 6:00pm",
})

By default, toasts appear in the bottom-right corner. You can change this by setting the position prop on the ToastProvider.

<ToastProvider position="top-center">{children}</ToastProvider>

Anchored Toasts

For toasts positioned relative to a specific element, use anchoredToastManager:

import { anchoredToastManager } from "@/components/ui/toast"
anchoredToastManager.add({
  title: "Copied!",
  positionerProps: {
    anchor: buttonRef.current,
  }
})

Examples

Simple

Loading...

With Description

Loading...

With Action

Loading...

With Icon

Loading...

Type

Loading...

Position

Loading...

Anchored

Loading...

API

See the Base UI documentation for more information.

On this page