- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Installation
$ pnpm dlx shadcn@latest add @cobalt/sonner
Install theme-cobalt for the exact card, status and countdown colours.
The countdown bar runs on a keyframe, so add it to your global CSS. The registry adds it for you; this is the rule it inserts:
@keyframes sonner-countdown {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}Usage
Render <Toaster /> once near the root of the app and call toast from this
file, not from sonner, so the card, its status icons and the countdown bar
are drawn. Sonner still owns the stack, the timers, the hover pause and the
swipe.
import { Toaster, toast } from "@/registry/cobalt/ui/sonner";
export function Layout({ children }) {
return (
<>
{children}
<Toaster />
</>
);
}
toast("Draft saved", { description: "Your notes are up to date." });
toast.success("Changes published");
toast.error("Could not publish", { description: "Try again." });
toast.warning("Review required");
const id = toast.loading("Preparing export");
toast.update(id, { title: "Export ready", status: "success", duration: 5000 });
toast.promise(saveReport(), {
loading: "Saving",
success: "Saved",
error: "Could not save",
});
toast("Delete this page?", {
actions: [{ label: "Keep" }, { label: "Delete" }],
});Options
| Option | Use |
|---|---|
description | A second line under the title. |
status | neutral, info, success, warning, error or loading. Picks the icon and its colours. |
icon | A component or node in place of the status icon. |
actions | Small buttons under the message. Pressing one also dismisses the toast. |
duration | Milliseconds before it closes. Default 5000; Infinity keeps it until closed. |
dismissible | Show the close button. Default true. |
onDismiss | Called once the toast has left the screen. |
id | Reuse an id to replace a toast that is still showing. |
toasterId | Draw only in the matching <Toaster id>, for a scoped preview or a second stack. |
toast.success, toast.error, toast.info, toast.warning and toast.loading set the status for you. Loading stays open until updated or dismissed. toast.promise(promise, { loading, success, error }) updates a loading toast when the promise settles; it returns { id, unwrap } for dismissal or access to the original result.
toast.update(id, options) changes a toast that is showing, and
toast.dismiss(id) closes one, or all of them without an id.
Motion
A toast rises 12px, scales from 97% and clears 4px of blur in 250ms. It leaves
by dropping 8px, scaling to 96% and blurring 3px in 180ms, while the older
cards close the gap. With prefers-reduced-motion: reduce the transitions are
off and the countdown bar holds at full width.
Source
"use client";
import {
RiNotification3Fill,
RiErrorWarningFill,
RiCheckboxCircleFill,
RiInformationFill,
RiLoader2Line,
RiAlertFill,
} from "@remixicon/react";
import { cn } from "cn";
import { useTheme } from "next-themes";
import * as React from "react";
import { Toaster as Sonner, toast as sonnerToast } from "sonner";
import type { ExternalToast, ToastT, ToasterProps } from "sonner";
import { Button } from "@/components/ui/button";
/**
* Toast — fire-and-forget notifications drawn by Sonner:
*
* toast("Changes saved");
* toast.error("Upload failed", { description: "The file is over 20MB." });
* const id = toast("Exporting…", { duration: Infinity });
* toast.update(id, { title: "Export ready", status: "success", duration: 4000 });
* toast.dismiss(id);
*
* Render one <Toaster /> near the root of the app. Sonner owns the stack,
* the timers, the hover pause and the swipe, so the cards keep the same
* height accounting and the same polite live region. The card, its status
* icons, the countdown bar and the enter and exit motion are ours.
*/
type ToastId = number | string;
type ToastStatus =
| "neutral"
| "info"
| "success"
| "warning"
| "error"
| "loading";
type ToastIcon = React.ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
interface ToastAction {
label: React.ReactNode;
onClick?: () => void;
variant?: React.ComponentProps<typeof Button>["variant"];
}
interface ToastOptions {
/** Reuse an id to replace a toast that is still showing. */
id?: ToastId;
description?: React.ReactNode;
status?: ToastStatus;
/** Custom leading icon; defaults to the status icon. */
icon?: ToastIcon | React.ReactNode;
/** Small buttons under the message. Pressing one also dismisses the toast. */
actions?: ToastAction[];
/** Milliseconds before it dismisses itself; `Infinity` keeps it until closed. Default 5000. */
duration?: number;
/** Show the close button. Default true. */
dismissible?: boolean;
/** Called once the toast has left the screen. */
onDismiss?: () => void;
/** Draw only in the matching <Toaster id>. */
toasterId?: string;
}
const DEFAULT_DURATION = 5000;
const STATUS_TYPE: Record<
ToastStatus,
"success" | "info" | "warning" | "error" | "loading" | undefined
> = {
neutral: undefined,
info: "info",
success: "success",
error: "error",
warning: "warning",
loading: "loading",
};
const TYPE_STATUS: Record<string, ToastStatus> = {
info: "info",
success: "success",
error: "error",
warning: "warning",
loading: "loading",
};
/*
* The card is Sonner's own element, so the motion has to be expressed on its
* state attributes. Resting 12px low at 97% and 4px of blur becomes 0 in
* 250ms; the exit drops 8px, scales to 96% and blurs 3px in 180ms, which
* Sonner's 200ms unmount window covers. The height transition closes the gap
* the leaving card leaves behind and runs a little longer.
*/
const TOAST_CLASS = [
// A title on its own is centred against the icon; with a description the
// text starts at the top edge of the icon, as in the source.
"cn-toast relative flex w-full flex-wrap items-center gap-3 overflow-hidden has-data-[description]:items-start",
"rounded-[16px] border border-border p-4 pe-11",
"bg-popover text-sm text-popover-foreground",
"shadow-[shadow:var(--shadow-dropdown,0_1px_1px_0_rgb(0_0_0/0.04),0_4px_4px_0_rgb(0_0_0/0.02))]",
"[transition:transform_250ms_ease-out,opacity_250ms_ease-out,filter_250ms_ease-out,scale_250ms_ease-out,height_300ms_ease-out]!",
"data-[mounted=false]:[--y:translateY(12px)]!",
"data-[mounted=false]:scale-97",
"data-[mounted=false]:blur-[4px]",
"data-[removed=true]:[--y:translateY(calc(var(--lift)_*_var(--offset)_+_8px))]!",
"data-[removed=true]:opacity-0!",
"data-[removed=true]:scale-96",
"data-[removed=true]:blur-[3px]",
"data-[removed=true]:[transition:transform_180ms_ease-out,opacity_180ms_ease-out,filter_180ms_ease-out,scale_180ms_ease-out,height_200ms_ease-out]!",
// Sonner moves the card with the pointer while it is being dragged.
"data-[swiping=true]:[transition:none]!",
// Repeated attribute so this outranks the state transitions above instead
// of depending on source order.
"motion-reduce:[&[data-sonner-toast][data-sonner-toast]]:[transition:none]!",
].join(" ");
const TITLE_CLASS = [
"text-sm leading-5 font-medium text-foreground",
// The countdown bar: full width along the bottom edge, shrinking from the
// start edge. `--toast-duration` is set per toast, so a toast that never
// dismisses gets a bar that is already empty.
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-[3px]",
"after:origin-left after:bg-[var(--notification-countdown,var(--primary))] after:content-['']",
"after:animate-[sonner-countdown_var(--toast-duration,0ms)_linear_forwards]",
"group-hover:after:[animation-play-state:paused]",
"rtl:after:origin-right",
"motion-reduce:after:animate-none",
].join(" ");
const CLOSE_CLASS = [
"absolute end-3 top-3 z-10 flex size-5 shrink-0 cursor-pointer items-center",
// Sonner restyles its own close button in dark mode with unlayered CSS, so
// the surface and the colours have to win on importance.
"justify-center rounded-full border-0 bg-[var(--surface-strong,var(--muted))]!",
"text-muted-foreground! outline-none",
"transition-colors! duration-150 ease hover:text-foreground!",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"focus-visible:ring-offset-popover motion-reduce:transition-none",
].join(" ");
const TOAST_CLASSNAMES = {
toast: TOAST_CLASS,
// `relative` centres Sonner's loading spinner inside the circle.
icon: "relative flex size-10 shrink-0 items-center justify-center rounded-full bg-[var(--surface-strong,var(--muted))] text-muted-foreground [&>svg]:size-5",
info: "[&_[data-icon]]:bg-[var(--notification-information-background,var(--muted))] [&_[data-icon]]:text-[color:var(--notification-information-foreground,var(--primary))]",
success:
"[&_[data-icon]]:bg-[var(--notification-success-background,var(--muted))] [&_[data-icon]]:text-[color:var(--notification-success-foreground,var(--primary))]",
error:
"[&_[data-icon]]:bg-[var(--notification-error-background,var(--muted))] [&_[data-icon]]:text-[color:var(--notification-error-foreground,var(--destructive))]",
warning: "[&_[data-icon]]:bg-muted [&_[data-icon]]:text-foreground",
content: "flex min-w-0 flex-1 flex-col gap-1",
title: TITLE_CLASS,
description: "text-sm leading-5 text-muted-foreground!",
closeButton: CLOSE_CLASS,
};
let nextToastId = 1;
function renderIcon(icon: ToastIcon | React.ReactNode): React.ReactNode {
if (typeof icon === "function") {
const Icon = icon;
return <Icon aria-hidden className="size-5" />;
}
return icon;
}
function ToastActions({
actions,
id,
}: {
actions: ToastAction[];
id: ToastId;
}) {
return (
// Full width so it wraps under the message, with the card's 12px gap
// pulled back to the 6px the actions row uses.
<div
className="-mt-1.5 flex w-full flex-wrap items-center gap-2"
onPointerDown={(event) => event.stopPropagation()}
>
{actions.map((action, index) => (
<Button
key={index}
size="sm"
variant={action.variant ?? (index === 0 ? "secondary" : "default")}
onClick={() => {
action.onClick?.();
sonnerToast.dismiss(id);
}}
>
{action.label}
</Button>
))}
</div>
);
}
function show(
type: "success" | "info" | "warning" | "error" | "loading" | undefined,
title: React.ReactNode,
options: ToastOptions
): ToastId {
nextToastId += 1;
const id = options.id ?? `sonner-toast-${nextToastId}`;
// A duration that is not a positive number keeps the toast until it is
// closed, as in the source. The bar and the timer have to run for the same
// time, so the duration is always explicit rather than left to the
// toaster's default.
const duration =
options.duration === undefined
? type === "loading"
? Number.POSITIVE_INFINITY
: DEFAULT_DURATION
: options.duration > 0
? options.duration
: Number.POSITIVE_INFINITY;
const payload: ExternalToast = {
id,
duration,
style: {
"--toast-duration": Number.isFinite(duration) ? `${duration}ms` : "0ms",
} as React.CSSProperties,
};
if (options.description !== undefined) {
payload.description = options.description;
}
if (options.dismissible !== undefined) {
payload.dismissible = options.dismissible;
payload.closeButton = options.dismissible;
}
if (options.onDismiss) {
payload.onDismiss = () => options.onDismiss?.();
}
if (options.toasterId !== undefined) {
payload.toasterId = options.toasterId;
}
if (options.icon !== undefined) {
payload.icon = renderIcon(options.icon);
} else if (!type) {
payload.icon = <RiNotification3Fill aria-hidden className="size-5" />;
}
if (options.actions && options.actions.length > 0) {
payload.action = <ToastActions actions={options.actions} id={id} />;
}
if (type === "success") {
sonnerToast.success(title, payload);
} else if (type === "info") {
sonnerToast.info(title, payload);
} else if (type === "warning") {
sonnerToast.warning(title, payload);
} else if (type === "loading") {
sonnerToast.loading(title, payload);
} else if (type === "error") {
sonnerToast.error(title, payload);
} else {
sonnerToast(title, payload);
}
return id;
}
function toast(title: React.ReactNode, options: ToastOptions = {}): ToastId {
return show(STATUS_TYPE[options.status ?? "neutral"], title, options);
}
toast.success = (
title: React.ReactNode,
options?: Omit<ToastOptions, "status">
) => show("success", title, options ?? {});
toast.error = (
title: React.ReactNode,
options?: Omit<ToastOptions, "status">
) => show("error", title, options ?? {});
toast.info = (title: React.ReactNode, options?: Omit<ToastOptions, "status">) =>
show("info", title, options ?? {});
toast.warning = (
title: React.ReactNode,
options?: Omit<ToastOptions, "status">
) => show("warning", title, options ?? {});
toast.loading = (
title: React.ReactNode,
options?: Omit<ToastOptions, "status">
) => show("loading", title, options ?? {});
toast.promise = <T,>(
promise: Promise<T> | (() => Promise<T>),
options: ToastOptions & {
loading: React.ReactNode;
success: React.ReactNode | ((value: T) => React.ReactNode);
error: React.ReactNode | ((error: unknown) => React.ReactNode);
}
) => {
const { loading, success, error: failure, ...rest } = options;
const id = show("loading", loading, rest);
const result = Promise.resolve().then(() =>
typeof promise === "function" ? promise() : promise
);
const settle = async () => {
let value: T;
try {
value = await result;
} catch (error: unknown) {
show("error", typeof failure === "function" ? failure(error) : failure, {
...rest,
id,
});
return;
}
show("success", typeof success === "function" ? success(value) : success, {
...rest,
id,
});
};
void settle();
return { id, unwrap: () => result };
};
/** Change a toast that is showing. */
toast.update = (
id: ToastId,
options: ToastOptions & { title?: React.ReactNode }
): void => {
const current = sonnerToast
.getToasts()
.find((item): item is ToastT => "title" in item && item.id === id);
if (!current) {
return;
}
const { title, status, icon, ...rest } = options;
const resolved = status ?? TYPE_STATUS[String(current.type)] ?? "neutral";
const currentTitle =
typeof current.title === "function" ? current.title() : current.title;
show(STATUS_TYPE[resolved], title ?? currentTitle, {
...rest,
id,
duration: options.duration ?? current.duration,
icon: icon ?? (status === undefined ? current.icon : undefined),
});
};
/** Dismiss one toast, or every toast when called without an id. */
toast.dismiss = (id?: ToastId) => {
sonnerToast.dismiss(id);
};
function Toaster({
className,
style,
toastOptions,
icons,
theme,
position = "bottom-right",
expand = true,
gap = 12,
visibleToasts = 3,
offset = 24,
mobileOffset = 12,
closeButton = true,
...props
}: ToasterProps) {
const { theme: appTheme = "system" } = useTheme();
return (
<Sonner
theme={(theme ?? appTheme) as ToasterProps["theme"]}
position={position}
expand={expand}
gap={gap}
visibleToasts={visibleToasts}
offset={offset}
mobileOffset={mobileOffset}
closeButton={closeButton}
className={cn("group", className)}
// The source stack is as wide as a 400px card, or the viewport minus
// 12px of gutter on a phone.
style={
{
"--width": "min(400px, calc(100vw - 24px))",
...style,
} as React.CSSProperties
}
icons={{
error: <RiErrorWarningFill aria-hidden className="size-5" />,
info: <RiInformationFill aria-hidden className="size-5" />,
loading: (
<RiLoader2Line
aria-hidden
className="size-5 animate-spin motion-reduce:animate-none"
/>
),
success: <RiCheckboxCircleFill aria-hidden className="size-5" />,
warning: <RiAlertFill aria-hidden className="size-5" />,
// The close glyph is drawn at 10.8px with a 2px round stroke, as in
// the source's 20px close button.
close: (
<svg
aria-hidden
width="10.8"
height="10.8"
viewBox="0 0 10.8 10.8"
fill="none"
>
<path
d="M2 2L8.8 8.8M8.8 2L2 8.8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
),
...icons,
}}
toastOptions={{
unstyled: true,
closeButtonAriaLabel: "Dismiss notification",
...toastOptions,
classNames: {
...TOAST_CLASSNAMES,
...toastOptions?.classNames,
},
}}
{...props}
/>
);
}
export { Toaster, toast };
export type { ToastAction, ToastId, ToastOptions, ToastStatus };