Skip to content

Toaster

Demo

This requires the following components to be installed:

This requires the following composables to be installed:

This requires the following utils to be installed:

This requires the following types to be installed:

This requires the following theme to be installed:

Component

Toaster.vue
vue
<script lang="ts">
import type { ComponentConfig } from '@/ui/utils/utils'
import type { ToastProviderProps } from 'reka-ui'
import Toast from '@/ui/components/Toast.vue'
import { usePortal } from '@/ui/composables/usePortal'
import { useToast } from '@/ui/composables/useToast'
import theme from '@/ui/theme/toaster'
import { omit } from '@/ui/utils/omit'
import { reactivePick } from '@vueuse/shared'
import { ToastPortal, ToastProvider, ToastViewport, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, ref, toRef } from 'vue'

type Toaster = ComponentConfig<typeof theme>

export interface ToasterProps extends Omit<ToastProviderProps, 'swipeDirection'> {
  position?: Toaster['variants']['position']
  expand?: boolean
  portal?: boolean | string | HTMLElement
  class?: any
  ui?: Toaster['slots']
}

export interface ToasterSlots {
  default: (props?: object) => any
}

export default {
  name: 'Toaster',
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<ToasterProps>(), {
  expand: true,
  portal: true,
  duration: 5000,
})
defineSlots<ToasterSlots>()

const { toasts, remove } = useToast()

const providerProps = useForwardProps(reactivePick(props, 'duration', 'label', 'swipeThreshold'))
const portalProps = usePortal(toRef(() => props.portal))

const swipeDirection = computed(() => {
  switch (props.position) {
    case 'top-center':
      return 'up'
    case 'top-right':
    case 'bottom-right':
      return 'right'
    case 'bottom-center':
      return 'down'
    case 'top-left':
    case 'bottom-left':
      return 'left'
  }
  return 'right'
})

const ui = computed(() => tv(theme)({
  position: props.position,
  swipeDirection: swipeDirection.value,
}))

function onUpdateOpen(value: boolean, id: string | number) {
  if (value) {
    return
  }

  remove(id)
}

const hovered = ref(false)
const expanded = computed(() => props.expand || hovered.value)

const refs = ref<{ height: number }[]>([])

const height = computed(() => refs.value.reduce((acc, { height }) => acc + height + 16, 0))
const frontHeight = computed(() => refs.value[refs.value.length - 1]?.height || 0)

function getOffset(index: number) {
  return refs.value.slice(index + 1).reduce((acc, { height }) => acc + height + 16, 0)
}
</script>

<template>
  <ToastProvider :swipe-direction="swipeDirection" v-bind="providerProps">
    <slot />

    <Toast
      v-for="(toast, index) of toasts"
      :key="toast.id"
      ref="refs"
      v-bind="omit(toast, ['id', 'close'])"
      :close="(toast.close as boolean)"
      :data-expanded="expanded"
      :data-front="!expanded && index === toasts.length - 1"
      :style="{
        '--index': (index - toasts.length) + toasts.length,
        '--before': toasts.length - 1 - index,
        '--offset': getOffset(index),
        '--scale': expanded ? '1' : 'calc(1 - var(--before) * var(--scale-factor))',
        '--translate': expanded ? 'calc(var(--offset) * var(--translate-factor))' : 'calc(var(--before) * var(--gap))',
        '--transform': 'translateY(var(--translate)) scale(var(--scale))',
      }"
      :class="[ui.base(), {
        'cursor-pointer': !!toast.onClick,
      }]"
      @update:open="onUpdateOpen($event, toast.id)"
      @click="toast.onClick && toast.onClick(toast)"
    />

    <ToastPortal v-bind="portalProps">
      <ToastViewport
        :data-expanded="expanded"
        :class="ui.viewport({ class: [props.class, props.ui?.viewport] })"
        :style="{
          '--scale-factor': '0.05',
          '--translate-factor': position?.startsWith('top') ? '1px' : '-1px',
          '--gap': position?.startsWith('top') ? '16px' : '-16px',
          '--front-height': `${frontHeight}px`,
          '--height': `${height}px`,
        }"
        @mouseenter="hovered = true"
        @mouseleave="hovered = false"
      />
    </ToastPortal>
  </ToastProvider>
