Toast

A succinct message that is displayed temporarily.

Setup

For the beginning, add the Toaster component to your app.vue.

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

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

Examples

Basic

Then, you can use the useToast composable to add toasts to your app:

PropDefaultTypeDescription
title-stringTitle of the toast
description-stringDescription of the toast
showProgressfalsebooleanShow the progress bar.
closabletruebooleanDisplay close button.
Preview
Code

With actions

PropDefaultTypeDescription
actions[]Action[]The array of action.
Preview
Code

Leading Icon

PropDefaultTypeDescription
leading-stringThe leading icon of the toast
Preview
Code

Variant and Color

PropDefaultTypeDescription
toastoutline-gray{variant}-{color}Set the toast variant and color.
progressprimary{color}Set the progress color.
Preview
Code

Provider

Configure the toast provider using the _toastProvider prop.

PropDefaultTypeDescription
duration4000numberSet the duration in milliseconds of the toast.
labelNotificationstringAn author-localized label for each toast.
swipeDirectionrightright left up downDirection of pointer swipe that should close the toast.
swipeThreshold50numberDistance in pixels that the swipe pass before a close is triggered.
Preview
Code

Slots

NamePropsDescription
default-The trigger slot.
actions-The actions slot.
info-The info slot.
title-The title slot.
description-The description slot.
closeIcon-The close icon slot.

Presets

shortcuts/toast.ts
type ToastPrefix = 'toast'

export const staticToast: Record<`${ToastPrefix}-${string}` | ToastPrefix, string> = {
  // config
  'toast': 'pointer-events-auto relative flex w-full space-x-2 justify-end overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all ',

  'toast-viewport': 'fixed top-0 z-100 flex max-h-screen gap-y-4 w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-420px',
  'toast-wrapper': 'w-0 flex flex-1 flex-col gap-2',
  'toast-title': 'text-sm font-semibold [&+div]:text-xs',
  'toast-description': 'text-sm opacity-90',
  'toast-leading': 'square-5',

  'toast-close': 'bg-transparent flex items-center justify-center absolute right-1 top-1 rounded-md p-1 text-brand/80 opacity-0 transition-opacity hover:text-brand focus:opacity-100 focus:ring-1 focus:ring-brand/80 focus:outline-none group-hover:opacity-100',
  'toast-close-icon': 'i-close',
  'toast-close-icon-base': 'h-1em w-1em',

  'toast-info': 'grid gap-1',
  'toast-actions': 'flex shrink-0 gap-1.5',
  'toast-progress': 'h-1 rounded-none',
}

export const dynamicToast = [
  // dynamic variants
  [/^toast-solid(-(\S+))?$/, ([, , c = 'primary']) => `alert-solid-${c}`],
  [/^toast-soft(-(\S+))?$/, ([, , c = 'primary']) => `alert-soft-${c}`],
  [/^toast-outline(-(\S+))?$/, ([, , c = 'primary']) => `alert-outline-${c}`],
  [/^toast-border(-(\S+))?$/, ([, , c = 'primary']) => `alert-border-${c}`],

]

export const toast = [
  ...dynamicToast,
  staticToast,
]

Props

