Toast
A shadcn-style toast component built with Ark UI primitives.
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const ToastBasicDemo = () => (
<Button
onClick={() =>
toaster.create({
title: "Event has been created",
description: "Monday, January 3rd at 6:00pm",
type: "info",
})
}
size="sm"
type="button"
variant="outline"
>
Default Toast
</Button>
);
export default ToastBasicDemo;
Installation
npx shadcn@latest add @ark-cn/toastInstall the dependency required by this primitive:
npm install @ark-ui/react lucide-reactCopy the component source into your app:
TSXcomponents/ui/toast.tsx
"use client";
import { Portal } from "@ark-ui/react/portal";
import {
createToaster,
Toaster,
Toast as ToastPrimitive,
} from "@ark-ui/react/toast";
import {
CircleAlertIcon,
CircleCheckIcon,
InfoIcon,
LoaderIcon,
TriangleAlertIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
export type ToastProps = ToastPrimitive.RootProps;
export type ToastTitleProps = ToastPrimitive.TitleProps;
export type ToastDescriptionProps = ToastPrimitive.DescriptionProps;
export type ToastActionTriggerProps = ToastPrimitive.ActionTriggerProps;
export type ToastCloseTriggerProps = ToastPrimitive.CloseTriggerProps;
export type ToastProviderProps = Omit<
ComponentProps<typeof Toaster>,
"toaster" | "children"
>;
export type AnchoredToastProviderProps = Omit<
ComponentProps<typeof Toaster>,
"toaster" | "children"
>;
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
success: CircleCheckIcon,
error: CircleAlertIcon,
warning: TriangleAlertIcon,
info: InfoIcon,
loading: LoaderIcon,
};
const ICON_COLOR_MAP: Record<string, string> = {
success: "text-success-foreground",
error: "text-destructive",
warning: "text-warning-foreground",
info: "text-info-foreground",
loading: "text-muted-foreground",
};
export const ToastRoot = ({ className, ...props }: ToastProps) => (
<ToastPrimitive.Root
className={cn(
"group pointer-events-auto relative flex w-full flex-col gap-1 overflow-hidden rounded-lg border border-border/80 bg-popover p-4 pe-10 text-popover-foreground shadow-lg",
"data-[placement=top]:mx-auto data-[placement=bottom]:mx-auto",
"data-[placement=top]:w-fit data-[placement=bottom]:w-fit",
"data-[placement=top]:max-w-[min(26rem,calc(100vw-2rem))] data-[placement=bottom]:max-w-[min(26rem,calc(100vw-2rem))]",
"will-change-transform",
"data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:duration-300 data-[state=open]:ease-out",
"data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:duration-250 data-[state=closed]:ease-in",
"data-[placement^=top]:data-[state=open]:slide-in-from-top-2 data-[placement^=bottom]:data-[state=open]:slide-in-from-bottom-2",
"data-[placement^=top]:data-[state=closed]:slide-out-to-top-2 data-[placement^=bottom]:data-[state=closed]:slide-out-to-bottom-2",
className,
)}
data-slot="toast"
{...props}
/>
);
export const ToastTitle = ({ className, ...props }: ToastTitleProps) => (
<ToastPrimitive.Title
className={cn("flex items-center gap-2 font-semibold text-sm", className)}
data-slot="toast-title"
{...props}
/>
);
export const ToastDescription = ({
className,
...props
}: ToastDescriptionProps) => (
<ToastPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
data-slot="toast-description"
{...props}
/>
);
export const ToastActionTrigger = ({
className,
...props
}: ToastActionTriggerProps) => (
<ToastPrimitive.ActionTrigger
className={cn(
"mt-1 inline-flex h-7 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border/80 bg-transparent px-3 font-medium text-xs transition-colors hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 disabled:pointer-events-none disabled:opacity-50",
className,
)}
data-slot="toast-action"
{...props}
/>
);
export const ToastCloseTrigger = ({
className,
...props
}: ToastCloseTriggerProps) => (
<ToastPrimitive.CloseTrigger
className={cn(
"absolute top-2 inset-e-2 cursor-pointer rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring group-hover:opacity-100",
className,
)}
data-slot="toast-close"
{...props}
>
<XIcon className="size-4" />
</ToastPrimitive.CloseTrigger>
);
export const ToastIndicator = ({
type,
className,
}: {
type?: string;
className?: string;
}) => {
if (!type || !(type in ICON_MAP)) return null;
const Icon = ICON_MAP[type];
return (
<Icon
className={cn(
"size-4 shrink-0",
ICON_COLOR_MAP[type] ?? "text-muted-foreground",
type === "loading" && "animate-spin",
className,
)}
/>
);
};
export const toaster = createToaster({
placement: "bottom-end",
overlap: true,
gap: 16,
max: 5,
});
type ToasterProps = Omit<
ComponentProps<typeof Toaster>,
"toaster" | "children"
>;
export const ToastProvider = ({ className, ...props }: ToasterProps) => (
<Portal>
<Toaster
toaster={toaster}
className={cn(
"fixed! z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:inset-e-0 sm:bottom-0 sm:flex-col md:max-w-105",
className,
)}
data-slot="toast-provider"
{...props}
>
{(toast) => (
<ToastRoot key={toast.id}>
<ToastTitle>
<ToastIndicator type={toast.type} />
{toast.title}
</ToastTitle>
{toast.description && (
<ToastDescription>{toast.description}</ToastDescription>
)}
{toast.action && (
<ToastActionTrigger>{toast.action.label}</ToastActionTrigger>
)}
<ToastCloseTrigger />
</ToastRoot>
)}
</Toaster>
</Portal>
);
export const ToastContext = ToastPrimitive.Context;
export { createToaster, Toaster } from "@ark-ui/react/toast";
Add the following CSS to your stylesheet (e.g. styles.css):
@theme inline {
--color-destructive-foreground: var(--destructive-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
:root {
--destructive-foreground: var(--color-red-400);
--info: var(--color-blue-500);
--info-foreground: var(--color-blue-700);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-700);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-700);
}
.dark {
--destructive-foreground: var(--color-red-400);
--info: var(--color-blue-500);
--info-foreground: var(--color-blue-400);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-400);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-400);
}
@layer base {
[data-scope="toast"][data-part="root"] {
translate: var(--x) var(--y);
scale: var(--scale);
z-index: var(--z-index);
height: var(--height);
opacity: var(--opacity);
will-change: translate, opacity, scale;
transition: translate 400ms, scale 400ms, opacity 400ms, height 400ms, box-shadow 200ms;
transition-timing-function: cubic-bezier(0.21, 1.02, 0.73, 1);
}
[data-scope="toast"][data-part="root"][data-state="closed"] {
transition: translate 400ms, scale 400ms, opacity 200ms;
transition-timing-function: cubic-bezier(0.06, 0.71, 0.55, 1);
}
}Update import aliases to match your project setup.
Usage
import * as Toast from "@/components/ui/toast"Read exported parts in src/components/ui/toast.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Action
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const ToastActionDemo = () => (
<Button
onClick={() =>
toaster.create({
title: "Event has been created",
description: "We have sent you an email with the event details.",
type: "info",
action: {
label: "Undo",
onClick: () => {
toaster.create({
title: "Action undone",
description: "The action has been reverted.",
type: "info",
});
},
},
})
}
size="sm"
type="button"
variant="outline"
>
With Action
</Button>
);
export default ToastActionDemo;
Basic
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const ToastBasicDemo = () => (
<Button
onClick={() =>
toaster.create({
title: "Event has been created",
description: "Monday, January 3rd at 6:00pm",
type: "info",
})
}
size="sm"
type="button"
variant="outline"
>
Default Toast
</Button>
);
export default ToastBasicDemo;
Loading
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const ToastLoadingDemo = () => (
<Button
onClick={() =>
toaster.create({
title: "Loading\u2026",
description: "Please wait while we process your request.",
type: "loading",
})
}
size="sm"
type="button"
variant="outline"
>
Loading Toast
</Button>
);
export default ToastLoadingDemo;
Types
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const ToastTypesDemo = () => (
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toaster.success({
title: "Success!",
description: "Your changes have been saved.",
})
}
size="sm"
type="button"
variant="outline"
>
Success
</Button>
<Button
onClick={() =>
toaster.error({
title: "Uh oh! Something went wrong.",
description: "There was a problem with your request.",
})
}
size="sm"
type="button"
variant="outline"
>
Error
</Button>
<Button
onClick={() =>
toaster.warning({
title: "Warning!",
description: "Your session is about to expire.",
})
}
size="sm"
type="button"
variant="outline"
>
Warning
</Button>
<Button
onClick={() =>
toaster.info({
title: "Heads up!",
description: "You can add components to your app using the cli.",
})
}
size="sm"
type="button"
variant="outline"
>
Info
</Button>
</div>
);
export default ToastTypesDemo;
Update
import { useRef } from "react";
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const ToastUpdateDemo = () => {
const idRef = useRef<string>(undefined);
return (
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
idRef.current = toaster.create({
title: "Sending message\u2026",
description: "Please wait while we deliver your message.",
type: "loading",
});
}}
size="sm"
type="button"
variant="outline"
>
Send message
</Button>
<Button
onClick={() => {
if (!idRef.current) return;
toaster.update(idRef.current, {
title: "Message sent",
description: "Your message has been delivered successfully.",
type: "success",
});
}}
size="sm"
type="button"
variant="outline"
>
Mark as sent
</Button>
</div>
);
};
export default ToastUpdateDemo;
Promise
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const ToastPromiseDemo = () => {
const handleUpload = () => {
const upload = () =>
new Promise<void>((resolve, reject) => {
setTimeout(() => {
Math.random() > 0.3 ? resolve() : reject(new Error("Upload failed"));
}, 2000);
});
toaster.promise(upload, {
loading: {
title: "Uploading file...",
description: "Please wait while we upload your document.",
},
success: {
title: "Upload complete",
description: "Your file has been uploaded successfully.",
},
error: {
title: "Upload failed",
description: "Could not upload the file. Please try again.",
},
});
};
return (
<Button onClick={handleUpload} size="sm" type="button" variant="outline">
Run Promise
</Button>
);
};
export default ToastPromiseDemo;
Duration
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const TOAST_DURATIONS = [
{ label: "1s", value: 1000 },
{ label: "3s", value: 3000 },
{ label: "5s", value: 5000 },
{ label: "Infinity", value: Infinity },
];
const ToastDurationDemo = () => (
<div className="flex flex-wrap gap-2">
{TOAST_DURATIONS.map((duration) => (
<Button
key={duration.label}
onClick={() =>
toaster.create({
title: "Reminder set",
description:
duration.value === Infinity
? "This notification will stay until dismissed."
: `This notification will disappear in ${duration.label}.`,
type: "info",
duration: duration.value,
})
}
size="sm"
type="button"
variant="outline"
>
{duration.label}
</Button>
))}
</div>
);
export default ToastDurationDemo;
Placement
import { Portal } from "@ark-ui/react/portal";
import { Button } from "@/components/ui/button";
import {
createToaster,
ToastCloseTrigger,
ToastDescription,
Toaster,
ToastRoot,
ToastTitle,
} from "@/components/ui/toast";
import { cn } from "@/lib/utils";
const PLACEMENTS = [
{ label: "Top start", value: "top-start", providerClass: "top-0 left-0" },
{ label: "Top end", value: "top-end", providerClass: "top-0 right-0" },
{
label: "Bottom start",
value: "bottom-start",
providerClass: "bottom-0 left-0",
},
{
label: "Bottom end",
value: "bottom-end",
providerClass: "bottom-0 right-0",
},
] as const;
type Placement = (typeof PLACEMENTS)[number]["value"];
const placementToasters: Record<
Placement,
ReturnType<typeof createToaster>
> = Object.fromEntries(
PLACEMENTS.map((placement) => [
placement.value,
createToaster({
placement: placement.value,
overlap: true,
gap: 16,
max: 3,
}),
]),
) as Record<Placement, ReturnType<typeof createToaster>>;
const ToastPlacementDemo = () => (
<>
<div className="flex flex-wrap gap-2">
{PLACEMENTS.map((placement) => (
<Button
key={placement.value}
onClick={() =>
placementToasters[placement.value].create({
title: `Toast at ${placement.label}`,
description: `Placement is set to "${placement.value}".`,
type: "info",
})
}
size="sm"
type="button"
variant="outline"
>
{placement.label}
</Button>
))}
</div>
<Portal>
{PLACEMENTS.map((placement) => (
<Toaster
key={placement.value}
toaster={placementToasters[placement.value]}
className={cn(
"fixed! z-100 flex max-h-screen p-4",
"w-full md:max-w-105",
placement.value.startsWith("top") ? "flex-col" : "flex-col-reverse",
placement.providerClass,
)}
data-slot={`toast-provider-${placement.value}`}
>
{(toast) => (
<ToastRoot key={toast.id}>
<ToastTitle>{toast.title}</ToastTitle>
{toast.description && (
<ToastDescription>{toast.description}</ToastDescription>
)}
<ToastCloseTrigger />
</ToastRoot>
)}
</Toaster>
))}
</Portal>
</>
);
export default ToastPlacementDemo;
Varying heights
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const TOAST_DESCRIPTIONS = [
"Your changes have been saved.",
"File uploaded successfully. You can view it in your documents folder.",
"Your meeting has been scheduled for tomorrow at 10:00 AM. We have sent a calendar invite to all participants.",
"We noticed unusual activity on your account. For your security, please verify your identity by clicking the link sent to your email.",
];
const ToastVaryingHeightDemo = () => {
const [count, setCount] = useState(0);
return (
<Button
onClick={() => {
setCount((prevCount) => prevCount + 1);
toaster.create({
title: `Notification ${count + 1}`,
description:
TOAST_DESCRIPTIONS[
Math.floor(Math.random() * TOAST_DESCRIPTIONS.length)
],
type: "info",
});
}}
size="sm"
type="button"
variant="outline"
>
Varying Heights
</Button>
);
};
export default ToastVaryingHeightDemo;
Stacked toasts
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toaster } from "@/components/ui/toast";
const ToastStackedDemo = () => (
<Button
onClick={() => {
["Deployment queued", "Build started", "Build completed"].forEach(
(title) => {
toaster.create({
title,
description: "Monday, January 3rd at 6:00pm",
});
},
);
}}
size="sm"
type="button"
variant="outline"
>
<BellIcon className="size-4" />
Push 3 toasts
</Button>
);
export default ToastStackedDemo;
API reference
This component mirrors the upstream Ark UI primitive. All props and DOM behavior are defined by Ark unless you see an ark-cn-only row below.
ToastIndicator
| Prop | Type | Description |
|---|---|---|
| type? | string | Selects icon and color (success, error, warning, info, loading, etc.). |
See the ARK UI documentation for the full API.