Toast
A toast provider built on Base UI.
registry/new-york-v4/ui/toast.tsx
"use client"
import type * as React from "react"
import { Toast } from "@base-ui/react/toast"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { IconPlaceholder } from "@/components/icon-placeholder"
type SwipeDirection = "up" | "down" | "left" | "right"
type ToastData = {
rootProps?: Omit<
React.ComponentProps<typeof Toast.Root>,
"children" | "className" | "swipeDirection" | "toast"
>
tooltipStyle?: boolean
}
function ToastIcon({ type }: { type?: string }) {
const className = cn(
"size-4 shrink-0",
type === "loading" && "animate-spin opacity-80",
type === "error" && "text-destructive",
type === "info" && "text-info",
type === "success" && "text-success",
type === "warning" && "text-warning"
)
switch (type) {
case "error":
return (
<IconPlaceholder
lucide="CircleAlertIcon"
tabler="IconAlertCircle"
hugeicons="Alert01Icon"
phosphor="WarningCircleIcon"
remixicon="RiErrorWarningLine"
className={className}
/>
)
case "info":
return (
<IconPlaceholder
lucide="InfoIcon"
tabler="IconInfoCircle"
hugeicons="InformationCircleIcon"
phosphor="InfoIcon"
remixicon="RiInformationLine"
className={className}
/>
)
case "loading":
return (
<IconPlaceholder
lucide="LoaderCircleIcon"
tabler="IconLoader2"
hugeicons="Loading03Icon"
phosphor="SpinnerGapIcon"
remixicon="RiLoaderLine"
className={className}
/>
)
case "success":
return (
<IconPlaceholder
lucide="CircleCheckIcon"
tabler="IconCircleCheck"
hugeicons="CheckmarkCircle01Icon"
phosphor="CheckCircleIcon"
remixicon="RiCheckboxCircleLine"
className={className}
/>
)
case "warning":
return (
<IconPlaceholder
lucide="TriangleAlertIcon"
tabler="IconAlertTriangle"
hugeicons="Alert01Icon"
phosphor="WarningIcon"
remixicon="RiAlertLine"
className={className}
/>
)
default:
return null
}
}
function ToastClose() {
return (
<Toast.Close
aria-label="Dismiss"
className={buttonVariants({
className: "-my-1 -mr-1 ml-1",
size: "icon-xs",
variant: "ghost",
})}
>
<IconPlaceholder
lucide="XIcon"
tabler="IconX"
hugeicons="Cancel01Icon"
phosphor="XIcon"
remixicon="RiCloseLine"
/>
</Toast.Close>
)
}
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 upsertReplayClassName(toast: {
type?: string
updateKey?: number
}): string | undefined {
const key = toast.updateKey ?? 0
if (key <= 0) return undefined
const isEven = key % 2 === 0
if (toast.type === "error") {
return isEven ? "animate-toast-error-even" : "animate-toast-error-odd"
}
return isEven ? "animate-toast-success-even" : "animate-toast-success-odd"
}
function Toasts({
position,
portalProps,
viewportProps,
}: {
position: ToastPosition
portalProps?: React.ComponentProps<typeof Toast.Portal>
viewportProps?: React.ComponentProps<typeof Toast.Viewport>
}) {
const { toasts } = Toast.useToastManager()
const swipeDirection = getSwipeDirection(position)
return (
<Toast.Portal data-slot="toast-portal" {...portalProps}>
<Toast.Viewport
{...viewportProps}
className={cn(
"fixed z-60 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--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",
viewportProps?.className
)}
data-position={position}
data-slot="toast-viewport"
>
{toasts.map((toast) => {
const toastData = toast.data as ToastData | undefined
return (
<Toast.Root
key={toast.id}
className={cn(
"absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full rounded-lg border bg-[color-mix(in_srgb,var(--popover),var(--color-black)_calc(1%*max(0,var(--toast-index,0))))] text-popover-foreground shadow-lg/5 select-none [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s,background-color_.5s] not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-expanded:bg-popover dark:bg-[color-mix(in_srgb,var(--popover),var(--color-black)_calc(6%*max(0,var(--toast-index,0))))] dark:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:data-expanded:bg-popover",
"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-[50%_calc(50%-50%*min(var(--toast-index,0),1))]",
"data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-[50%_calc(50%+50%*min(var(--toast-index,0),1))]",
"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",
"[--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))]",
"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))]",
"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))]",
"data-limited:opacity-0",
"data-expanded:h-(--toast-height)",
"data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
"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",
"data-[position*=top]:data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(-100%-var(--toast-inset)))]",
"data-[position*=bottom]: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)))]",
"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)))]",
upsertReplayClassName(toast)
)}
{...toastData?.rootProps}
data-position={position}
swipeDirection={swipeDirection}
toast={toast}
>
<Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 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 min-w-0 gap-2">
<ToastIcon type={toast.type} />
<div className="flex min-w-0 flex-col gap-0.5">
<Toast.Title
className="font-medium"
data-slot="toast-title"
/>
<Toast.Description
className="text-muted-foreground"
data-slot="toast-description"
/>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{toast.actionProps ? (
<Toast.Action
className={buttonVariants({ size: "xs" })}
data-slot="toast-action"
>
{toast.actionProps.children}
</Toast.Action>
) : null}
<ToastClose />
</div>
</Toast.Content>
</Toast.Root>
)
})}
</Toast.Viewport>
</Toast.Portal>
)
}
function AnchoredToasts({
portalProps,
}: {
portalProps?: React.ComponentProps<typeof Toast.Portal>
}) {
const { toasts } = Toast.useToastManager()
return (
<Toast.Portal data-slot="toast-portal-anchored" {...portalProps}>
<Toast.Viewport
className="outline-none"
data-slot="toast-viewport-anchored"
>
{toasts.map((toast) => {
const toastData = toast.data as ToastData | undefined
const tooltipStyle = toastData?.tooltipStyle ?? false
const positionerProps = toast.positionerProps
if (!positionerProps?.anchor) return null
return (
<Toast.Positioner
key={toast.id}
className="z-50 max-w-[min(--spacing(64),var(--available-width))]"
data-slot="toast-positioner"
sideOffset={positionerProps.sideOffset ?? 4}
toast={toast}
>
<Toast.Root
className={cn(
"relative border bg-popover text-xs text-balance text-popover-foreground transition-[scale,opacity] not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-ending-style:opacity-0 data-starting-style:scale-98 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
tooltipStyle
? "rounded-md shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]"
: "rounded-lg shadow-lg/5 before:rounded-[calc(var(--radius-lg)-1px)]",
upsertReplayClassName(toast)
)}
{...toastData?.rootProps}
data-slot="toast-popup"
toast={toast}
>
{tooltipStyle ? (
<Toast.Content className="pointer-events-auto px-2 py-1">
<Toast.Title data-slot="toast-title" />
</Toast.Content>
) : (
<Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm">
<div className="flex min-w-0 gap-2">
<ToastIcon type={toast.type} />
<div className="flex min-w-0 flex-col gap-0.5">
<Toast.Title
className="font-medium"
data-slot="toast-title"
/>
<Toast.Description
className="text-muted-foreground"
data-slot="toast-description"
/>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{toast.actionProps ? (
<Toast.Action
className={buttonVariants({ size: "xs" })}
data-slot="toast-action"
>
{toast.actionProps.children}
</Toast.Action>
) : null}
<ToastClose />
</div>
</Toast.Content>
)}
</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
portalProps?: React.ComponentProps<typeof Toast.Portal>
viewportProps?: React.ComponentProps<typeof Toast.Viewport>
}
export function ToastProvider({
children,
position = "bottom-right",
portalProps,
viewportProps,
toastManager: manager = toastManager,
...props
}: ToastProviderProps) {
return (
<Toast.Provider toastManager={manager} {...props}>
{children}
<Toasts
portalProps={portalProps}
position={position}
viewportProps={viewportProps}
/>
</Toast.Provider>
)
}
export interface AnchoredToastProviderProps extends Toast.Provider.Props {
portalProps?: React.ComponentProps<typeof Toast.Portal>
}
export function AnchoredToastProvider({
children,
portalProps,
toastManager: manager = anchoredToastManager,
...props
}: AnchoredToastProviderProps) {
return (
<Toast.Provider toastManager={manager} {...props}>
{children}
<AnchoredToasts portalProps={portalProps} />
</Toast.Provider>
)
}
export { Toast as ToastPrimitive }
No selection tags yet — empty means no evidence, not lack of support.
DependenciesBundle weight · light
- ui-primitives
- @base-ui/react@^1.4.1