Vue Toaster Component

Toaster

Create toast notifications from anywhere in a Vue app with the CoreUI Toaster: a store keyed by id with add, update, close and promise, a placed container, a visible-toasts limit and screen-reader priority.

Available in Other JavaScript Frameworks

CoreUI Vue Toaster Component is also available for Bootstrap and React. Explore framework-specific implementations below:

Overview

Added in v6.0.0

createToaster() creates a store of toasts that lives outside the component tree, so a toast can be added from anywhere — a composable, a Pinia action, a fetch wrapper — without any markup up front. <CToaster :toaster="toaster" /> renders a <CToast> for every entry, keyed by id, and one pair serves the whole app: add() returns the id, update() rewrites a toast in place, close() hides one or all of them, and promise() follows an asynchronous operation through its loading, success and error states.

import { createToaster } from '@coreui/vue'

export const toaster = createToaster({ placement: 'top-end', limit: 3 })

const id = toaster.add({ title: 'Saved', description: 'Your changes are live.', theme: 'success' })
toaster.update(id, { description: 'Done' })
toaster.close(id)

The store carries the placement, so <CToaster :toaster="toaster" /> needs nothing else; the container is a region landmark named Notifications, and users of assistive technology can navigate to it.

Live example

Two toasters, each with its container in a top corner of the viewport: a list on the end side and a stack on the start side. Every other example on this page renders its container inside the example, so its toasts stay put.

vue
<template>
  <CButton theme="primary" @click="addToast">Add a toast</CButton>
  <CButton theme="secondary" @click="addToStack">Add to the stack</CButton>
  <CToaster :toaster="toaster" />
  <CToaster :toaster="stacked" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const toaster = createToaster({ placement: 'top-end' })
const stacked = createToaster({ placement: 'top-start', stack: true, limit: 6 })

const addToast = () => {
  toaster.add({
    title: 'CoreUI for Vue.js',
    description: 'Hello, world! This is a toast message.',
  })
}

const addToStack = () => {
  stacked.add({
    title: 'CoreUI for Vue.js',
    description: 'Hello, world! This one joins a stack.',
  })
}
</script>

Title, description and theme

A toast is a plain options object. title renders the header with a close button, description the body; a toast without a title puts the close button in the body. theme adds the theme-{color} class, priority: 'high' has screen readers announce it right away, timeout overrides the toaster’s default (0 keeps the toast until it is closed) and dismissible: false drops the close button.

vue
<template>
  <div class="hstack gap-3 flex-wrap mb-4">
    <CButton theme="primary" @click="addTitled">Title and description</CButton>
    <CButton theme="secondary" @click="addDescription">Description only</CButton>
    <CButton theme="danger" @click="addThemed">Themed, high priority</CButton>
    <CButton theme="secondary" variant="outline" @click="addSticky">Not dismissible</CButton>
  </div>
  <CToaster :toaster="toaster" class="position-static" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const toaster = createToaster({ timeout: 0 })

const addTitled = () => toaster.add({ title: 'Saved', description: 'Your changes are live.' })

const addDescription = () => toaster.add({ description: 'Copied to the clipboard' })

const addThemed = () =>
  toaster.add({
    title: 'Payment failed',
    description: 'The card was declined.',
    theme: 'danger',
    priority: 'high',
  })

const addSticky = () =>
  toaster.add({
    title: 'Read me',
    description: 'This one stays until you close it.',
    dismissible: false,
    timeout: 0,
  })
</script>

title and description take a string or a VNode; for a body of your own pass render: (toast) => VNode and the toast object — id, data, updateKey and every option — is yours to read. Components rendered inside a <CToaster> reach the store with useToaster().

Updating a toast

Passing an id that already exists updates that toast instead of adding a second one, and bumps its updateKey, so a “Draft saved” toast clicked twice stays a single toast. update() does the same for a toast whose id you kept, and accepts a function of the current toast when the change depends on it: toaster.update(id, (previous) => ({ description: previous.data.step + 1 })). A toast that is already closing is not reused: adding its id again removes it at once and adds a new toast, which takes the newest place in the container.