</template>
Toaster.vue
vue
<script lang="ts">
import type { ComponentConfig } from '@/UI/Utils/utils'
import type { ToastProviderProps } from 'reka-ui'
import Toast from '@/UI/Components/Toast.vue'
import { usePortal } from '@/UI/Composables/usePortal'
import { useToast } from '@/UI/Composables/useToast'
import theme from '@/UI/Theme/toaster'
import { omit } from '@/UI/Utils/omit'
import { reactivePick } from '@vueuse/shared'
import { ToastPortal, ToastProvider, ToastViewport, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, ref, toRef } from 'vue'

type Toaster = ComponentConfig<typeof theme>

export interface ToasterProps extends Omit<ToastProviderProps, 'swipeDirection'> {
  position?: Toaster['variants']['position']
  expand?: boolean
  portal?: boolean | string | HTMLElement
  class?: any
  ui?: Toaster['slots']
}

export interface ToasterSlots {
  default: (props?: object) => any
}

export default {
  name: 'Toaster',
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<ToasterProps>(), {
  expand: true,
  portal: true,
  duration: 5000,
})
defineSlots<ToasterSlots>()

const { toasts, remove } = useToast()

const providerProps = useForwardProps(reactivePick(props, 'duration', 'label', 'swipeThreshold'))
const portalProps = usePortal(toRef(() => props.portal))

const swipeDirection = computed(() => {
  switch (props.position) {
    case 'top-center':
      return 'up'
    case 'top-right':
    case 'bottom-right':
      return 'right'
    case 'bottom-center':
      return 'down'
    case 'top-left':
    case 'bottom-left':
      return 'left'
  }
  return 'right'
})

const ui = computed(() => tv(theme)({
  position: props.position,
  swipeDirection: swipeDirection.value,
}))

function onUpdateOpen(value: boolean, id: string | number) {
  if (value) {
    return
  }

  remove(id)
}

const hovered = ref(false)
const expanded = computed(() => props.expand || hovered.value)

const refs = ref<{ height: number }[]>([])

const height = computed(() => refs.value.reduce((acc, { height }) => acc + height + 16, 0))
const frontHeight = computed(() => refs.value[refs.value.length - 1]?.height || 0)

function getOffset(index: number) {
  return refs.value.slice(index + 1).reduce((acc, { height }) => acc + height + 16, 0)
}
</script>

<template>
  <ToastProvider :swipe-direction="swipeDirection" v-bind="providerProps">
    <slot />

    <Toast
      v-for="(toast, index) of toasts"
      :key="toast.id"
      ref="refs"
      v-bind="omit(toast, ['id', 'close'])"
      :close="(toast.close as boolean)"
      :data-expanded="expanded"
      :data-front="!expanded && index === toasts.length - 1"
      :style="{
        '--index': (index - toasts.length) + toasts.length,
        '--before': toasts.length - 1 - index,
        '--offset': getOffset(index),
        '--scale': expanded ? '1' : 'calc(1 - var(--before) * var(--scale-factor))',
        '--translate': expanded ? 'calc(var(--offset) * var(--translate-factor))' : 'calc(var(--before) * var(--gap))',
        '--transform': 'translateY(var(--translate)) scale(var(--scale))',
      }"
      :class="[ui.base(), {
        'cursor-pointer': !!toast.onClick,
      }]"
      @update:open="onUpdateOpen($event, toast.id)"
      @click="toast.onClick && toast.onClick(toast)"
    />

    <ToastPortal v-bind="portalProps">
      <ToastViewport
        :data-expanded="expanded"
        :class="ui.viewport({ class: [props.class, props.ui?.viewport] })"
        :style="{
          '--scale-factor': '0.05',
          '--translate-factor': position?.startsWith('top') ? '1px' : '-1px',
          '--gap': position?.startsWith('top') ? '16px' : '-16px',
          '--front-height': `${frontHeight}px`,
          '--height': `${height}px`,
        }"
        @mouseenter="hovered = true"
        @mouseleave="hovered = false"
      />
    </ToastPortal>
  </ToastProvider>
</template>

Theme

