Toast
A short message that confirms what just happened and leaves on its own. Built on Sonner: it queues, times, stacks, opens on hover and swipes away; the panel is Trelund.
import { Button } from '@/components/button'
import { toast } from '@/components/toast'
export default function Example() {
return (
<Button outline onClick={() => toast.success('Successfully saved!', { description: 'Anyone with a link can now view this file.' })}>
Save
</Button>
)
}<script setup lang="ts">
import Button from '@/components/button.vue'
import { toast } from '@/components/toast'
</script>
<template>
<Button outline @click="toast.success('Successfully saved!', { description: 'Anyone with a link can now view this file.' })">
Save
</Button>
</template>Dependencies
Add Sonner, then mount one Toaster at the end of your root layout. Every call to `toast` renders there.
npm install sonnerpnpm add sonneryarn add sonnerbun add sonnernpm install vue-sonnerpnpm add vue-sonneryarn add vue-sonnerbun add vue-sonnerimport { Toaster } from '@/components/toast'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
<Toaster />
</body>
</html>
)
}<script setup lang="ts">
import Toaster from '@/components/toaster.vue'
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<Toaster />
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/24/outline'
import { XMarkIcon } from '@heroicons/react/20/solid'
import clsx from 'clsx'
import type React from 'react'
import { Toaster as Sonner, toast, type ExternalToast, type ToasterProps } from 'sonner'
import { NotificationContent, type NotificationProps } from './notification'
import { Spinner } from './spinner'
export { toast }
export function notify({ onClose, ...props }: NotificationProps, options?: ExternalToast) {
return toast.custom(
(id) => (
<NotificationContent
{...props}
onClose={() => {
onClose?.()
toast.dismiss(id)
}}
/>
),
{
...options,
classNames: { ...options?.classNames, toast: clsx('block! overflow-hidden p-0!', options?.classNames?.toast) },
}
)
}
const classNames = {
toast:
'group/toast pointer-events-auto flex w-full items-start gap-3 rounded-lg bg-white p-4 shadow-lg ring-1 ring-zinc-950/10 outline-none focus-visible:shadow-lg! focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:bg-zinc-800 dark:ring-white/10 dark:ring-inset [&[data-expanded=false][data-front=false]>*]:opacity-0',
icon: 'relative flex size-6 shrink-0 items-center justify-center',
content: 'min-w-0 flex-1',
title: 'text-sm/6 text-zinc-950 dark:text-white font-medium',
description: 'mt-1 text-sm/6 text-zinc-500 dark:text-zinc-400',
actionButton:
'shrink-0 cursor-pointer text-sm/6 font-medium text-accent-600 hover:text-accent-700 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:text-accent-400 dark:hover:text-accent-300',
cancelButton:
'shrink-0 cursor-pointer text-sm/6 font-medium text-zinc-700 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:text-zinc-300 dark:hover:text-white',
// Sonner renders the close button first: order-last moves it to the end of the row
closeButton:
'order-last ml-1 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-zinc-400 hover:text-zinc-500 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:hover:text-white',
}
const icons = {
success: <CheckCircleIcon aria-hidden="true" className="size-6 text-green-500" />,
info: <InformationCircleIcon aria-hidden="true" className="size-6 text-blue-500" />,
warning: <ExclamationTriangleIcon aria-hidden="true" className="size-6 text-amber-500" />,
error: <XCircleIcon aria-hidden="true" className="size-6 text-red-500" />,
loading: (
<span className="flex size-6 items-center justify-center text-zinc-400">
<Spinner />
</span>
),
close: <XMarkIcon aria-hidden="true" className="size-5" />,
}
export function Toaster({ className, style, toastOptions, ...props }: ToasterProps) {
return (
<Sonner
theme="light"
position="top-right"
closeButton
duration={5000}
gap={16}
offset={24}
mobileOffset={{ top: 24, right: 16, bottom: 24, left: 16 }}
containerAriaLabel="Notifications"
icons={icons}
{...props}
className={clsx(className, 'font-sans!')}
style={{ '--width': '24rem', ...style } as React.CSSProperties}
toastOptions={{
unstyled: true,
...toastOptions,
classNames: { ...classNames, ...toastOptions?.classNames },
}}
/>
)
}Also copy Notification, Spinner.
import { defineComponent, h, markRaw, type VNodeChild } from 'vue'
import { toast, type ExternalToast } from 'vue-sonner'
import NotificationContent, { type NotificationType, type SplitAction } from './notification-content.vue'
export { toast }
type Slot = () => VNodeChild
export type NotifyOptions = {
type?: NotificationType
title: string
description?: string
splitActions?: SplitAction[]
onClose?: () => void
icon?: Slot
media?: Slot
action?: Slot
actions?: Slot
}
export function notify({ onClose, icon, media, action, actions, ...props }: NotifyOptions, options?: ExternalToast) {
let id: string | number
const slots = Object.fromEntries(Object.entries({ icon, media, action, actions }).filter(([, slot]) => slot))
const panel = markRaw(
defineComponent(
() => () =>
h(
NotificationContent,
{
...props,
onClose: () => {
onClose?.()
toast.dismiss(id)
},
},
slots
)
)
)
id = toast.custom(panel, {
...options,
classes: {
...options?.classes,
toast: ['block! overflow-hidden p-0!', options?.classes?.toast].filter(Boolean).join(' '),
},
})
return id
}Also copy Notification, Spinner.
Component API
| Prop | Default | Description |
|---|---|---|
Toasterrenders a <sonner> element | ||
position | 'top-right' | Where the toasts stack. Any corner, or `top-center` and `bottom-center`. |
expand | false | Toasts stack behind the newest and the stack opens on hover. `true` lists them all the time. |
closeButton | true | The cross on every toast. |
duration | 5000 | Milliseconds before a toast leaves on its own. Paused while the pointer is over the stack. |
visibleToasts | 3 | How many show at once; the rest wait their turn. |
gap | 16 | Pixels between toasts. |
offset | 24 | Pixels from the edge of the viewport. `mobileOffset` under 600px. |
| toast(message, options) | ||
toast.success / info / warning / error | - | Same call with the icon of that type. `toast()` alone has no icon. |
toast.promise(promise, { loading, success, error }) | - | One toast that follows the promise. `success` and `error` can be functions of the result. |
toast.dismiss(id?) | - | Closes that toast, or every toast without an id. |
toast.loading(message) | - | A spinner that stays until you `toast.dismiss(id)` or reuse the `id` with another call. |
| options | ||
description | - | The second line, in zinc. |
action | - | `{ label, onClick }`, an accent button on the right. Or any element. |
cancel | - | `{ label, onClick }`, a plain button before the action. |
duration | - | Overrides the Toaster for this toast. `Infinity` keeps it until dismissed. |
id | - | Reuse it to update a toast in place instead of adding one. |
dismissible | true | `false` removes the cross and the swipe. |
icon | - | Replaces the icon of the type. |
onDismiss, onAutoClose | - | Called with the toast when the user closes it, or when the timer does. |
| Prop | Default | Description |
|---|---|---|
Toasterrenders a <vue-sonner> element | ||
position | 'top-right' | Where the toasts stack. Any corner, or `top-center` and `bottom-center`. |
expand | false | Toasts stack behind the newest and the stack opens on hover. `true` lists them all the time. |
closeButton | true | The cross on every toast. |
duration | 5000 | Milliseconds before a toast leaves on its own. Paused while the pointer is over the stack. |
visibleToasts | 3 | How many show at once; the rest wait their turn. |
gap | 16 | Pixels between toasts. |
offset | 24 | Pixels from the edge of the viewport. `mobileOffset` under 600px. |
| toast(message, options) | ||
toast.success / info / warning / error | - | Same call with the icon of that type. `toast()` alone has no icon. |
toast.promise(promise, { loading, success, error }) | - | One toast that follows the promise. `success` and `error` can be functions of the result. |
toast.dismiss(id?) | - | Closes that toast, or every toast without an id. |
toast.loading(message) | - | A spinner that stays until you `toast.dismiss(id)` or reuse the `id` with another call. |
| options | ||
description | - | The second line, in zinc. |
action | - | `{ label, onClick }`, an accent button on the right. |
cancel | - | `{ label, onClick }`, a plain button before the action. |
duration | - | Overrides the Toaster for this toast. `Infinity` keeps it until dismissed. |
id | - | Reuse it to update a toast in place instead of adding one. |
dismissible | true | `false` removes the cross and the swipe. |
icon | - | A component that replaces the icon of the type. |
onDismiss, onAutoClose | - | Called with the toast when the user closes it, or when the timer does. |
Examples
Basic example
A title and, when it helps, a description. The toast stays five seconds, longer while the pointer is over it, and the cross closes it at any time:
import { Button } from '@/components/button'
import { toast } from '@/components/toast'
export default function Example() {
return (
<Button outline onClick={() => toast.success('Successfully saved!', { description: 'Anyone with a link can now view this file.' })}>
Save
</Button>
)
}<script setup lang="ts">
import Button from '@/components/button.vue'
import { toast } from '@/components/toast'
</script>
<template>
<Button outline @click="toast.success('Successfully saved!', { description: 'Anyone with a link can now view this file.' })">
Save
</Button>
</template>Types
The type sets the icon: green check, blue info, amber triangle, red cross. toast() alone shows none:
toast('Discussion archived')
toast.success('Invoice sent', { description: 'Mariam Keita will get it by email.' })
toast.info('New version available', { description: 'Reload to get the latest changes.' })
toast.warning('Storage almost full', { description: '92% of your plan is used.' })
toast.error('Payment failed', { description: 'The card was declined. Try another one.' })With an action
One verb on the right, in the accent color. The toast closes when it is clicked. cancel adds a plain button before it for the way out that does nothing:
toast('Discussion archived', {
action: { label: 'Undo', onClick: () => restore() },
})Following a promise
One toast for the whole request: a spinner while it runs, then the success or the error message in its place:
toast.promise(save(), {
loading: 'Saving…',
success: (report) => `${report.name} saved`,
error: 'Could not save',
})Until dismissed
duration: Infinity keeps the toast until the user acts. Keep the id to close it from the action:
let id = toast.warning('Your session expires in 5 minutes', {
description: 'Save your work or extend the session.',
duration: Infinity,
action: { label: 'Extend', onClick: () => toast.dismiss(id) },
})const id = toast.warning('Your session expires in 5 minutes', {
description: 'Save your work or extend the session.',
duration: Infinity,
action: { label: 'Extend', onClick: () => toast.dismiss(id) },
})A richer panel
For an avatar, buttons below or split buttons, notify() puts a Notification in the same stack, with the same timer and swipe. See the Notification page for the six layouts:
import { notify, toast } from '@/components/toast'
let id = notify({
media: <Avatar src={from.avatar} className="size-10" />,
title: from.name,
description: 'Sent you an invite to connect.',
actions: (
<>
<Button onClick={() => accept(id)}>Accept</Button>
<Button outline onClick={() => toast.dismiss(id)}>Decline</Button>
</>
),
})import { notify, toast } from '@/components/toast'
import { h } from 'vue'
const id = notify({
media: () => h(Avatar, { src: from.avatar, class: 'size-10' }),
title: from.name,
description: 'Sent you an invite to connect.',
actions: () => [
h(Button, { onClick: () => accept(id) }, () => 'Accept'),
h(Button, { outline: true, onClick: () => toast.dismiss(id) }, () => 'Decline'),
],
})