Toast

A succinct message that is displayed temporarily.

Setup

Add NToaster once, in your app.vue:

app.vue
<template>
  <div>
    <NuxtPage />

    <NToaster />
  </div>
</template>

Examples

Basic

useToast() returns vue-sonner's toast. The message is the first argument; everything else is options.

Preview
Code

Types

Each type renders its own coloured icon on a neutral card.

Preview
Code

Actions

OptionTypeDescription
actionNToastActionA single button.
cancelNToastActionA single dismissing button.
actionsNToastAction[]Two or more buttons. una's own option — vue-sonner allows only one.

Buttons are NButton components, so any NButton prop works — btn, size, leading. They default to outline-gray, and cancel to ghost-gray. A single button sits inline with the text; two or more move to their own row underneath.

Preview
Code

Promise and updating

toast.promise() follows a promise through its states. Any toast can also be updated in place by passing its id to a later call — the usual way to resolve a loading toast.

Preview
Code

Progress

OptionDefaultTypeDescription
showProgressfalsebooleanShow a bar counting down duration.
progress-{color}Colour of the bar.
Preview
Code

Toaster

NToaster accepts every vue-sonner Toaster prop.

PropDefaultTypeDescription
positionbottom-rightPositionCorner the stack sits in.
duration4000numberHow long a toast stays, in milliseconds.
visibleToasts3numberHow many are shown before the rest are stacked.
expandfalsebooleanShow the stack expanded instead of collapsed.
closeButtonfalsebooleanShow a close button on every toast.
richColorsfalsebooleanTint the card per type, using vue-sonner's palette rather than una's tokens.

duration, closeButton and richColors can also be set per toast.

Theming

Colours come from una's theme tokens — the card follows popover, popover-foreground and border, and type icons follow success, error, warning and info. Dark mode is handled by una's colour mode; nothing extra is needed.

Individual parts can be restyled through the una prop:

<NToaster
  :una="{
    toastTitle: 'font-semibold',
    toastDescription: 'text-xs',
  }"
/>

Migrating

- toast({ title: 'Saved', description: 'All good', closable: true })
+ toast('Saved', { description: 'All good' })

- toast({ toast: 'soft-error', title: 'Failed' })
+ toast.error('Failed')

- toast({ title: 'Uploading…', _toastProvider: { duration: 8000 } })
+ toast('Uploading…', { duration: 8000 })
BeforeAfter
title: 'Saved'The message is the first argument — toast('Saved').
toast: 'soft-error' and other variantsA type call — toast.error(), toast.success(), …
closable: truecloseButton: true, and it now defaults to false.
_toastProvider.durationduration — per toast, or on NToaster for all of them.
_toastProvider.swipeDirectionswipeDirections on NToaster.
const { dismiss, update } = toast({ … })Keep the returned id — toast.dismiss(id), toast('New title', { id }).
useToast().toastsGone — NToaster renders the stack itself.
Slots — #title, #description, #actions, …toast.custom(markRaw(MyToast)) renders a component of your own.
NToastProvider / Viewport / Title / Removed — NToaster is the only component left.

actions[], showProgress and leading carry over unchanged.

Presets

shortcuts/toast.ts
type ToastPrefix = 'toast'

/**
 * vue-sonner keeps its own skin for standard toasts, so most of these exist for
 * the rich ones: `toast.custom` sets data-styled="false", which drops the card,
 * the content layout and the button styling.
 */
export const staticToast: Record<`${ToastPrefix}-${string}` | ToastPrefix, string> = {
  // mirrors sonner's card so rich toasts sit flush with the standard ones.
  // Below sonner's own 600px breakpoint it stretches the `li` instead: sonner
  // sizes that to the viewport there, and `--width` would overflow it.
  'toast': 'w-[var(--width)] [@media(max-width:600px)]:w-full overflow-hidden p-4 text-13px bg-popover text-popover-foreground border border-border rounded-[var(--border-radius)] shadow-lg',
  'toast-stack': 'flex flex-col gap-1.5',
  'toast-row': 'flex items-start gap-3',
  'toast-content': 'flex flex-col gap-1 min-w-0 flex-1',
  'toast-actions': 'flex flex-wrap shrink-0 justify-end gap-1.5',
  // una's smallest rectangle button is ~31px, which would drive the card height;
  // these are sonner's 24px/8px metrics
  'toast-action': 'h-6 px-2',
  'toast-progress': 'h-1 -mx-4 -mb-4 mt-1 w-auto rounded-none',
  // no font size: both paths inherit 13px, sonner's own and the card's above
  'toast-title': 'font-medium',
  'toast-description': '!text-muted-foreground',

  // sized both ways — sonner puts this class on a wrapper around the icon span,
  // the rich toast puts it on the span itself
  'toast-icon': 'mt-0.5 shrink-0 square-4 [&>span[icon-base]]:square-4',

  // the card stays neutral, so unlike `alert` the icon carries the type colour
  'toast-success-icon': 'i-lucide-circle-check text-success',
  'toast-error-icon': 'i-lucide-circle-alert text-error',
  'toast-warning-icon': 'i-lucide-triangle-alert text-warning',
  'toast-info-icon': 'i-lucide-info text-info',
  'toast-loading-icon': 'i-loading text-muted-foreground',
  'toast-loading': 'animate-spin',
  'toast-close-icon': 'i-close',
}