toaster.ts
ts
export default {
  slots: {
    viewport: '',
    base: '',
  },
  variants: {
    position: {
      'top-left': {
        viewport: 'left-4',
      },
      'top-center': {
        viewport: 'left-1/2 transform -translate-x-1/2',
      },
      'top-right': {
        viewport: 'right-4',
      },
      'bottom-left': {
        viewport: 'left-4',
      },
      'bottom-center': {
        viewport: 'left-1/2 transform -translate-x-1/2',
      },
      'bottom-right': {
        viewport: 'right-4',
      },
    },
    swipeDirection: {
      up: '',
      right: '',
      down: '',
      left: '',
    },
  },
  compoundVariants: [{
    position: ['top-left', 'top-center', 'top-right'],
    class: {
      viewport: 'top-4',
      base: 'top-0 data-[state=open]:animate-[slide-in-from-top_200ms_ease-in-out]',
    },
  }, {
    position: ['bottom-left', 'bottom-center', 'bottom-right'],
    class: {
      viewport: 'bottom-4',
      base: 'bottom-0 data-[state=open]:animate-[slide-in-from-bottom_200ms_ease-in-out]',
    },
  }, {
    swipeDirection: ['left', 'right'],
    class: 'data-[swipe=move]:translate-x-(--reka-toast-swipe-move-x) data-[swipe=end]:translate-x-(--reka-toast-swipe-end-x) data-[swipe=cancel]:translate-x-0',
  }, {
    swipeDirection: ['up', 'down'],
    class: 'data-[swipe=move]:translate-y-(--reka-toast-swipe-move-y) data-[swipe=end]:translate-y-(--reka-toast-swipe-end-y) data-[swipe=cancel]:translate-y-0',
  }] as any,
  defaultVariants: {
    position: 'bottom-right',
  } as const,
}
View Nuxt UI theme
toaster.ts
ts
export default {
  slots: {
    viewport: 'fixed flex flex-col w-[calc(100%-2rem)] sm:w-96 z-[100] data-[expanded=true]:h-(--height) focus:outline-none',
    base: 'pointer-events-auto absolute inset-x-0 z-(--index) transform-(--transform) data-[expanded=false]:data-[front=false]:h-(--front-height) data-[expanded=false]:data-[front=false]:*:invisible data-[state=closed]:animate-[toast-closed_200ms_ease-in-out] data-[state=closed]:data-[expanded=false]:data-[front=false]:animate-[toast-collapsed-closed_200ms_ease-in-out] data-[swipe=move]:transition-none transition-[transform,translate,height] duration-200 ease-out',
  },
  variants: {
    position: {
      'top-left': {
        viewport: 'left-4',
      },
      'top-center': {
        viewport: 'left-1/2 transform -translate-x-1/2',
      },
      'top-right': {
        viewport: 'right-4',
      },
      'bottom-left': {
        viewport: 'left-4',
      },
      'bottom-center': {
        viewport: 'left-1/2 transform -translate-x-1/2',
      },
      'bottom-right': {
        viewport: 'right-4',
      },
    },
    swipeDirection: {
      up: 'data-[swipe=end]:animate-[toast-slide-up_200ms_ease-out]',
      right: 'data-[swipe=end]:animate-[toast-slide-right_200ms_ease-out]',
      down: 'data-[swipe=end]:animate-[toast-slide-down_200ms_ease-out]',
      left: 'data-[swipe=end]:animate-[toast-slide-left_200ms_ease-out]',
    },
  },
  compoundVariants: [{
    position: ['top-left', 'top-center', 'top-right'],
    class: {
      viewport: 'top-4',
      base: 'top-0 data-[state=open]:animate-[slide-in-from-top_200ms_ease-in-out]',
    },
  }, {
    position: ['bottom-left', 'bottom-center', 'bottom-right'],
    class: {
      viewport: 'bottom-4',
      base: 'bottom-0 data-[state=open]:animate-[slide-in-from-bottom_200ms_ease-in-out]',
    },
  }, {
    swipeDirection: ['left', 'right'],
    class: 'data-[swipe=move]:translate-x-(--reka-toast-swipe-move-x) data-[swipe=end]:translate-x-(--reka-toast-swipe-end-x) data-[swipe=cancel]:translate-x-0',
  }, {
    swipeDirection: ['up', 'down'],
    class: 'data-[swipe=move]:translate-y-(--reka-toast-swipe-move-y) data-[swipe=end]:translate-y-(--reka-toast-swipe-end-y) data-[swipe=cancel]:translate-y-0',
  }] as any,
  defaultVariants: {
    position: 'bottom-right',
  } as const,
}

Test

To test this component, you can use the following test file:

Toaster.test.ts
ts
import { expect, it } from 'vitest'

// No tests for this component
it('passes', () => {
  expect(true).toBe(true)
})
Toaster.test.ts
ts
import { expect, it } from 'vitest'

// No tests for this component
it('passes', () => {
  expect(true).toBe(true)
})

Contributors

barbapapazes

Changelog

7d577 - feat: add toast component (#77) on 1/3/2025