vue
<template>
  <div class="hstack gap-3 flex-wrap mb-4">
    <CButton theme="primary" @click="saveDraft">Save draft</CButton>
    <CButton theme="secondary" @click="upload">Upload with progress</CButton>
  </div>
  <CToaster :toaster="toaster" class="position-static" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const toaster = createToaster({ timeout: 0 })
let saves = 0

const saveDraft = () => {
  saves += 1
  toaster.add({
    id: 'draft',
    title: 'Draft saved',
    description: `Saved ${saves} time${saves === 1 ? '' : 's'}`,
  })
}

const upload = () => {
  const id = toaster.add({ title: 'Uploading…', description: '0 %' })
  let progress = 0

  const interval = setInterval(() => {
    progress += 25
    toaster.update(id, { description: `${progress} %` })

    if (progress === 100) {
      clearInterval(interval)
      toaster.update(id, { title: 'Uploaded', theme: 'success' })
    }
  }, 500)
}
</script>

Promises

promise() adds a toast in the loading state and updates it when the promise settles: success receives the resolved value, error the rejection. Each state is a description string, a full options object, or a function returning either.

vue
<template>
  <CButton theme="primary" class="mb-4" @click="save">Save</CButton>
  <CToaster :toaster="toaster" class="position-static" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const toaster = createToaster({ timeout: 0 })

const save = () => {
  const request = new Promise((resolve, reject) => {
    setTimeout(
      () => (Math.random() > 0.3 ? resolve('3 files') : reject(new Error('Network error'))),
      2000
    )
  })

  toaster
    .promise(request, {
      loading: 'Saving…',
      success: (files) => `Saved ${files}`,
      error: (error) => ({ title: 'Not saved', description: error.message, priority: 'high' }),
    })
    .catch(() => {})
}
</script>

Action

action renders a button in the body; its onClick receives the click event and the toast object, so an undo can rewrite the same toast.

vue
<template>
  <CButton theme="primary" class="mb-4" @click="archive">Archive message</CButton>
  <CToaster :toaster="toaster" class="position-static" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const toaster = createToaster({ timeout: 0 })

const archive = () =>
  toaster.add({
    description: 'Message archived',
    action: {
      label: 'Undo',
      onClick: (event, toast) =>
        toaster.update(toast.id, { description: 'Message restored', action: null }),
    },
  })
</script>

Limit

limit caps how many toasts are visible at once. The newest stay on screen; older ones sink away from the edge and fade, remain in the DOM with inert and data-coreui-limited, and rise back in as the visible ones go away. limit: 0 shows everything.

vue
<template>
  <div class="hstack gap-3 mb-4">
    <CButton theme="primary" @click="addToast">Add a toast</CButton>
    <CButton theme="secondary" variant="outline" @click="toaster.close()">Close all</CButton>
  </div>
  <CToaster :toaster="toaster" class="position-static" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const toaster = createToaster({ limit: 2, timeout: 0 })
let count = 0

const addToast = () => {
  count += 1
  toaster.add({ title: `Toast ${count}`, description: 'Only two stay visible.' })
}
</script>

Stacking

With stack: true the toasts sit on top of each other: the newest in front at full size, the ones behind it scaled down by --cui-toast-stack-scale per step and receding towards a vanishing depth. The first toast behind peeks out by --cui-toast-stack-peek and every further one by that strip times the scale ratio, so the corners of the stack line up like a deck of cards seen in perspective. Only the front toast’s timer runs; the ones behind wait their turn and start counting when they reach the front, so the stack empties from the front. A toast that leaves from behind — closed by id, or pushed over the limit — fades where it is instead of sliding out from under the deck. Hovering or focusing the container spreads the stack into a list; leaving it folds the stack back. The stack takes the place of the flow layout; limit decides how many toasts it holds. Folded, the deck shows the front toast and two behind it (--cui-toast-stack-visible); spreading it reveals every toast within the limit.

