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.jsonManual
Install the following dependencies:
npm install @base-ui/reactCopy 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.