Toast
A succinct message to provide information or feedback to the user.
Demo
Related Components
This requires the following components to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<script lang="ts">
import type { AvatarProps } from '@/ui/components/Avatar.vue'
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { ComponentConfig, StringOrVNode } from '@/ui/utils/utils'
import type { ToastRootEmits, ToastRootProps } from 'reka-ui'
import Avatar from '@/ui/components/Avatar.vue'
import Button from '@/ui/components/Button.vue'
import Icon from '@/ui/components/Icon.vue'
import { closeIcon } from '@/ui/icons'
import theme from '@/ui/theme/toast'
import { reactivePick } from '@vueuse/core'
import { ToastAction, ToastClose, ToastDescription, ToastRoot, ToastTitle, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, onMounted, ref } from 'vue'
type Toast = ComponentConfig<typeof theme>
export interface ToastProps extends Pick<ToastRootProps, 'defaultOpen' | 'open' | 'type' | 'duration'> {
as?: any
title?: StringOrVNode
description?: StringOrVNode
icon?: string
avatar?: AvatarProps
color?: Toast['variants']['color']
orientation?: Toast['variants']['orientation']
actions?: ButtonProps[]
close?: boolean | Partial<ButtonProps>
closeIcon?: string
class?: any
ui?: Toast['slots']
}
export interface ToastEmits extends ToastRootEmits {}
export interface ToastSlots {
leading: (props?: object) => any
title: (props?: object) => any
description: (props?: object) => any
actions: (props?: object) => any
close: (props: { ui: { [K in keyof Required<Toast['slots']>]: (props?: Record<string, any>) => string } }) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<ToastProps>(), {
close: true,
orientation: 'vertical',
})
const emits = defineEmits<ToastEmits>()
const slots = defineSlots<ToastSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultOpen', 'open', 'duration', 'type'), emits)
const ui = computed(() => tv(theme)({
color: props.color,
orientation: props.orientation,
title: !!props.title || !!slots.title,
}))
const el = ref()
const height = ref(0)
onMounted(() => {
if (!el.value) {
return
}
setTimeout(() => {
height.value = el.value.$el.getBoundingClientRect()?.height
}, 0)
})
defineExpose({
height,
})
</script>
<template>
<ToastRoot
ref="el"
v-slot="{ remaining, duration }"
v-bind="rootProps"
:data-orientation="orientation"
:class="ui.root({ class: [props.class, props.ui?.root] })"
:style="{ '--height': height }"
>
<slot name="leading">
<Avatar v-if="avatar" :size="((props.ui?.avatarSize || ui.avatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.avatar({ class: props.ui?.avatar })" />
<Icon v-else-if="icon" :name="icon" :class="ui.icon({ class: props.ui?.icon })" />
</slot>
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<ToastTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title">
<component :is="title()" v-if="typeof title === 'function'" />
<component :is="title" v-else-if="typeof title === 'object'" />
<template v-else>
{{ title }}
</template>
</slot>
</ToastTitle>
<ToastDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description">
<component :is="description()" v-if="typeof description === 'function'" />
<component :is="description" v-else-if="typeof description === 'object'" />
<template v-else>
{{ description }}
</template>
</slot>
</ToastDescription>
<div v-if="orientation === 'vertical' && (actions?.length || !!slots.actions)" :class="ui.actions({ class: props.ui?.actions })">
<slot name="actions">
<ToastAction v-for="(action, index) in actions" :key="index" :alt-text="action.label || 'Action'" as-child @click.stop>
<Button size="xs" :color="color" v-bind="action" />
</ToastAction>
</slot>
</div>
</div>
<div v-if="(orientation === 'horizontal' && (actions?.length || !!slots.actions)) || close" :class="ui.actions({ class: props.ui?.actions, orientation: 'horizontal' })">
<template v-if="orientation === 'horizontal' && (actions?.length || !!slots.actions)">
<slot name="actions">
<ToastAction v-for="(action, index) in actions" :key="index" :alt-text="action.label || 'Action'" as-child @click.stop>
<Button size="xs" :color="color" v-bind="action" />
</ToastAction>
</slot>
</template>
<ToastClose v-if="close || !!slots.close" as-child>
<slot name="close" :ui="ui">
<Button
v-if="close"
:icon="closeIcon"
size="md"
color="neutral"
variant="link"
aria-label="close"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })"
@click.stop
/>
</slot>
</ToastClose>
</div>
<div v-if="remaining > 0 && duration" :class="ui.progress({ class: props.ui?.progress })" :style="{ width: `${remaining / duration * 100}%` }" />
</ToastRoot>
</template>vue
<script lang="ts">
import type { AvatarProps } from '@/UI/Components/Avatar.vue'
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { ComponentConfig, StringOrVNode } from '@/UI/Utils/utils'
import type { ToastRootEmits, ToastRootProps } from 'reka-ui'
import Avatar from '@/UI/Components/Avatar.vue'
import Button from '@/UI/Components/Button.vue'
import Icon from '@/UI/Components/Icon.vue'
import { closeIcon } from '@/UI/icons'
import theme from '@/UI/Theme/toast'
import { reactivePick } from '@vueuse/core'
import { ToastAction, ToastClose, ToastDescription, ToastRoot, ToastTitle, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, onMounted, ref } from 'vue'
type Toast = ComponentConfig<typeof theme>
export interface ToastProps extends Pick<ToastRootProps, 'defaultOpen' | 'open' | 'type' | 'duration'> {
as?: any
title?: StringOrVNode
description?: StringOrVNode
icon?: string
avatar?: AvatarProps
color?: Toast['variants']['color']
orientation?: Toast['variants']['orientation']
actions?: ButtonProps[]
close?: boolean | Partial<ButtonProps>
closeIcon?: string
class?: any
ui?: Toast['slots']
}
export interface ToastEmits extends ToastRootEmits {}
export interface ToastSlots {
leading: (props?: object) => any
title: (props?: object) => any
description: (props?: object) => any
actions: (props?: object) => any
close: (props: { ui: { [K in keyof Required<Toast['slots']>]: (props?: Record<string, any>) => string } }) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<ToastProps>(), {
close: true,
orientation: 'vertical',
})
const emits = defineEmits<ToastEmits>()
const slots = defineSlots<ToastSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultOpen', 'open', 'duration', 'type'), emits)
const ui = computed(() => tv(theme)({
color: props.color,
orientation: props.orientation,
title: !!props.title || !!slots.title,
}))
const el = ref()
const height = ref(0)
onMounted(() => {
if (!el.value) {
return
}
setTimeout(() => {
height.value = el.value.$el.getBoundingClientRect()?.height
}, 0)
})
defineExpose({
height,
})
</script>
<template>
<ToastRoot
ref="el"
v-slot="{ remaining, duration }"
v-bind="rootProps"
:data-orientation="orientation"
:class="ui.root({ class: [props.class, props.ui?.root] })"
:style="{ '--height': height }"
>
<slot name="leading">
<Avatar v-if="avatar" :size="((props.ui?.avatarSize || ui.avatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.avatar({ class: props.ui?.avatar })" />
<Icon v-else-if="icon" :name="icon" :class="ui.icon({ class: props.ui?.icon })" />
</slot>
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<ToastTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title">
<component :is="title()" v-if="typeof title === 'function'" />
<component :is="title" v-else-if="typeof title === 'object'" />
<template v-else>
{{ title }}
</template>
</slot>
</ToastTitle>
<ToastDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description">
<component :is="description()" v-if="typeof description === 'function'" />
<component :is="description" v-else-if="typeof description === 'object'" />
<template v-else>
{{ description }}
</template>
</slot>
</ToastDescription>
<div v-if="orientation === 'vertical' && (actions?.length || !!slots.actions)" :class="ui.actions({ class: props.ui?.actions })">
<slot name="actions">
<ToastAction v-for="(action, index) in actions" :key="index" :alt-text="action.label || 'Action'" as-child @click.stop>
<Button size="xs" :color="color" v-bind="action" />
</ToastAction>
</slot>
</div>
</div>
<div v-if="(orientation === 'horizontal' && (actions?.length || !!slots.actions)) || close" :class="ui.actions({ class: props.ui?.actions, orientation: 'horizontal' })">
<template v-if="orientation === 'horizontal' && (actions?.length || !!slots.actions)">
<slot name="actions">
<ToastAction v-for="(action, index) in actions" :key="index" :alt-text="action.label || 'Action'" as-child @click.stop>
<Button size="xs" :color="color" v-bind="action" />
</ToastAction>
</slot>
</template>
<ToastClose v-if="close || !!slots.close" as-child>
<slot name="close" :ui="ui">
<Button
v-if="close"
:icon="closeIcon"
size="md"
color="neutral"
variant="link"
aria-label="close"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })"
@click.stop
/>
</slot>
</ToastClose>
</div>
<div v-if="remaining > 0 && duration" :class="ui.progress({ class: props.ui?.progress })" :style="{ width: `${remaining / duration * 100}%` }" />
</ToastRoot>
</template>Theme
ts
export default {
slots: {
root: '',
wrapper: '',
title: '',
description: '',
icon: '',
avatar: '',
avatarSize: '',
actions: '',
progress: '',
close: '',
},
variants: {
color: {
primary: {
root: '',
icon: '',
progress: '',
},
secondary: {
root: '',
icon: '',
progress: '',
},
success: {
root: '',
icon: '',
progress: '',
},
info: {
root: '',
icon: '',
progress: '',
},
warning: {
root: '',
icon: '',
progress: '',
},
error: {
root: '',
icon: '',
progress: '',
},
neutral: {
root: '',
icon: '',
progress: '',
},
},
orientation: {
horizontal: {
root: '',
actions: '',
},
vertical: {
root: '',
actions: '',
},
},
title: {
true: {
description: '',
},
},
},
defaultVariants: {
color: 'primary',
} as const,
}View Nuxt UI theme
ts
export default {
slots: {
root: 'relative group overflow-hidden bg-default shadow-lg rounded-lg ring ring-default p-4 flex gap-2.5 focus:outline-none',
wrapper: 'w-0 flex-1 flex flex-col',
title: 'text-sm font-medium text-highlighted',
description: 'text-sm text-muted',
icon: 'shrink-0 size-5',
avatar: 'shrink-0',
avatarSize: '2xl',
actions: 'flex gap-1.5 shrink-0',
progress: 'absolute inset-x-0 bottom-0 h-1 z-[-1]',
close: 'p-0',
},
variants: {
color: {
primary: {
root: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
icon: 'text-primary',
progress: 'bg-primary',
},
secondary: {
root: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-secondary',
icon: 'text-secondary',
progress: 'bg-secondary',
},
success: {
root: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-success',
icon: 'text-success',
progress: 'bg-success',
},
info: {
root: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-info',
icon: 'text-info',
progress: 'bg-info',
},
warning: {
root: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-warning',
icon: 'text-warning',
progress: 'bg-warning',
},
error: {
root: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-error',
icon: 'text-error',
progress: 'bg-error',
},
neutral: {
root: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-inverted',
icon: 'text-highlighted',
progress: 'bg-inverted',
},
},
orientation: {
horizontal: {
root: 'items-center',
actions: 'items-center',
},
vertical: {
root: 'items-start',
actions: 'items-start mt-2.5',
},
},
title: {
true: {
description: 'mt-1',
},
},
},
defaultVariants: {
color: 'primary',
} as const,
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import Toast from '@/ui/components/Toast.vue'
import Toaster from '@/ui/components/Toaster.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
const ToastWrapper = defineComponent({
components: {
Toaster,
Toast,
},
inheritAttrs: false,
template: `<Toaster :portal="false">
<Toast v-bind="$attrs">
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</Toast>
</Toaster>`,
})
describe('toast', () => {
const props = { title: 'Toast' }
it.each<[string, RenderOptions<typeof Toast & typeof Toaster>]>([
// Props
['with title', { props }],
['with description', { props: { ...props, description: 'This is a toast' } }],
['with icon', { props: { ...props, icon: 'i-lucide-rocket' } }],
['with avatar', { props: { ...props, avatar: { src: 'https://github.com/vue.png' } } }],
['with actions', { props: { ...props, actions: [{ label: 'Action' }] } }],
['with orientation vertical', { props: { ...props, icon: 'i-lucide-rocket', description: 'This is a toast', actions: [{ label: 'Action' }], orientation: 'vertical' as const } }],
['with orientation horizontal', { props: { ...props, icon: 'i-lucide-rocket', description: 'This is a toast', actions: [{ label: 'Action' }], orientation: 'horizontal' as const } }],
['without close', { props: { ...props, close: false } }],
['with type', { props: { ...props, type: 'background' as const } }],
['with color neutral', { props: { ...props, color: 'neutral' as const } }],
['with as', { props: { ...props, as: 'section' } }],
['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)/50' } }],
['with ui', { props: { ...props, ui: { title: 'font-bold' } } }],
// Slots
['with leading slot', { props, slots: { leading: () => 'Leading slot' } }],
['with title slot', { props, slots: { title: () => 'Title slot' } }],
['with description slot', { props, slots: { description: () => 'Description slot' } }],
['with close slot', { props, slots: { close: () => 'Close slot' } }],
])('renders %s correctly', async (name, options) => {
const { html } = render(ToastWrapper, {
...options,
})
await new Promise(resolve => setTimeout(resolve, 0))
expect(html()).toMatchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import Toast from '@/UI/Components/Toast.vue'
import Toaster from '@/UI/Components/Toaster.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
const ToastWrapper = defineComponent({
components: {
Toaster,
Toast,
},
inheritAttrs: false,
template: `<Toaster :portal="false">
<Toast v-bind="$attrs">
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</Toast>
</Toaster>`,
})
describe('toast', () => {
const props = { title: 'Toast' }
it.each<[string, RenderOptions<typeof Toast & typeof Toaster>]>([
// Props
['with title', { props }],
['with description', { props: { ...props, description: 'This is a toast' } }],
['with icon', { props: { ...props, icon: 'i-lucide-rocket' } }],
['with avatar', { props: { ...props, avatar: { src: 'https://github.com/vue.png' } } }],
['with actions', { props: { ...props, actions: [{ label: 'Action' }] } }],
['with orientation vertical', { props: { ...props, icon: 'i-lucide-rocket', description: 'This is a toast', actions: [{ label: 'Action' }], orientation: 'vertical' as const } }],
['with orientation horizontal', { props: { ...props, icon: 'i-lucide-rocket', description: 'This is a toast', actions: [{ label: 'Action' }], orientation: 'horizontal' as const } }],
['without close', { props: { ...props, close: false } }],
['with type', { props: { ...props, type: 'background' as const } }],
['with color neutral', { props: { ...props, color: 'neutral' as const } }],
['with as', { props: { ...props, as: 'section' } }],
['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)/50' } }],
['with ui', { props: { ...props, ui: { title: 'font-bold' } } }],
// Slots
['with leading slot', { props, slots: { leading: () => 'Leading slot' } }],
['with title slot', { props, slots: { title: () => 'Title slot' } }],
['with description slot', { props, slots: { description: () => 'Description slot' } }],
['with close slot', { props, slots: { close: () => 'Close slot' } }],
])('renders %s correctly', async (name, options) => {
const { html } = render(ToastWrapper, {
...options,
})
await new Promise(resolve => setTimeout(resolve, 0))
expect(html()).toMatchSnapshot()
})
})