vue
<template>
  <div class="hstack gap-3 mb-4">
    <CButton theme="primary" @click="addToast">Add a toast</CButton>
    <CButton theme="secondary" variant="outline" @click="toaster.close()">Close all</CButton>
  </div>
  <CToaster :toaster="toaster" class="position-relative" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const toaster = createToaster({ stack: true, limit: 0, timeout: 0 })
let count = 0

const addToast = () => {
  count += 1
  toaster.add({
    title: `Toast ${count}`,
    description: count % 2 ? 'Hover or focus the stack to spread it out.' : 'A shorter one.',
  })
}
</script>

<CToaster> keeps the geometry in tokens the CSS reads: --cui-toast-stack-index, --cui-toast-stack-before (the height of the toasts in front) and --cui-toast-height on each toast, --cui-toast-stack-count, --cui-toast-stack-front-height and --cui-toast-stack-heights on the container. Style the stack by these — the data-coreui-stack-index and data-coreui-stack-hidden attributes mark a toast’s place in it.

A stack works in every placement: it grows away from the edge it is pinned to, so at the bottom the newest toast is the lowest one and the stack peeks upward.

vue
<template>
  <div class="d-grid gap-3" style="grid-template-columns: repeat(3, 1fr); max-width: 30rem">
    <CButton
      v-for="placement in placements"
      :key="placement"
      theme="secondary"
      variant="outline"
      @click="addToast(placement)"
    >
      {{ placement }}
    </CButton>
  </div>
  <CToaster v-for="placement in placements" :key="placement" :toaster="stacks[placement]" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const placements = [
  'top-start',
  'top-center',
  'top-end',
  'middle-start',
  'middle-center',
  'middle-end',
  'bottom-start',
  'bottom-center',
  'bottom-end',
]

const stacks = Object.fromEntries(
  placements.map((placement) => [placement, createToaster({ placement, stack: true, limit: 0 })])
)

const addToast = (placement) => {
  const toaster = stacks[placement]

  toaster.add({
    title: `${placement} · ${toaster.getToasts().length + 1}`,
    description: 'Hover the stack to spread it.',
  })
}
</script>

Motion

A toast slides in from beyond the container’s edge while it fades in — from the side in the start and end placements, from above in top-center and from below in bottom-center — and leaves the way it came, so the entry and the exit read as one gesture. middle-center sits in the middle of the screen with no edge to come from, so its toasts only fade. While a toast leaves, the ones behind it glide into the freed space; when a new one arrives, they make room the same way. enter picks another edge for the entry and leave for the exit — a top-end toaster can drop its toasts in from above and still send them out sideways — and a toast outside a placed container slides by --cui-toast-slide instead. start and end are logical: they follow the direction the container sits in, so under dir="rtl" they change sides along with the placement. All of it respects prefers-reduced-motion.

const toaster = createToaster({ placement: 'top-end', enter: 'top', leave: 'end' })

Instant

A toast with instant: true skips all of it — no slide, no fade, no glide of its neighbours, no stack transition — and so does every toast when the user prefers reduced motion: the stylesheet emits no transitions under prefers-reduced-motion: reduce and the toaster skips its position animations, so toasts appear, move and vanish in place.

vue
<template>
  <div class="hstack gap-3 mb-4">
    <CButton theme="primary" @click="addInstant">Add an instant toast</CButton>
    <CButton theme="secondary" @click="addAnimated">Add an animated toast</CButton>
  </div>
  <CToaster :toaster="toaster" class="position-static" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const toaster = createToaster({ timeout: 3000 })

const addInstant = () =>
  toaster.add({ title: 'Instant', description: 'No slide, no fade, no glide.', instant: true })

const addAnimated = () =>
  toaster.add({ title: 'Animated', description: 'Slides in and glides the others.' })
</script>

Placement

placement pins the container to the viewport with one of the nine .toast-container-{block}-{inline} classes: top, middle or bottom × start, center or end. In the top placements a new toast is inserted first, in the others last, so the newest always sits closest to the edge. The placement property of <CToaster> overrides the store’s for that container.

Each button below adds a toast through a toaster placed at that spot, so you can see where a toast enters from: from the side in the start and end columns, from above in top-center and from below in bottom-center, while middle-center fades in place.

