Skip to content
  1. Feedback
  2. Toast

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 sonner
pnpm add sonner
yarn add sonner
bun add sonner
npm install vue-sonner
pnpm add vue-sonner
yarn add vue-sonner
bun add vue-sonner
import { 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

PropDefaultDescription
Toasterrenders a <sonner> element
position'top-right'Where the toasts stack. Any corner, or `top-center` and `bottom-center`.
expandfalseToasts stack behind the newest and the stack opens on hover. `true` lists them all the time.
closeButtontrueThe cross on every toast.
duration5000Milliseconds before a toast leaves on its own. Paused while the pointer is over the stack.
visibleToasts3How many show at once; the rest wait their turn.
gap16Pixels between toasts.
offset24Pixels 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.
dismissibletrue`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.
PropDefaultDescription
Toasterrenders a <vue-sonner> element
position'top-right'Where the toasts stack. Any corner, or `top-center` and `bottom-center`.
expandfalseToasts stack behind the newest and the stack opens on hover. `true` lists them all the time.
closeButtontrueThe cross on every toast.
duration5000Milliseconds before a toast leaves on its own. Paused while the pointer is over the stack.
visibleToasts3How many show at once; the rest wait their turn.
gap16Pixels between toasts.
offset24Pixels 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.
dismissibletrue`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'),
  ],
})