types/toast.ts
import type { ToastActionProps, ToastCloseProps, ToastDescriptionProps, ToastProviderProps, ToastRootProps, ToastTitleProps, ToastViewportProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import type { NButtonProps } from './button'
import type { NProgressProps } from './progress'

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

export type Toaster = NToastProps & { id: string }

export interface NToasterProps extends BaseExtensions, ToastProviderProps {
  _toastViewport?: Partial<NToastViewportProps>
  _toast?: Partial<NToastProps>
}

export interface NToastProps extends BaseExtensions, Pick<NProgressProps, 'progress'>, ToastRootProps {
  /**
   * Allows you to add `UnaUI` toast preset properties,
   * Think of it as a shortcut for adding options or variants to the preset if available.
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/toast.ts
   * @example
   * toast="outline-green"
   */
  toast?: HTMLAttributes['class']
  /**
   * Display leading icon.
   *
   * @default null
   */
  leading?: HTMLAttributes['class']
  /**
   * Add a title to the toast.
   */
  title?: string
  /**
   * Add a description to the toast.
   */
  description?: string
  /**
   * Display `close` icon on the right side of the toast.
   *
   * @default false
   */
  closable?: boolean
  /**
   * The array of actions.
   */
  actions?: ToastActionProps[]
  /**
   * The array of toasts
   */
  toasts?: Toaster[]
  /**
   * Allows you to change the size of the input.
   *
   * @default sm
   *
   * @example
   * size="sm" | size="2cm" | size="2rem" | size="2px"
   */
  size?: string
  /**
   * Show progress bar on the toast.
   *
   * @default false
   */
  showProgress?: boolean

  // Subcomponents
  _toastProvider?: Partial<NToastProviderProps>
  _toastTitle?: Partial<NToastTitleProps>
  _toastDescription?: Partial<NToastDescriptionProps>
  _toastViewport?: Partial<NToastViewportProps>
  _toastAction?: Partial<NToastActionProps>
  _toastClose?: Partial<NToastCloseProps>
  _toastInfo?: Partial<NToastInfoProps>
  _progress?: Partial<NProgressProps>

  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/toast.ts
   */
  una?: NToastUnaProps

  onOpenChange?: ((value: boolean) => void) | undefined
}

export interface NToastProviderProps extends ToastProviderProps {}

export interface NToastTitleProps extends ToastTitleProps, BaseExtensions {
  /** Additional properties for the una component */
  una?: Pick<NToastUnaProps, 'toastTitle'>
}

export interface NToastDescriptionProps extends ToastDescriptionProps, BaseExtensions {
  /** Additional properties for the una component */
  una?: Pick<NToastUnaProps, 'toastDescription'>
}

export interface NToastViewportProps extends ToastViewportProps, BaseExtensions {
  /** Additional properties for the una component */
  una?: Pick<NToastUnaProps, 'toastViewport'>
}

export interface NToastActionProps extends NButtonProps, ToastActionProps {
}

export interface NToastInfoProps extends BaseExtensions {
  /** Additional properties for the una component */
  una?: Pick<NToastUnaProps, 'toastInfo'>
}

export interface NToastCloseProps extends ToastCloseProps, BaseExtensions {
  /** Additional properties for the una component */
  una?: Pick<NToastUnaProps, 'toastClose' | 'toastCloseIconBase' | 'toastCloseIcon'>
}

export interface NToastUnaProps {
  toast?: HTMLAttributes['class']
  toastRoot?: HTMLAttributes['class']
  toastTitle?: HTMLAttributes['class']
  toastDescription?: HTMLAttributes['class']
  toastViewport?: HTMLAttributes['class']
  toastClose?: HTMLAttributes['class']
  toastCloseIconBase?: HTMLAttributes['class']
  toastCloseIcon?: HTMLAttributes['class']
  toastInfo?: HTMLAttributes['class']
  toastLeading?: HTMLAttributes['class']
  toastWrapper?: HTMLAttributes['class']
  toastProgress?: HTMLAttributes['class']
}

Composables

useToast.ts
import type { ComputedRef, VNode } from 'vue'
import type { NToastProps } from '../types'
import { computed, ref } from 'vue'

const TOAST_LIMIT = 10
const TOAST_REMOVE_DELAY = 1000000

export type StringOrVNode =
  | string
  | VNode
  | (() => VNode)

type ToasterToast = NToastProps & {
  id: string
  title?: string
  description?: StringOrVNode
  actions?: NToastProps[]
}

const actionTypes = {
  ADD_TOAST: 'ADD_TOAST',
  UPDATE_TOAST: 'UPDATE_TOAST',
  DISMISS_TOAST: 'DISMISS_TOAST',
  REMOVE_TOAST: 'REMOVE_TOAST',
} as const

let count = 0

function genId() {
  count = (count + 1) % Number.MAX_VALUE
  return count.toString()
}

type ActionType = typeof actionTypes

type Action =
  | {
    type: ActionType['ADD_TOAST']
    toast: ToasterToast
  }
  | {
    type: ActionType['UPDATE_TOAST']
    toast: Partial<ToasterToast>
  }
  | {
    type: ActionType['DISMISS_TOAST']
    toastId?: ToasterToast['id']
  }
  | {
    type: ActionType['REMOVE_TOAST']
    toastId?: ToasterToast['id']
  }

interface State {
  toasts: ToasterToast[]
}

const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()

function addToRemoveQueue(toastId: string) {
  if (toastTimeouts.has(toastId))
    return

  const timeout = setTimeout(() => {
    toastTimeouts.delete(toastId)
    dispatch({
      type: actionTypes.REMOVE_TOAST,
      toastId,
    })
  }, TOAST_REMOVE_DELAY)

  toastTimeouts.set(toastId, timeout)
}

const state = ref<State>({
  toasts: [],
})

function dispatch(action: Action) {
  switch (action.type) {
    case actionTypes.ADD_TOAST:
      state.value.toasts = [action.toast, ...state.value.toasts].slice(0, TOAST_LIMIT)
      break

    case actionTypes.UPDATE_TOAST:
      state.value.toasts = state.value.toasts.map(t =>
        t.id === action.toast.id ? { ...t, ...action.toast } : t,
      )
      break

    case actionTypes.DISMISS_TOAST: {
      const { toastId } = action

      if (toastId) {
        addToRemoveQueue(toastId)
      }
      else {
        state.value.toasts.forEach((toast) => {
          addToRemoveQueue(toast.id)
        })
      }

      state.value.toasts = state.value.toasts.map(t =>
        t.id === toastId || toastId === undefined
          ? {
              ...t,
              open: false,
            }
          : t,
      )
      break
    }

    case actionTypes.REMOVE_TOAST:
      if (action.toastId === undefined)
        state.value.toasts = []
      else
        state.value.toasts = state.value.toasts.filter(t => t.id !== action.toastId)

      break
  }
}

type Toast = Omit<ToasterToast, 'id'>

function toast(props: Toast) {
  const id = genId()

  const update = (props: ToasterToast) =>
    dispatch({
      type: actionTypes.UPDATE_TOAST,
      toast: { ...props, id },
    })

  const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id })

  dispatch({
    type: actionTypes.ADD_TOAST,
    toast: {
      ...props,
      id,
      open: true,
      onOpenChange: (open: boolean) => {
        if (!open)
          dismiss()
      },
    },
  })

  return {
    id,
    dismiss,
    update,
  }
}