vue
<template>
  <div class="d-grid gap-3" style="grid-template-columns: repeat(3, 1fr); max-width: 30rem">
    <CButton
      v-for="placement in placements"
      :key="placement"
      theme="secondary"
      variant="outline"
      @click="addToast(placement)"
    >
      {{ placement }}
    </CButton>
  </div>
  <CToaster v-for="placement in placements" :key="placement" :toaster="toasters[placement]" />
</template>

<script setup>
import { CButton, CToaster, createToaster } from '@coreui/vue'

const placements = [
  'top-start',
  'top-center',
  'top-end',
  'middle-start',
  'middle-center',
  'middle-end',
  'bottom-start',
  'bottom-center',
  'bottom-end',
]

const toasters = Object.fromEntries(
  placements.map((placement) => [placement, createToaster({ placement })])
)

const addToast = (placement) =>
  toasters[placement].add({ title: placement, description: 'Slides in from the nearest edge.' })
</script>

Render the container where you want the toasts to land — a position-static or position-relative class keeps it inside the layout instead of pinning it to the viewport:

<CToaster :toaster="toaster" class="position-static" />

Accessibility

The container is a region landmark named by ariaLabel (Notifications by default), so screen-reader users can jump to it through landmark navigation. Every toast is read through announce() when it is added or updated: the string parts of its title and description go to the polite live region of the page, or to the assertive one with priority: 'high', as a message of their own, so toasts raised together are each announced instead of the last one overwriting the others. The store sends the message once and it belongs to the page, not to <CToaster>, so a container mounted again on the same store, for example after a route change, does not add the messages of older toasts again, a toast added together with a route change is announced once, and while a modal dialog is open the message goes to a region inside the dialog. Updating or removing a toast takes its message out of the region. Only that text is announced, never the close button or the action, and the toast element itself carries no live role. A toast added while a modal dialog plays its closing transition, or from the modal’s close event, is announced on the page half a second after the dialog has closed, once the page is back for the screen reader; Announcing messages has the measurements. The close button <CToaster> renders is named by ariaCloseLabel (Close by default); set it once for the whole toaster in a translated interface, and name the close button of a render body yourself. normal, the default, waits until the user is idle, unless the toast is added together with a high one, which takes both to the assertive region; keep high for the few messages that should interrupt, such as a failed save (WCAG 4.1.3 Status Messages).

Auto-hiding is a time limit on reading, which WCAG 2.2.1 Timing Adjustable asks you to let the user turn off, adjust or extend. Timers stop while the pointer or focus is inside the container (pauseOnHover), so a spread stack or a toast being read does not disappear mid-way, and the default timeout is the toaster’s to set for the whole app. A toast with an action, or one the user must act on, should not hide on its own: give it timeout: 0, so keyboard and screen-reader users can reach the button. Toasts never take focus.

Usage

Create a toaster once, outside the component tree, and export it; every toast in the app goes through it. Mount one <CToaster> for it — in the app shell, so it survives route changes.

// toaster.ts
import { createToaster } from '@coreui/vue'

export const toaster = createToaster({ placement: 'bottom-end', limit: 3 })
<template>
  <RouterView />
  <CToaster :toaster="toaster" />
</template>

<script setup>
import { CToaster } from '@coreui/vue'
import { toaster } from './toaster'
</script>

useToaster() returns the store to any component rendered inside a <CToaster>, so a custom body can close or update the toast it belongs to.

Options

Options of createToaster().

PropertyTypeDefault
ariaCloseLabelstring'Close'

Accessible name of the close button <CToaster> renders in every toast; a render body names its own.

ariaLabelstring'Notifications'

Accessible name of the container’s region landmark.

enterstring'auto'

Edge a toast slides in from: 'start', 'end', 'top' or 'bottom'. 'start' and 'end' are logical and follow the direction the container sits in. 'auto' follows the placement — the side in the start and end columns, above in top-center, below in bottom-center, and no slide at all in middle-center.

leavestring'auto'