export const toast = [
  staticToast,
]

Props

types/toast.ts
import type { HTMLAttributes } from 'vue'
import type { ToasterProps } from 'vue-sonner'
import type { NButtonProps } from './button'
import type { NProgressProps } from './progress'

interface BaseExtensions {
  class?: HTMLAttributes['class']
}

export interface NToasterProps extends Omit<ToasterProps, 'class'>, BaseExtensions {
  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/toast.ts
   */
  una?: NToasterUnaProps
}

export interface NToastAction extends NButtonProps {
  /** Dismiss the toast after `onClick` runs. @default true */
  dismissOnClick?: boolean
  onClick?: () => void
}

/**
 * Options for a rich toast. Passing `actions` or `showProgress` routes the call
 * through `toast.custom`, since vue-sonner supports only one action + one cancel
 * and has no progress element.
 */
export interface NToastProps extends BaseExtensions, Pick<NProgressProps, 'progress'> {
  type?: 'success' | 'error' | 'warning' | 'info' | 'loading'
  title?: string
  description?: string
  /** Leading icon. Defaults to the icon for `type`. */
  leading?: HTMLAttributes['class']
  /** Two or more buttons; vue-sonner's own `action`/`cancel` handle the single case. */
  actions?: NToastAction[]
  /** Render the buttons inline with the text even when there are two. */
  inlineActions?: boolean
  /** Show a bar counting down `duration`. Runs its own timer — see NToast docs. */
  showProgress?: boolean
  duration?: number
  /** Injected by vue-sonner while auto-dismiss is paused. Holds the bar with it. */
  isPaused?: boolean
  una?: NToastUnaProps
}

export interface NToastUnaProps {
  toast?: HTMLAttributes['class']
  toastRow?: HTMLAttributes['class']
  toastIcon?: HTMLAttributes['class']
  toastContent?: HTMLAttributes['class']
  toastTitle?: HTMLAttributes['class']
  toastDescription?: HTMLAttributes['class']
  toastActions?: HTMLAttributes['class']
  toastProgress?: HTMLAttributes['class']
}

export interface NToasterUnaProps {
  toast?: HTMLAttributes['class']
  toastTitle?: HTMLAttributes['class']
  toastDescription?: HTMLAttributes['class']
  toastContent?: HTMLAttributes['class']
  toastIcon?: HTMLAttributes['class']
  toastCloseButton?: HTMLAttributes['class']
}

/** Re-exported so consumers can type their own helpers without depending on vue-sonner directly. */
export type { Action, ExternalToast, ToastClasses, ToasterProps, ToastT } from 'vue-sonner'

Composables

useToast.ts
import type { Component } from 'vue'
import type { ExternalToast } from 'vue-sonner'
import type { NToastAction, NToastProps } from '../types'
import { markRaw } from 'vue'
import { toast as sonner } from 'vue-sonner'
import Toast from '../components/overlays/toast/Toast.vue'

type RichOptions = Omit<ExternalToast, 'action' | 'cancel'>
  & Pick<NToastProps, 'actions' | 'showProgress' | 'progress' | 'leading' | 'una'>
  & {
    /** Single button, inline with the text. Any `NButton` prop works. */
    action?: NToastAction
    /** Single dismissing button, inline with the text. */
    cancel?: NToastAction
  }
type ToastType = NToastProps['type']

/**
 * Anything una-specific renders through our own component, so buttons are real
 * `Button`s carrying `btn` variants rather than sonner's `[data-button]`, which
 * is reachable only through class overrides.
 */
function isRich(opts?: RichOptions) {
  return Boolean(opts && ((opts.actions?.length ?? 0) > 0 || opts.action || opts.cancel || opts.showProgress || opts.leading || opts.una))
}

function rich(message: string, opts: RichOptions, type?: ToastType) {
  const { actions, action, cancel, showProgress, progress, leading, una, description, ...rest } = opts
  // only the bar needs a concrete duration; otherwise leave it to the Toaster
  const duration = rest.duration ?? (showProgress ? 4000 : undefined)

  const paired: NToastAction[] = [
    ...(action ? [{ btn: 'outline-gray', ...action }] : []),
    ...(cancel ? [{ btn: 'ghost-gray', ...cancel }] : []),
  ]
  const resolved = [...paired, ...(actions ?? [])]

  // sonner renders its own action/cancel pair inline; `actions[]` is una's
  // superset and still stacks
  const inlineActions = paired.length > 0 && !actions?.length

  const componentProps: NToastProps = {
    type,
    title: message,
    description: description as string,
    actions: resolved,
    inlineActions,
    showProgress,
    progress,
    leading,
    una,
    duration,
  }

  return sonner.custom(markRaw(Toast) as Component, { ...rest, duration, componentProps })
}