interface UseToast {
  toasts: ComputedRef<ToasterToast[]>
  toast: (props: Toast) => { id: string, dismiss: () => void, update: (props: ToasterToast) => void }
  dismiss: (toastId?: string) => void
}

function useToast(): UseToast {
  return {
    toasts: computed(() => state.value.toasts),
    toast,
    dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
  }
}

export { toast, useToast }

Components

Toast.vue
Toaster.vue
ToastRoot.vue
ToastProvider.vue
ToastViewport.vue
ToastInfo.vue
ToastTitle.vue
ToastDescription.vue
ToastAction.vue
ToastClose.vue
<script setup lang="ts">
import type { NToastProps } from '../../../types'
import { reactivePick } from '@vueuse/core'
import { ToastRoot, type ToastRootEmits, useForwardPropsEmits } from 'radix-vue'

import { cn } from '../../../utils'
import Icon from '../../elements/Icon.vue'
import Progress from '../../elements/Progress.vue'
import ToastAction from './ToastAction.vue'
import ToastClose from './ToastClose.vue'
import ToastDescription from './ToastDescription.vue'
import ToastInfo from './ToastInfo.vue'
import ToastTitle from './ToastTitle.vue'

defineOptions({
  inheritAttrs: false,
})

const props = withDefaults(defineProps<NToastProps>(), {
  toast: 'solid-white',
  closable: true,
})

const emits = defineEmits<ToastRootEmits>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultOpen', 'open', 'duration', 'type'), emits)
</script>

<template>
  <ToastRoot
    v-slot="{ remaining, duration }"
    :class="cn(
      'group toast data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
      props.class,
      props.una?.toast,
    )"
    v-bind="rootProps"
    :toast
    @update:open="onOpenChange"
  >
    <slot name="leading">
      <Icon
        v-if="leading"
        :name="leading"
        :class="cn(
          'toast-leading',
          props.una?.toastLeading,
        )"
      />
    </slot>

    <div
      :class="cn(
        'toast-wrapper',
        props.una?.toastWrapper,
      )"
    >
      <slot>
        <ToastInfo
          v-if="$slots.info || $slots.title || $slots.description || title || description"
          v-bind="_toastInfo"
          :una
        >
          <slot name="info">
            <ToastTitle
              v-if="$slots.title || title"
              v-bind="_toastTitle"
              :una
            >
              <slot name="title">
                {{ title }}
              </slot>
            </ToastTitle>

            <ToastDescription
              v-if="$slots.description || description"
              v-bind="_toastDescription"
              :una
            >
              <slot name="description">
                {{ description }}
              </slot>
            </ToastDescription>
          </slot>
        </ToastInfo>

        <div
          v-if="actions"
          class="toast-actions"
        >
          <slot name="actions" :actions>
            <ToastAction
              v-for="(action, index) in actions"
              :key="index"
              v-bind="action"
            />
          </slot>
        </div>

        <ToastClose
          v-if="closable"
          v-bind="_toastClose"
          :una
        >
          <slot name="closeIcon" />
        </ToastClose>
      </slot>
    </div>

    <div
      v-if="showProgress"
      class="absolute inset-x-0 bottom-0 !mx-0"
    >
      <Progress
        :progress
        v-bind="_progress"
        :class="cn(
          'toast-progress bg-transparent',
          props.una?.toastProgress,
        )"
        :model-value="remaining / duration * 100"
      />
    </div>
  </ToastRoot>
</template>