Edge a toast slides out to, taking the same values as enter. 'auto' is the edge it came in from.

limitnumber3

How many toasts stay visible; the oldest beyond it are hidden and inert until a slot frees. 0 disables the limit.

pauseOnHoverbooleantrue

Pause every toast’s timer while the pointer or focus is inside the container, and resume when it leaves. Each toast still pauses on its own hover.

placementstring'top-end'

Corner or edge of the viewport for the container: top, middle or bottom × start, center or end. Toasts in the top placements are inserted first, in the others last, so the newest always sits closest to the edge.

stackbooleanfalse

Stack the toasts on top of each other, newest in front, and spread them out on hover or focus.

timeoutnumber5000

Default time (ms) before a toast hides. 0 keeps toasts until they are closed.

Toast options

Options of add(), update() and the toast objects getToasts() returns.

PropertyTypeDefault
actionobject, null—

{ label, onClick(event, toast) } renders a button in the body.

classstring—

Extra classes on the .toast.

dataobject—

Anything you want to read back from the toast object later, e.g. in update(id, (previous) => …).

descriptionstring, VNode—

Body of the toast.

dismissiblebooleantrue

Render the close button.

idstringgenerated

Stable id; adding with an existing id updates that toast in place, unless it is already closing, in which case a new toast replaces it.

instantbooleanfalse

Skip the fade (.toast-instant).

onClosefunction—

Called when the toast starts hiding, with the toast object.

onRemovefunction—

Called after the toast left the DOM, with the toast object.

prioritystring'normal'

Which live region announces the toast, as in announce(): 'normal' the polite one, 'high' the assertive one, which also takes the normal toasts added together with it.

renderfunction—

(toast) => VNode renders the body of the toast yourself, instead of the header and body built from title, description and action.

themestring—

Adds theme-{color}.

timeoutnumbertoaster’s timeout

Time (ms) before this toast hides; 0 keeps it.

titlestring, VNode—

Header of the toast, with the close button next to it.

translucentbooleanfalse

Adds .toast-translucent.

A toast object in the store carries id, updateKey, limited, open and order next to the options it was created with.

Methods

MethodDescription
add(options)Adds a toast and returns its id. With an id that already exists it behaves like update, unless that toast is already closing.
update(id, options)Rewrites the toast in place and increments its updateKey. options may be a function receiving the current toast object and returning the changes.
close(id?)Hides the toast, which removes it from the store once the fade ends. Without an id, hides every toast.
remove(id)Drops the toast from the store at once, without the exit transition. <CToaster> calls it when a toast finished hiding.
promise(promise, { loading, success, error })Adds a toast in the loading state and updates it when the promise settles: success gets the resolved value, error the rejection. Each state is a description string or a full options object, or a function returning either. Returns the promise.
pause() / resume()Stop and restart every toast’s timer; pauseOnHover calls these for the container.
getToasts()The toast objects in render order.
toastsThe reactive shallowRef behind getToasts(), to read the toasts in a computed of your own.

Events

The store reports through the callbacks of a toast — onClose when it starts hiding, onRemove once it left the DOM — and every rendered toast still emits the <CToast> events on its own component.

Customizing

CSS variables

<CToaster> renders regular toasts, so their tokens apply — see the toast CSS variables. Four are its own: --cui-toast-container-inset on the container sets the distance from the viewport edge, --cui-toast-translate on the container is where a toast enters from and leaves to, overridden separately by --cui-toast-enter-translate and --cui-toast-leave-translate (the enter and leave options write those; set them yourself for any other offset), --cui-toast-inline-sign on the container is the 1 or -1 those inline offsets are multiplied by, which is how start and end follow the direction, and --cui-toast-limited-translate on a toast sets where a toast over the limit sinks to.

scss
--cui-toast-zindex: var(--cui-z-toast);
--cui-toast-container-inset: #{var(--cui-spacer-5)};
--cui-toast-inline-sign: 1;
--cui-toast-max-width: #{350px};
--cui-toast-spacing: #{$container-padding-x};
--cui-toast-transition-duration: #{.3s};
--cui-toast-transition-timing: #{ease-out};