function create(type?: ToastType) {
  return (message: string, opts?: RichOptions) => {
    if (isRich(opts))
      return rich(message, opts!, type)
    return type ? sonner[type](message, opts) : sonner(message, opts)
  }
}

/** vue-sonner's `toast`, with `actions[]` and `showProgress` added. */
const toast = Object.assign(create(), sonner, {
  success: create('success'),
  error: create('error'),
  warning: create('warning'),
  info: create('info'),
  loading: create('loading'),
})

interface UseToast {
  toast: typeof toast
  /** Dismiss a toast by id, or every toast when called with no argument. */
  dismiss: typeof sonner.dismiss
}

function useToast(): UseToast {
  return {
    toast,
    dismiss: sonner.dismiss,
  }
}

export { toast, useToast }

Components

Toaster.vue
Toast.vue
<script setup lang="ts">
import type { NToasterProps } from '../../types'
import { useColorMode } from '#imports'
import { reactiveOmit } from '@vueuse/core'
import { computed } from 'vue'
import { Toaster } from 'vue-sonner'
import { cn } from '../../utils'
import Icon from '../elements/Icon.vue'

const props = withDefaults(defineProps<NToasterProps>(), {
  position: 'bottom-right',
  duration: 4000,
  visibleToasts: 3,
  expand: false,
  closeButtonPosition: 'top-right',
})

const toasterProps = reactiveOmit(props, ['una', 'theme', 'toastOptions', 'style'])

// The gray scale has to be remapped alongside --normal-*: sonner declares
// --gray1..--gray12 with light values and its dark block overrides neither, so
// the close button would render near-black on a dark card.
const tokens = {
  // sonner adds these on top of the row gap, which would make standard toasts
  // sit 3px wider from their text than the rich ones
  '--toast-icon-margin-start': '0',
  '--toast-icon-margin-end': '0',

  '--normal-bg': 'oklch(var(--una-popover))',
  '--normal-text': 'oklch(var(--una-popover-foreground))',
  '--normal-border': 'oklch(var(--una-border))',
  '--border-radius': 'var(--una-radius)',

  '--gray1': 'oklch(var(--una-background))',
  '--gray2': 'oklch(var(--una-muted))',
  '--gray3': 'oklch(var(--una-muted))',
  '--gray4': 'oklch(var(--una-border))',
  '--gray5': 'oklch(var(--una-border))',
  '--gray6': 'oklch(var(--una-border))',
  '--gray7': 'oklch(var(--una-border))',
  '--gray8': 'oklch(var(--una-muted-foreground))',
  '--gray9': 'oklch(var(--una-muted-foreground))',
  '--gray10': 'oklch(var(--una-muted-foreground))',
  '--gray11': 'oklch(var(--una-muted-foreground))',
  '--gray12': 'oklch(var(--una-foreground))',
}

// sonner's own dark rules key off this, not off a class
const colorMode = useColorMode()
const theme = computed(() => (colorMode.value === 'dark' ? 'dark' : 'light'))

// inline so it outranks sonner's `align-items: center` without !important
const layout = { alignItems: 'flex-start', gap: '12px' }

// Keep these class strings in this .vue file — UnoCSS does not scan plain .ts,
// so moving them would silently generate no CSS.
const classes = computed(() => ({
  toast: props.una?.toast,
  title: props.una?.toastTitle,
  // `toast-description` carries a `!`: sonner hardcodes this colour per theme
  // behind three selectors, which no class can outrank.
  description: cn('toast-description', props.una?.toastDescription),
  content: props.una?.toastContent,
  icon: cn('toast-icon', props.una?.toastIcon),
  closeButton: props.una?.toastCloseButton,
}))
</script>

<template>
  <Toaster
    v-bind="toasterProps"
    :theme="props.theme ?? theme"
    :toast-options="{
      ...props.toastOptions,
      style: { ...layout, ...props.toastOptions?.style },
      classes: { ...classes, ...props.toastOptions?.classes },
    }"
    :style="{ ...tokens, ...props.style }"
  >
    <template #success-icon>
      <Icon name="toast-success-icon" />
    </template>
    <template #error-icon>
      <Icon name="toast-error-icon" />
    </template>
    <template #warning-icon>
      <Icon name="toast-warning-icon" />
    </template>
    <template #info-icon>
      <Icon name="toast-info-icon" />
    </template>
    <template #loading-icon>
      <Icon name="toast-loading-icon" class="toast-loading" />
    </template>
    <template #close-icon>
      <Icon name="toast-close-icon" />
    </template>
  </Toaster>
</template>