Components

Toast

A temporary notification that appears on screen to inform users.

Loading...

Installation

CLI

npx shadcn@latest add @exabase/toast

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...

Persistent

Set timeout: 0 to keep a toast on screen until the user dismisses it.

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