The stack’s peek, scale step and folded count are tokens on .toast-container-stack:

scss
.toast-container-stack {
  --cui-toast-stack-peek: .75rem;
  --cui-toast-stack-scale: .1;
  // Every toast behind is scaled by the same ratio again and pushed towards a vanishing depth,
  // so the corners of the stack line up and each strip is the previous one times the ratio
  --cui-toast-stack-visible: 3;
  --cui-toast-stack-ratio: calc(1 - var(--cui-toast-stack-scale));
  --cui-toast-stack-depth: calc(var(--cui-toast-stack-front-height) + var(--cui-toast-stack-peek) / var(--cui-toast-stack-scale));
  --cui-toast-stack-last: calc(min(var(--cui-toast-stack-count), var(--cui-toast-stack-visible)) - 1);

  width: var(--cui-toast-max-width);
  max-width: 100%;
  height: calc(var(--cui-toast-stack-depth) * (1 - pow(var(--cui-toast-stack-ratio), var(--cui-toast-stack-last))) + var(--cui-toast-stack-front-height) * pow(var(--cui-toast-stack-ratio), var(--cui-toast-stack-last)));
  pointer-events: auto;
  @include transition-props(height, var(--cui-toast-transition-duration), var(--cui-toast-transition-timing));

  > .toast {
    position: absolute;
    inset-block-start: calc(var(--cui-toast-stack-depth) * (1 - pow(var(--cui-toast-stack-ratio), var(--cui-toast-stack-index))));
    inset-inline: 0;
    z-index: calc(var(--cui-toast-stack-count) - var(--cui-toast-stack-index));
    height: var(--cui-toast-stack-front-height);
    margin-block-end: 0;
    overflow: hidden;
    transform-origin: top center;
    scale: pow(var(--cui-toast-stack-ratio), var(--cui-toast-stack-index));

    &:not(.toast-instant) {
      @include transition-props(
        #{"opacity, translate, display, inset-block-start, height, scale"},
        var(--cui-toast-transition-duration),
        var(--cui-toast-transition-timing),
        allow-discrete
      );

      > * {
        @include transition-props(opacity, var(--cui-toast-transition-duration), var(--cui-toast-transition-timing));
      }
    }
  }

  > .toast:not([data-coreui-stack-index="0"]) {
    --cui-toast-leave-translate: 0 0;
    --cui-toast-limited-translate: 0 0;

    > * {
      opacity: 0;
    }
  }

  > .toast[data-coreui-stack-hidden] {
    pointer-events: none;
    opacity: 0;
  }

  &:is(:hover, :focus-within) {
    height: calc(var(--cui-toast-stack-heights) + (var(--cui-toast-stack-count) - 1) * var(--cui-toast-spacing));

    > .toast {
      inset-block-start: calc(var(--cui-toast-stack-before) + var(--cui-toast-stack-index) * var(--cui-toast-spacing));
      height: var(--cui-toast-height);
      scale: 1;

      > * {
        opacity: 1;
      }
    }

    > .toast[data-coreui-stack-hidden]:not([data-coreui-limited]) {
      pointer-events: auto;
      opacity: 1;
    }
  }
}

.toast-container-bottom-start.toast-container-stack,
.toast-container-bottom-center.toast-container-stack,
.toast-container-bottom-end.toast-container-stack {
  > .toast {
    inset-block-start: auto;
    inset-block-end: calc(var(--cui-toast-stack-depth) * (1 - pow(var(--cui-toast-stack-ratio), var(--cui-toast-stack-index))));
    transform-origin: bottom center;

    &:not(.toast-instant) {
      @include transition-props(
        #{"opacity, translate, display, inset-block-end, height, scale"},
        var(--cui-toast-transition-duration),
        var(--cui-toast-transition-timing),
        allow-discrete
      );
    }
  }

  &:is(:hover, :focus-within) > .toast {
    inset-block-end: calc(var(--cui-toast-stack-before) + var(--cui-toast-stack-index) * var(--cui-toast-spacing));
  }
}

API