Toast
Brief notifications fired from anywhere with toast(); mount one Toaster to show them.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add toastpnpm dlx ermineui add toastyarn dlx ermineui add toastbunx ermineui add toastThat copies 3 files into src/components/ui/toast/, 1 helper into src/lib/ermine/. Files you already have are kept.
Use
import { Toaster, dismissToast, toast, toasts } from '@/components/ui/toast'<Toaster /> <!-- once, near the root of the app -->In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
Toaster.vue | src/components/ui/toast/Toaster.vue |
index.ts | src/components/ui/toast/index.ts |
toast.ts | src/components/ui/toast/toast.ts |
primitive.ts helper | src/lib/ermine/primitive.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Toaster.vue 289 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Portal } from '../../lib/primitive'
import { dismissToast, toasts, type ToastItem } from './toast'
const props = withDefaults(
defineProps<{
position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
/** Accessible name of the notification region. */
label?: string
/** Accessible name of each toast's dismiss button. */
dismissLabel?: string
}>(),
{ position: 'bottom-right', label: 'Notifications', dismissLabel: 'Dismiss' },
)
// The newest toast sits nearest the screen edge.
const ordered = computed(() => (props.position.startsWith('top') ? [...toasts].reverse() : toasts))
// Timers pause while the pointer or keyboard focus is on the toasts, so nothing vanishes mid-read.
const hovered = ref(false)
const focused = ref(false)
const paused = computed(() => hovered.value || focused.value)
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget
focused.value = next instanceof Node && (event.currentTarget as HTMLElement).contains(next)
}
const timers = new Map<string | number, ReturnType<typeof setTimeout>>()
function sync() {
for (const [id, timer] of timers) {
if (paused.value || !toasts.some((toast) => toast.id === id)) {
clearTimeout(timer)
timers.delete(id)
}
}
if (paused.value) return
for (const toast of toasts) {
if (timers.has(toast.id) || !Number.isFinite(toast.duration) || toast.duration <= 0) continue
timers.set(
toast.id,
setTimeout(() => dismissToast(toast.id), toast.duration),
)
}
}
watch([() => toasts.map((toast) => toast.id).join(), paused], sync, { immediate: true })
onBeforeUnmount(() => timers.forEach((timer) => clearTimeout(timer)))
function runAction(toast: ToastItem) {
toast.action?.onClick()
dismissToast(toast.id)
}
</script>
<template>
<Portal>
<section
class="er-toaster"
:aria-label="label"
:data-position="position"
data-er-layer-ignore
@pointerenter="hovered = true"
@pointerleave="hovered = false"
@focusin="focused = true"
@focusout="onFocusOut"
>
<TransitionGroup name="er-toast" tag="ol" class="er-toast-list" aria-live="polite" aria-relevant="additions text">
<li v-for="toast in ordered" :key="toast.id" class="er-toast" :data-variant="toast.variant">
<svg
v-if="toast.variant !== 'default'"
class="er-toast-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="toast.variant === 'success'">
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.5 2.5 4.5-5.5" />
</template>
<template v-else-if="toast.variant === 'warning'">
<path d="M12 4 2.8 19.5h18.4L12 4Z" />
<path d="M12 10v4M12 17h.01" />
</template>
<template v-else-if="toast.variant === 'error'">
<circle cx="12" cy="12" r="9" />
<path d="M12 7.5v5M12 16h.01" />
</template>
<template v-else>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5.5M12 7.5h.01" />
</template>
</svg>
<div class="er-toast-body">
<p class="er-toast-title">{{ toast.title }}</p>
<p v-if="toast.description" class="er-toast-description">{{ toast.description }}</p>
</div>
<button v-if="toast.action" type="button" class="er-toast-action" @click="runAction(toast)">
{{ toast.action.label }}
</button>
<button type="button" class="er-toast-close" :aria-label="dismissLabel" @click="dismissToast(toast.id)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</li>
</TransitionGroup>
</section>
</Portal>
</template>
<style>
@layer components {
.er-toaster {
position: fixed;
inset: 0;
z-index: var(--er-z-toast);
display: flex;
padding: 1rem;
pointer-events: none;
}
.er-toaster[data-position^='top'] {
align-items: flex-start;
}
.er-toaster[data-position^='bottom'] {
align-items: flex-end;
}
.er-toaster[data-position$='left'] {
justify-content: flex-start;
}
.er-toaster[data-position$='center'] {
justify-content: center;
}
.er-toaster[data-position$='right'] {
justify-content: flex-end;
}
.er-toast-list {
position: relative;
width: min(22rem, 100%);
margin: 0;
padding: 0;
list-style: none;
}
.er-toast + .er-toast {
margin-top: 0.5rem;
}
.er-toast {
display: flex;
align-items: flex-start;
gap: 0.625rem;
width: 100%;
padding: 0.875rem 1rem;
border: 1px solid var(--er-border);
border-radius: var(--er-radius-lg);
background: var(--er-popover);
color: var(--er-popover-foreground);
font-size: 0.875rem;
box-shadow: var(--er-shadow-lg);
pointer-events: auto;
}
.er-toast-icon {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
margin-top: 0.0625rem;
}
.er-toast[data-variant='success'] .er-toast-icon {
color: var(--er-success);
}
.er-toast[data-variant='error'] .er-toast-icon {
color: var(--er-destructive);
}
.er-toast[data-variant='warning'] .er-toast-icon {
color: var(--er-warning);
}
.er-toast[data-variant='info'] .er-toast-icon {
color: var(--er-info);
}
.er-toast-body {
flex: 1;
min-width: 0;
}
.er-toast-title,
.er-toast-description {
margin: 0;
line-height: 1.35;
overflow-wrap: anywhere;
}
.er-toast-title {
font-weight: var(--er-weight-medium);
}
.er-toast-description {
margin-top: 0.125rem;
color: var(--er-muted-foreground);
}
.er-toast-action {
flex-shrink: 0;
align-self: center;
height: 1.75rem;
padding: 0 0.625rem;
border: 0;
border-radius: var(--er-radius-sm);
background: var(--er-primary);
color: var(--er-primary-foreground);
font: inherit;
font-size: 0.8125rem;
font-weight: var(--er-weight-medium);
cursor: pointer;
}
.er-toast-close {
flex-shrink: 0;
display: grid;
place-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
color: var(--er-muted-foreground);
cursor: pointer;
}
.er-toast-close:hover {
color: var(--er-foreground);
}
.er-toast-close svg {
width: 0.875rem;
height: 0.875rem;
}
.er-toast-action:focus-visible,
.er-toast-close:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-toast-enter-active,
.er-toast-leave-active,
.er-toast-move {
transition:
opacity calc(var(--er-duration) * 1.5) var(--er-ease),
transform calc(var(--er-duration) * 1.5) var(--er-ease);
}
/* Leaving toasts step out of the flow so the rest can slide into place. */
.er-toast-leave-active {
position: absolute;
}
.er-toast-enter-from,
.er-toast-leave-to {
opacity: 0;
transform: translateY(0.75rem);
}
.er-toaster[data-position^='top'] .er-toast-enter-from,
.er-toaster[data-position^='top'] .er-toast-leave-to {
transform: translateY(-0.75rem);
}
}
</style>index.ts 5 lines
/** Toast — brief notifications fired from anywhere with toast(); mount one Toaster to show them. */
export { default as Toaster } from './Toaster.vue'
export { dismissToast, toast, toasts } from './toast'
export type { ToastItem, ToastOptions, ToastVariant } from './toast'toast.ts 63 lines
import { reactive } from 'vue'
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'
export interface ToastOptions {
/** Reuse an id to replace a toast that is already showing. */
id?: string | number
description?: string
variant?: ToastVariant
/** Milliseconds before it dismisses itself. Use Infinity to keep it until dismissed. */
duration?: number
action?: { label: string; onClick: () => void }
}
export interface ToastItem extends ToastOptions {
id: string | number
title: string
variant: ToastVariant
duration: number
}
/** The toasts currently on screen. <Toaster /> renders this list. */
export const toasts = reactive<ToastItem[]>([])
let count = 0
function show(title: string, options: ToastOptions = {}): string | number {
const id = options.id ?? ++count
// Toasts are a browser-only concern; on the server this is a no-op.
if (typeof window === 'undefined') return id
const item: ToastItem = { variant: 'default', duration: 4000, ...options, id, title }
const existing = toasts.findIndex((toast) => toast.id === id)
if (existing > -1) toasts.splice(existing, 1, item)
else toasts.push(item)
return id
}
/** Dismiss one toast by id, or all of them when called without one. */
export function dismissToast(id?: string | number) {
if (id === undefined) {
toasts.splice(0)
return
}
const index = toasts.findIndex((toast) => toast.id === id)
if (index > -1) toasts.splice(index, 1)
}
const withVariant = (variant: ToastVariant) => (title: string, options?: ToastOptions) =>
show(title, { ...options, variant })
/**
* Show a toast from anywhere: `toast('Saved')`, `toast.error('Upload failed', { description })`.
* Needs one `<Toaster />` mounted somewhere in the app.
*/
export const toast = Object.assign(show, {
success: withVariant('success'),
error: withVariant('error'),
warning: withVariant('warning'),
info: withVariant('info'),
dismiss: dismissToast,
})