Skip to content

Toast

Toast shows short, non-blocking messages for confirmations, failures, and long-running work.

Toast.vue

Installation

One command detects Vue, React, or Svelte and adds Toast:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add toast

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

The command creates toast/toast.js and toast/Toast.vue, Toast.jsx, or Toast.svelte under the conventional components directory. Both files immediately belong to the application. There is no Klean runtime dependency, klean-ui.json, provider wrapper, alias questionnaire, or generated cn.js.

Usage

Mount <Toast /> near the application root. Then call toast() wherever a notification is needed.

Vue

AppLayout.vue
<script setup>
import Toast from '@/components/ui/toast/Toast.vue'
import { toast } from '@/components/ui/toast/toast.js'

function save() {
  toast({
    title: 'Changes saved',
    message: 'Your draft is ready.',
    action: { label: 'View draft', href: '/drafts/42' }
  })
}
</script>

<template>
  <Toast />
  <button type="button" @click="save">Save changes</button>
</template>

React

AppLayout.jsx
import Toast from '@/components/ui/toast/Toast.jsx'
import { toast } from '@/components/ui/toast/toast.js'

export default function App() {
  function save() {
    toast({
      title: 'Changes saved',
      message: 'Your draft is ready.',
      action: { label: 'View draft', href: '/drafts/42' }
    })
  }

  return (
    <>
      <Toast />
      <button type="button" onClick={save}>
        Save changes
      </button>
    </>
  )
}

Svelte

AppLayout.svelte
<script>
  import Toast from '$lib/components/ui/toast/Toast.svelte'
  import { toast } from '$lib/components/ui/toast/toast.js'

  function save() {
    toast({
      title: 'Changes saved',
      message: 'Your draft is ready.',
      action: { label: 'View draft', href: '/drafts/42' }
    })
  }
</script>

<Toast />
<button type="button" onclick={save}>Save changes</button>

API

Calling toast

toast('Changes saved') covers the ordinary case. An object adds structure and application metadata:

js
const id = toast({
  title: 'Changes saved',
  message: 'Your draft is ready.',
  duration: 5000,
  dismissible: true,
  action: { label: 'View draft', href: '/drafts/42' },
  class: 'shadow-xl'
})
InputDefaultPurpose
string inputShorthand for the notification message.
title''Short notification heading.
message''Supporting detail.
duration5000Visible time in milliseconds. Use false or 0 for externally completed work.
dismissibletrueWhether the toast includes its named dismiss button.
action{ label, href } for an anchor or { label, onClick } for a button.
class / className''Tailwind merged last on the notification item.
application metadataAny additional fields needed by custom content, such as progress or status.

The returned ID identifies the same notification throughout its lifetime:

js
toast.update(id, patch)
toast.dismiss(id)
toast.clear()

createToast({ duration, max }) creates an independent toast instance for tests or embedded surfaces. The exported toast remains the zero-configuration application default.

Toast props

InputDefaultPurpose
controllershared toastAn optional independent toast instance.
positiontop-righttop-left, top-center, top-right, bottom-left, bottom-center, or bottom-right.
fromnearest horizontal edgeEntry direction: left, right, top, bottom, fade, or none.
tonearest horizontal edgeExit direction using the same values.
labelNotificationsAccessible name for the persistent live region.
class / classNameTailwind for the viewport shelf. Item styling belongs to toast({ class }).
default contentbuilt-in bodyVue scoped slot, React function child, or Svelte snippet receiving { item, dismiss }.

There is deliberately no variant, tone, type, motion-duration, easing, icon, progress, or theme prop.

Semantic actions

An action with href renders a real anchor. An action with onClick renders a real button type="button". Both dismiss after activation, and action.class accepts ordinary Tailwind.

toast-actions.vue

Motion

Toast enters and leaves toward the nearest edge by default. Use from and to when the notification should travel in another direction. Reduced-motion preferences are respected automatically.

toast-motion.vue

Long-running work

Use duration: false when an external event controls completion. Keep the returned ID and update the same notification instead of adding one toast for every status message.

deployment-toast.vue

Product styling

The built-in body is deliberately neutral and never maps success, error, or another type to color or iconography. Pass Tailwind through class, or replace the complete body when the product needs icons, actions, progress, or a different structure.

product-toasts.vue

Accessibility and Durable UI contract

  • New notifications are announced politely and never steal focus.
  • Timers pause while a toast is hovered or focused, and while the page is inactive. They resume with the remaining time.
  • Dismiss controls have specific accessible names. Actions use real links or buttons.
  • update() keeps long-running work in one notification.
  • Reduced-motion preferences are respected.
  • Notifications are temporary and are never persisted in storage or the URL.

Complete framework source

Copy, inspect, and change the complete source for your framework.

Controller

toast.js
const DEFAULT_DURATION = 5000
const DEFAULT_MAX = 4
const ENTER_FALLBACK = 500
const LEAVE_FALLBACK = 450

function normalizeDuration(value, fallback) {
  if (value === false || value === 0) return 0

  const duration = Number(value)
  return Number.isFinite(duration) && duration > 0 ? duration : fallback
}

export function createToast({
  duration = DEFAULT_DURATION,
  max = DEFAULT_MAX
} = {}) {
  const listeners = new Set()
  const timers = new Map()
  const lifecycleTimers = new Map()
  const pauseReasons = new Map()
  const globalPauseReasons = new Set()
  let items = []
  let nextId = 0

  function getSnapshot() {
    return items
  }

  function subscribe(listener) {
    listeners.add(listener)
    return () => listeners.delete(listener)
  }

  function publish(nextItems) {
    items = nextItems
    for (const listener of listeners) listener()
  }

  function clearTimer(id) {
    const timer = timers.get(id)
    if (!timer?.timeout) return

    clearTimeout(timer.timeout)
    timer.timeout = null
  }

  function clearLifecycleTimer(id) {
    const timeout = lifecycleTimers.get(id)
    if (timeout) clearTimeout(timeout)
    lifecycleTimers.delete(id)
  }

  function reasonsFor(id) {
    if (!pauseReasons.has(id)) {
      pauseReasons.set(id, new Set(globalPauseReasons))
    }

    return pauseReasons.get(id)
  }

  function schedule(id) {
    const timer = timers.get(id)
    const item = items.find((candidate) => candidate.id === id)

    if (
      !timer ||
      !item ||
      item.state === 'closing' ||
      timer.remaining <= 0 ||
      reasonsFor(id).size > 0
    ) {
      return
    }

    timer.startedAt = Date.now()
    timer.timeout = setTimeout(() => dismiss(id), timer.remaining)
  }

  function resetTimer(id, itemDuration) {
    clearTimer(id)
    timers.delete(id)

    if (!itemDuration) return

    timers.set(id, {
      remaining: itemDuration,
      startedAt: 0,
      timeout: null
    })
    schedule(id)
  }

  function completeEnter(id) {
    clearLifecycleTimer(id)
    const index = items.findIndex((item) => item.id === id)
    if (index === -1 || items[index].state !== 'entering') return false

    const nextItems = [...items]
    nextItems[index] = { ...nextItems[index], state: 'open' }
    publish(nextItems)
    return true
  }

  function remove(id) {
    const exists = items.some((item) => item.id === id)
    if (!exists) return false

    clearTimer(id)
    timers.delete(id)
    clearLifecycleTimer(id)
    pauseReasons.delete(id)
    publish(items.filter((item) => item.id !== id))
    return true
  }

  function dismiss(id) {
    const index = items.findIndex((item) => item.id === id)
    if (index === -1 || items[index].state === 'closing') return false

    clearTimer(id)
    timers.delete(id)
    clearLifecycleTimer(id)

    const nextItems = [...items]
    nextItems[index] = { ...nextItems[index], state: 'closing' }
    publish(nextItems)

    lifecycleTimers.set(
      id,
      setTimeout(() => remove(id), LEAVE_FALLBACK)
    )
    return true
  }

  function pause(id, reason = 'interaction') {
    if (!items.some((item) => item.id === id)) return false

    const reasons = reasonsFor(id)
    if (reasons.has(reason)) return false
    reasons.add(reason)

    const timer = timers.get(id)
    if (timer?.timeout) {
      timer.remaining = Math.max(
        0,
        timer.remaining - (Date.now() - timer.startedAt)
      )
      clearTimer(id)
    }

    return true
  }

  function resume(id, reason = 'interaction') {
    const reasons = pauseReasons.get(id)
    if (!reasons?.has(reason)) return false

    reasons.delete(reason)
    schedule(id)
    return true
  }

  function pauseAll(reason = 'page') {
    globalPauseReasons.add(reason)
    for (const item of items) pause(item.id, reason)
  }

  function resumeAll(reason = 'page') {
    globalPauseReasons.delete(reason)
    for (const item of items) resume(item.id, reason)
  }

  function update(id, patch = {}) {
    const index = items.findIndex((item) => item.id === id)
    if (index === -1 || items[index].state === 'closing') return false

    const current = items[index]
    const itemDuration =
      patch.duration === undefined
        ? current.duration
        : normalizeDuration(patch.duration, duration)
    const nextItems = [...items]

    nextItems[index] = {
      ...current,
      ...patch,
      id,
      duration: itemDuration,
      state: current.state
    }
    publish(nextItems)

    if (patch.duration !== undefined) resetTimer(id, itemDuration)
    return true
  }

  function clear() {
    for (const item of [...items]) dismiss(item.id)
  }

  function destroy() {
    for (const id of [...timers.keys()]) clearTimer(id)
    for (const id of [...lifecycleTimers.keys()]) clearLifecycleTimer(id)
    timers.clear()
    pauseReasons.clear()
    globalPauseReasons.clear()
    items = []
    listeners.clear()
  }

  function notify(input = {}, options = {}) {
    const item =
      typeof input === 'string' ? { ...options, message: input } : { ...input }
    const id = item.id ?? `toast-${++nextId}`
    const existing = items.find(
      (candidate) => candidate.id === id && candidate.state !== 'closing'
    )

    if (existing) {
      update(id, item)
      return id
    }

    const openItems = items.filter((candidate) => candidate.state !== 'closing')
    if (max > 0 && openItems.length >= max) dismiss(openItems[0].id)

    const itemDuration = normalizeDuration(item.duration, duration)
    const nextItem = {
      id,
      title: '',
      message: '',
      class: '',
      ...item,
      duration: itemDuration,
      state: 'entering'
    }

    pauseReasons.set(id, new Set(globalPauseReasons))
    publish([...items, nextItem])
    resetTimer(id, itemDuration)
    lifecycleTimers.set(
      id,
      setTimeout(() => completeEnter(id), ENTER_FALLBACK)
    )
    return id
  }

  Object.assign(notify, {
    clear,
    completeEnter,
    destroy,
    dismiss,
    getSnapshot,
    pause,
    pauseAll,
    remove,
    resume,
    resumeAll,
    subscribe,
    update
  })

  return notify
}

export const toast = createToast()

Vue source

Toast.vue
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import { toast } from './toast.js'

defineOptions({ inheritAttrs: false })

const POSITIONS = {
  'top-left': 'left-4 top-4 items-start',
  'top-center': 'left-1/2 top-4 -translate-x-1/2 items-center',
  'top-right': 'right-4 top-4 items-end',
  'bottom-left': 'bottom-4 left-4 items-start',
  'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2 items-center',
  'bottom-right': 'bottom-4 right-4 items-end'
}

const POSITION_EDGES = {
  'top-left': ['top', 'left'],
  'top-center': ['top'],
  'top-right': ['top', 'right'],
  'bottom-left': ['bottom', 'left'],
  'bottom-center': ['bottom'],
  'bottom-right': ['bottom', 'right']
}

const NEARBY_DURATION = { enter: 300, leave: 200 }
const CROSS_VIEWPORT_DURATION = { enter: 450, leave: 320 }

function motionVector(direction, position) {
  if (direction === 'fade' || direction === 'none') {
    return { x: '0px', y: '0px' }
  }

  const nearby = POSITION_EDGES[position]?.includes(direction)
  const horizontal = direction === 'left' || direction === 'right'
  const distance = nearby
    ? 'calc(100% + 1rem)'
    : horizontal
      ? '100vw'
      : '100dvh'
  const negative = direction === 'left' || direction === 'top'
  const signedDistance = negative
    ? nearby
      ? 'calc(-100% - 1rem)'
      : horizontal
        ? '-100vw'
        : '-100dvh'
    : distance

  return horizontal
    ? { x: signedDistance, y: '0px' }
    : { x: '0px', y: signedDistance }
}

function motionDuration(phase, direction, position) {
  if (direction === 'none') return 0
  if (['fade', ...POSITION_EDGES[position]].includes(direction)) {
    return NEARBY_DURATION[phase]
  }
  return CROSS_VIEWPORT_DURATION[phase]
}

const props = defineProps({
  /** Optional isolated controller. The shared `toast` works without a provider. */
  controller: { type: Function, default: undefined },
  /** Fixed viewport position. */
  position: {
    type: String,
    default: 'top-right',
    validator: (value) =>
      [
        'top-left',
        'top-center',
        'top-right',
        'bottom-left',
        'bottom-center',
        'bottom-right'
      ].includes(value)
  },
  /** Direction new notifications enter from. */
  from: {
    type: String,
    default: undefined,
    validator: (value) =>
      ['left', 'right', 'top', 'bottom', 'fade', 'none'].includes(value)
  },
  /** Direction dismissed notifications leave toward. */
  to: {
    type: String,
    default: undefined,
    validator: (value) =>
      ['left', 'right', 'top', 'bottom', 'fade', 'none'].includes(value)
  },
  /** Accessible name for the persistent live region. */
  label: { type: String, default: 'Notifications' }
})

const attrs = useAttrs()
const items = ref([])
const activeController = computed(() => props.controller ?? toast)
const defaultDirection = computed(() =>
  props.position.endsWith('-left') ? 'left' : 'right'
)
const resolvedFrom = computed(() => props.from ?? defaultDirection.value)
const resolvedTo = computed(() => props.to ?? defaultDirection.value)
let unsubscribe = () => {}

const viewportClasses = computed(() =>
  twMerge(
    'pointer-events-none fixed z-100 m-0 flex w-[min(24rem,calc(100vw-2rem))] flex-col',
    POSITIONS[props.position],
    attrs.class
  )
)

const viewportAttrs = computed(() => {
  const {
    class: _class,
    style: _style,
    'aria-label': _ariaLabel,
    'aria-live': _ariaLive,
    'data-slot': _dataSlot,
    ...rest
  } = attrs

  return rest
})

const motionStyle = computed(() => {
  const enter = motionVector(resolvedFrom.value, props.position)
  const leave = motionVector(resolvedTo.value, props.position)
  const enterDuration = motionDuration(
    'enter',
    resolvedFrom.value,
    props.position
  )
  const leaveDuration = motionDuration(
    'leave',
    resolvedTo.value,
    props.position
  )
  const collapseDelay = Math.min(80, Math.round(leaveDuration * 0.4))

  return {
    '--klean-toast-enter-x': enter.x,
    '--klean-toast-enter-y': enter.y,
    '--klean-toast-leave-x': leave.x,
    '--klean-toast-leave-y': leave.y,
    '--klean-toast-enter-duration': `${enterDuration}ms`,
    '--klean-toast-leave-duration': `${leaveDuration}ms`,
    '--klean-toast-collapse-delay': `${collapseDelay}ms`,
    '--klean-toast-collapse-duration': `${Math.max(0, leaveDuration - collapseDelay)}ms`
  }
})

function activateAction(item, event) {
  item.action?.onClick?.(event, item)
  activeController.value.dismiss(item.id)
}

function subscribe(controller) {
  unsubscribe()
  const sync = () => {
    items.value = controller.getSnapshot()

    if (resolvedFrom.value === 'none' || resolvedTo.value === 'none') {
      queueMicrotask(() => {
        for (const item of controller.getSnapshot()) {
          if (item.state === 'entering' && resolvedFrom.value === 'none') {
            controller.completeEnter(item.id)
          } else if (item.state === 'closing' && resolvedTo.value === 'none') {
            controller.remove(item.id)
          }
        }
      })
    }
  }

  sync()
  unsubscribe = controller.subscribe(sync)
}

function handleAnimationEnd(item, event) {
  if (event.target !== event.currentTarget) return

  if (item.state === 'entering') activeController.value.completeEnter(item.id)
  else if (item.state === 'closing') activeController.value.remove(item.id)
}

function handleFocusOut(item, event) {
  if (!event.currentTarget.contains(event.relatedTarget)) {
    activeController.value.resume(item.id, 'focus')
  }
}

function handleVisibility() {
  if (document.hidden) activeController.value.pauseAll('page-hidden')
  else activeController.value.resumeAll('page-hidden')
}

function handleWindowBlur() {
  activeController.value.pauseAll('window-blur')
}

function handleWindowFocus() {
  activeController.value.resumeAll('window-blur')
}

watch(activeController, subscribe)

onMounted(() => {
  subscribe(activeController.value)
  document.addEventListener('visibilitychange', handleVisibility)
  window.addEventListener('blur', handleWindowBlur)
  window.addEventListener('focus', handleWindowFocus)
  handleVisibility()
})

onBeforeUnmount(() => {
  unsubscribe()
  document.removeEventListener('visibilitychange', handleVisibility)
  window.removeEventListener('blur', handleWindowBlur)
  window.removeEventListener('focus', handleWindowFocus)
  activeController.value.resumeAll('page-hidden')
  activeController.value.resumeAll('window-blur')
})
</script>

<template>
  <section
    v-bind="viewportAttrs"
    data-slot="toast-viewport"
    :data-position="position"
    :data-from="resolvedFrom"
    :data-to="resolvedTo"
    :aria-label="label"
    aria-live="polite"
    aria-atomic="false"
    aria-relevant="additions text"
    :class="viewportClasses"
    :style="[motionStyle, attrs.style]"
  >
    <ol data-slot="toast-list" class="m-0 flex w-full list-none flex-col p-0">
      <li
        v-for="item in items"
        :key="item.id"
        data-klean-toast-row
        :data-state="item.state"
        aria-atomic="true"
        class="grid grid-rows-[1fr] pb-3"
        @mouseenter="activeController.pause(item.id, 'hover')"
        @mouseleave="activeController.resume(item.id, 'hover')"
        @focusin="activeController.pause(item.id, 'focus')"
        @focusout="handleFocusOut(item, $event)"
      >
        <div
          data-slot="toast"
          data-klean-toast-item
          :data-state="item.state"
          :data-from="resolvedFrom"
          :data-to="resolvedTo"
          :class="
            twMerge(
              'pointer-events-auto grid min-h-0 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 text-gray-950 shadow-xl ring-1 ring-gray-950/10 dark:bg-gray-950 dark:text-white dark:ring-white/15',
              item.class
            )
          "
          @animationend="handleAnimationEnd(item, $event)"
        >
          <slot :item="item" :dismiss="() => activeController.dismiss(item.id)">
            <div class="min-w-0 pt-0.5">
              <p
                v-if="item.title"
                data-slot="toast-title"
                class="text-sm font-semibold leading-5"
              >
                {{ item.title }}
              </p>
              <p
                v-if="item.message"
                data-slot="toast-message"
                :class="
                  twMerge(
                    'text-sm leading-5 text-gray-600 dark:text-gray-300',
                    item.title && 'mt-0.5'
                  )
                "
              >
                {{ item.message }}
              </p>
              <a
                v-if="item.action?.href"
                data-slot="toast-action"
                :href="item.action.href"
                :class="
                  twMerge(
                    'mt-2 inline-flex min-h-8 items-center text-sm font-semibold text-gray-950 underline decoration-gray-300 underline-offset-4 hover:decoration-current focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:decoration-gray-600 dark:focus-visible:ring-white',
                    item.action.class
                  )
                "
                @click="activateAction(item, $event)"
              >
                {{ item.action.label }}
              </a>
              <button
                v-else-if="item.action?.label"
                type="button"
                data-slot="toast-action"
                :class="
                  twMerge(
                    'mt-2 inline-flex min-h-8 cursor-pointer items-center text-sm font-semibold text-gray-950 hover:text-gray-600 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:hover:text-gray-300 dark:focus-visible:ring-white',
                    item.action.class
                  )
                "
                @click="activateAction(item, $event)"
              >
                {{ item.action.label }}
              </button>
            </div>
            <button
              v-if="item.dismissible !== false"
              type="button"
              data-slot="toast-dismiss"
              class="-mr-2 -mt-1 grid size-9 cursor-pointer place-items-center rounded-lg text-lg leading-none text-gray-400 hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:ring-white"
              :aria-label="
                item.dismissLabel ?? `Dismiss ${item.title || 'notification'}`
              "
              @click="activeController.dismiss(item.id)"
            >
              <span aria-hidden="true">×</span>
            </button>
          </slot>
        </div>
      </li>
    </ol>
  </section>
</template>

<style>
@keyframes klean-toast-enter {
  0% {
    opacity: 0;
    transform: translate3d(
        var(--klean-toast-enter-x),
        var(--klean-toast-enter-y),
        0
      )
      scale(0.98);
  }
  100% {
    opacity: 1;
    transform: translate3d(0, 0, 0) scale(1);
  }
}

@keyframes klean-toast-leave {
  0% {
    opacity: 1;
    transform: translate3d(0, 0, 0) scale(1);
  }
  100% {
    opacity: 0;
    transform: translate3d(
        var(--klean-toast-leave-x),
        var(--klean-toast-leave-y),
        0
      )
      scale(0.98);
  }
}

@keyframes klean-toast-collapse {
  0% {
    grid-template-rows: 1fr;
    padding-block-end: 0.75rem;
  }
  100% {
    grid-template-rows: 0fr;
    padding-block-end: 0;
  }
}

[data-klean-toast-item][data-state='entering'] {
  animation: klean-toast-enter var(--klean-toast-enter-duration) ease-out both;
}

[data-klean-toast-item][data-state='closing'] {
  animation: klean-toast-leave var(--klean-toast-leave-duration) ease-in both;
  pointer-events: none;
}

[data-klean-toast-row][data-state='closing'] {
  animation: klean-toast-collapse var(--klean-toast-collapse-duration) ease-in
    var(--klean-toast-collapse-delay) both;
  overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
  [data-klean-toast-item][data-state] {
    animation-duration: 1ms;
    animation-timing-function: linear;
  }

  [data-klean-toast-row][data-state='closing'] {
    animation-delay: 0ms;
    animation-duration: 1ms;
  }
}
</style>

React source

Toast.jsx
import { useEffect, useMemo, useSyncExternalStore } from 'react'
import { twMerge } from 'tailwind-merge'
import { toast } from './toast.js'

const POSITIONS = {
  'top-left': 'left-4 top-4 items-start',
  'top-center': 'left-1/2 top-4 -translate-x-1/2 items-center',
  'top-right': 'right-4 top-4 items-end',
  'bottom-left': 'bottom-4 left-4 items-start',
  'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2 items-center',
  'bottom-right': 'bottom-4 right-4 items-end'
}

const POSITION_EDGES = {
  'top-left': ['top', 'left'],
  'top-center': ['top'],
  'top-right': ['top', 'right'],
  'bottom-left': ['bottom', 'left'],
  'bottom-center': ['bottom'],
  'bottom-right': ['bottom', 'right']
}

const NEARBY_DURATION = { enter: 300, leave: 200 }
const CROSS_VIEWPORT_DURATION = { enter: 450, leave: 320 }

function motionVector(direction, position) {
  if (direction === 'fade' || direction === 'none') return ['0px', '0px']

  const nearby = POSITION_EDGES[position]?.includes(direction)
  const horizontal = direction === 'left' || direction === 'right'
  const negative = direction === 'left' || direction === 'top'
  const distance = nearby
    ? negative
      ? 'calc(-100% - 1rem)'
      : 'calc(100% + 1rem)'
    : horizontal
      ? negative
        ? '-100vw'
        : '100vw'
      : negative
        ? '-100dvh'
        : '100dvh'

  return horizontal ? [distance, '0px'] : ['0px', distance]
}

function motionDuration(phase, direction, position) {
  if (direction === 'none') return 0
  if (['fade', ...POSITION_EDGES[position]].includes(direction)) {
    return NEARBY_DURATION[phase]
  }
  return CROSS_VIEWPORT_DURATION[phase]
}

const MOTION_CSS = `
@keyframes klean-toast-enter {
  0% { opacity: 0; transform: translate3d(var(--klean-toast-enter-x), var(--klean-toast-enter-y), 0) scale(.98); }
  100% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}
@keyframes klean-toast-leave {
  0% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
  100% { opacity: 0; transform: translate3d(var(--klean-toast-leave-x), var(--klean-toast-leave-y), 0) scale(.98); }
}
@keyframes klean-toast-collapse {
  0% { grid-template-rows: 1fr; padding-block-end: .75rem; }
  100% { grid-template-rows: 0fr; padding-block-end: 0; }
}
[data-klean-toast-item][data-state="entering"] { animation: klean-toast-enter var(--klean-toast-enter-duration) ease-out both; }
[data-klean-toast-item][data-state="closing"] { animation: klean-toast-leave var(--klean-toast-leave-duration) ease-in both; pointer-events: none; }
[data-klean-toast-row][data-state="closing"] { animation: klean-toast-collapse var(--klean-toast-collapse-duration) ease-in var(--klean-toast-collapse-delay) both; overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
  [data-klean-toast-item][data-state] { animation-duration: 1ms; animation-timing-function: linear; }
  [data-klean-toast-row][data-state="closing"] { animation-delay: 0ms; animation-duration: 1ms; }
}`

function motionStyle(from, to, position, style) {
  const enter = motionVector(from, position)
  const leave = motionVector(to, position)
  const enterDuration = motionDuration('enter', from, position)
  const leaveDuration = motionDuration('leave', to, position)
  const collapseDelay = Math.min(80, Math.round(leaveDuration * 0.4))

  return {
    '--klean-toast-enter-x': enter[0],
    '--klean-toast-enter-y': enter[1],
    '--klean-toast-leave-x': leave[0],
    '--klean-toast-leave-y': leave[1],
    '--klean-toast-enter-duration': `${enterDuration}ms`,
    '--klean-toast-leave-duration': `${leaveDuration}ms`,
    '--klean-toast-collapse-delay': `${collapseDelay}ms`,
    '--klean-toast-collapse-duration': `${Math.max(0, leaveDuration - collapseDelay)}ms`,
    ...style
  }
}

export default function Toast({
  controller = toast,
  position = 'top-right',
  from,
  to,
  label = 'Notifications',
  className,
  style,
  children,
  ...viewportProps
}) {
  const defaultDirection = position.endsWith('-left') ? 'left' : 'right'
  const resolvedFrom = from ?? defaultDirection
  const resolvedTo = to ?? defaultDirection
  const items = useSyncExternalStore(
    controller.subscribe,
    controller.getSnapshot,
    controller.getSnapshot
  )
  const resolvedStyle = useMemo(
    () => motionStyle(resolvedFrom, resolvedTo, position, style),
    [position, resolvedFrom, resolvedTo, style]
  )

  useEffect(() => {
    function syncInstantMotion() {
      queueMicrotask(() => {
        for (const item of controller.getSnapshot()) {
          if (item.state === 'entering' && resolvedFrom === 'none') {
            controller.completeEnter(item.id)
          } else if (item.state === 'closing' && resolvedTo === 'none') {
            controller.remove(item.id)
          }
        }
      })
    }

    syncInstantMotion()
    return controller.subscribe(syncInstantMotion)
  }, [controller, resolvedFrom, resolvedTo])

  useEffect(() => {
    function handleVisibility() {
      if (document.hidden) controller.pauseAll('page-hidden')
      else controller.resumeAll('page-hidden')
    }
    function handleBlur() {
      controller.pauseAll('window-blur')
    }
    function handleFocus() {
      controller.resumeAll('window-blur')
    }

    document.addEventListener('visibilitychange', handleVisibility)
    window.addEventListener('blur', handleBlur)
    window.addEventListener('focus', handleFocus)
    handleVisibility()

    return () => {
      document.removeEventListener('visibilitychange', handleVisibility)
      window.removeEventListener('blur', handleBlur)
      window.removeEventListener('focus', handleFocus)
      controller.resumeAll('page-hidden')
      controller.resumeAll('window-blur')
    }
  }, [controller])

  function handleAnimationEnd(item, event) {
    if (event.target !== event.currentTarget) return
    if (item.state === 'entering') controller.completeEnter(item.id)
    else if (item.state === 'closing') controller.remove(item.id)
  }

  function activateAction(item, event) {
    item.action?.onClick?.(event, item)
    controller.dismiss(item.id)
  }

  function defaultContent(item) {
    return (
      <>
        <div className="min-w-0 pt-0.5">
          {item.title ? (
            <p
              data-slot="toast-title"
              className="text-sm font-semibold leading-5"
            >
              {item.title}
            </p>
          ) : null}
          {item.message ? (
            <p
              data-slot="toast-message"
              className={twMerge(
                'text-sm leading-5 text-gray-600 dark:text-gray-300',
                item.title && 'mt-0.5'
              )}
            >
              {item.message}
            </p>
          ) : null}
          {item.action?.href ? (
            <a
              data-slot="toast-action"
              href={item.action.href}
              className={twMerge(
                'mt-2 inline-flex min-h-8 items-center text-sm font-semibold text-gray-950 underline decoration-gray-300 underline-offset-4 hover:decoration-current focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:decoration-gray-600 dark:focus-visible:ring-white',
                item.action.class,
                item.action.className
              )}
              onClick={(event) => activateAction(item, event)}
            >
              {item.action.label}
            </a>
          ) : item.action?.label ? (
            <button
              type="button"
              data-slot="toast-action"
              className={twMerge(
                'mt-2 inline-flex min-h-8 cursor-pointer items-center text-sm font-semibold text-gray-950 hover:text-gray-600 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:hover:text-gray-300 dark:focus-visible:ring-white',
                item.action.class,
                item.action.className
              )}
              onClick={(event) => activateAction(item, event)}
            >
              {item.action.label}
            </button>
          ) : null}
        </div>
        {item.dismissible !== false ? (
          <button
            type="button"
            data-slot="toast-dismiss"
            className="-mr-2 -mt-1 grid size-9 cursor-pointer place-items-center rounded-lg text-lg leading-none text-gray-400 hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:ring-white"
            aria-label={
              item.dismissLabel ?? `Dismiss ${item.title || 'notification'}`
            }
            onClick={() => controller.dismiss(item.id)}
          >
            <span aria-hidden="true">×</span>
          </button>
        ) : null}
      </>
    )
  }

  return (
    <section
      {...viewportProps}
      data-slot="toast-viewport"
      data-position={position}
      data-from={resolvedFrom}
      data-to={resolvedTo}
      aria-label={label}
      aria-live="polite"
      aria-atomic="false"
      aria-relevant="additions text"
      className={twMerge(
        'pointer-events-none fixed z-100 m-0 flex w-[min(24rem,calc(100vw-2rem))] flex-col',
        POSITIONS[position],
        className
      )}
      style={resolvedStyle}
    >
      <style>{MOTION_CSS}</style>
      <ol
        data-slot="toast-list"
        className="m-0 flex w-full list-none flex-col p-0"
      >
        {items.map((item) => (
          <li
            key={item.id}
            data-klean-toast-row=""
            data-state={item.state}
            aria-atomic="true"
            className="grid grid-rows-[1fr] pb-3"
            onMouseEnter={() => controller.pause(item.id, 'hover')}
            onMouseLeave={() => controller.resume(item.id, 'hover')}
            onFocus={() => controller.pause(item.id, 'focus')}
            onBlur={(event) => {
              if (!event.currentTarget.contains(event.relatedTarget)) {
                controller.resume(item.id, 'focus')
              }
            }}
          >
            <div
              data-slot="toast"
              data-klean-toast-item=""
              data-state={item.state}
              data-from={resolvedFrom}
              data-to={resolvedTo}
              className={twMerge(
                'pointer-events-auto grid min-h-0 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 text-gray-950 shadow-xl ring-1 ring-gray-950/10 dark:bg-gray-950 dark:text-white dark:ring-white/15',
                item.class,
                item.className
              )}
              onAnimationEnd={(event) => handleAnimationEnd(item, event)}
            >
              {typeof children === 'function'
                ? children({
                    item,
                    dismiss: () => controller.dismiss(item.id)
                  })
                : defaultContent(item)}
            </div>
          </li>
        ))}
      </ol>
    </section>
  )
}

Svelte source

Toast.svelte
<script>
  import { onMount } from "svelte";
  import { twMerge } from "tailwind-merge";
  import { toast } from "./toast.js";

  const POSITIONS = {
    "top-left": "left-4 top-4 items-start",
    "top-center": "left-1/2 top-4 -translate-x-1/2 items-center",
    "top-right": "right-4 top-4 items-end",
    "bottom-left": "bottom-4 left-4 items-start",
    "bottom-center": "bottom-4 left-1/2 -translate-x-1/2 items-center",
    "bottom-right": "bottom-4 right-4 items-end",
  };

  const POSITION_EDGES = {
    "top-left": ["top", "left"],
    "top-center": ["top"],
    "top-right": ["top", "right"],
    "bottom-left": ["bottom", "left"],
    "bottom-center": ["bottom"],
    "bottom-right": ["bottom", "right"],
  };

  const NEARBY_DURATION = { enter: 300, leave: 200 };
  const CROSS_VIEWPORT_DURATION = { enter: 450, leave: 320 };

  function motionVector(direction, position) {
    if (direction === "fade" || direction === "none") return ["0px", "0px"];

    const nearby = POSITION_EDGES[position]?.includes(direction);
    const horizontal = direction === "left" || direction === "right";
    const negative = direction === "left" || direction === "top";
    const distance = nearby
      ? negative
        ? "calc(-100% - 1rem)"
        : "calc(100% + 1rem)"
      : horizontal
        ? negative
          ? "-100vw"
          : "100vw"
        : negative
          ? "-100dvh"
          : "100dvh";

    return horizontal ? [distance, "0px"] : ["0px", distance];
  }

  function motionDuration(phase, direction, position) {
    if (direction === "none") return 0;
    if (["fade", ...POSITION_EDGES[position]].includes(direction)) {
      return NEARBY_DURATION[phase];
    }
    return CROSS_VIEWPORT_DURATION[phase];
  }

  let {
    controller = toast,
    position = "top-right",
    from,
    to,
    label = "Notifications",
    class: className = "",
    style = "",
    children,
    ...viewportProps
  } = $props();

  let items = $state([]);
  let defaultDirection = $derived(
    position.endsWith("-left") ? "left" : "right",
  );
  let resolvedFrom = $derived(from ?? defaultDirection);
  let resolvedTo = $derived(to ?? defaultDirection);
  let motionStyle = $derived.by(() => {
    const enter = motionVector(resolvedFrom, position);
    const leave = motionVector(resolvedTo, position);
    const enterDuration = motionDuration("enter", resolvedFrom, position);
    const leaveDuration = motionDuration("leave", resolvedTo, position);
    const collapseDelay = Math.min(80, Math.round(leaveDuration * 0.4));

    return [
      `--klean-toast-enter-x:${enter[0]}`,
      `--klean-toast-enter-y:${enter[1]}`,
      `--klean-toast-leave-x:${leave[0]}`,
      `--klean-toast-leave-y:${leave[1]}`,
      `--klean-toast-enter-duration:${enterDuration}ms`,
      `--klean-toast-leave-duration:${leaveDuration}ms`,
      `--klean-toast-collapse-delay:${collapseDelay}ms`,
      `--klean-toast-collapse-duration:${Math.max(0, leaveDuration - collapseDelay)}ms`,
      style,
    ]
      .filter(Boolean)
      .join(";");
  });

  function syncInstantMotion() {
    queueMicrotask(() => {
      for (const item of controller.getSnapshot()) {
        if (item.state === "entering" && resolvedFrom === "none") {
          controller.completeEnter(item.id);
        } else if (item.state === "closing" && resolvedTo === "none") {
          controller.remove(item.id);
        }
      }
    });
  }

  $effect(() => {
    const activeController = controller;
    const sync = () => {
      items = activeController.getSnapshot();
      syncInstantMotion();
    };

    sync();
    const unsubscribe = activeController.subscribe(sync);
    return unsubscribe;
  });

  function handleAnimationEnd(item, event) {
    if (event.target !== event.currentTarget) return;
    if (item.state === "entering") controller.completeEnter(item.id);
    else if (item.state === "closing") controller.remove(item.id);
  }

  function handleFocusOut(item, event) {
    if (!event.currentTarget.contains(event.relatedTarget)) {
      controller.resume(item.id, "focus");
    }
  }

  function activateAction(item, event) {
    item.action?.onClick?.(event, item);
    controller.dismiss(item.id);
  }

  onMount(() => {
    function handleVisibility() {
      if (document.hidden) controller.pauseAll("page-hidden");
      else controller.resumeAll("page-hidden");
    }
    function handleBlur() {
      controller.pauseAll("window-blur");
    }
    function handleFocus() {
      controller.resumeAll("window-blur");
    }

    document.addEventListener("visibilitychange", handleVisibility);
    window.addEventListener("blur", handleBlur);
    window.addEventListener("focus", handleFocus);
    handleVisibility();

    return () => {
      document.removeEventListener("visibilitychange", handleVisibility);
      window.removeEventListener("blur", handleBlur);
      window.removeEventListener("focus", handleFocus);
      controller.resumeAll("page-hidden");
      controller.resumeAll("window-blur");
    };
  });
</script>

<section
  {...viewportProps}
  data-slot="toast-viewport"
  data-position={position}
  data-from={resolvedFrom}
  data-to={resolvedTo}
  aria-label={label}
  aria-live="polite"
  aria-atomic="false"
  aria-relevant="additions text"
  class={twMerge(
    "pointer-events-none fixed z-100 m-0 flex w-[min(24rem,calc(100vw-2rem))] flex-col",
    POSITIONS[position],
    className,
  )}
  style={motionStyle}
>
  <ol data-slot="toast-list" class="m-0 flex w-full list-none flex-col p-0">
    {#each items as item (item.id)}
      <li
        data-klean-toast-row
        data-state={item.state}
        aria-atomic="true"
        class="grid grid-rows-[1fr] pb-3"
        onmouseenter={() => controller.pause(item.id, "hover")}
        onmouseleave={() => controller.resume(item.id, "hover")}
        onfocusin={() => controller.pause(item.id, "focus")}
        onfocusout={(event) => handleFocusOut(item, event)}
      >
        <div
          data-slot="toast"
          data-klean-toast-item
          data-state={item.state}
          data-from={resolvedFrom}
          data-to={resolvedTo}
          class={twMerge(
            "pointer-events-auto grid min-h-0 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 text-gray-950 shadow-xl ring-1 ring-gray-950/10 dark:bg-gray-950 dark:text-white dark:ring-white/15",
            item.class,
            item.className,
          )}
          onanimationend={(event) => handleAnimationEnd(item, event)}
        >
          {#if children}
            {@render children({
              item,
              dismiss: () => controller.dismiss(item.id),
            })}
          {:else}
            <div class="min-w-0 pt-0.5">
              {#if item.title}
                <p
                  data-slot="toast-title"
                  class="text-sm font-semibold leading-5"
                >
                  {item.title}
                </p>
              {/if}
              {#if item.message}
                <p
                  data-slot="toast-message"
                  class={twMerge(
                    "text-sm leading-5 text-gray-600 dark:text-gray-300",
                    item.title && "mt-0.5",
                  )}
                >
                  {item.message}
                </p>
              {/if}
              {#if item.action?.href}
                <a
                  data-slot="toast-action"
                  href={item.action.href}
                  class={twMerge(
                    "mt-2 inline-flex min-h-8 items-center text-sm font-semibold text-gray-950 underline decoration-gray-300 underline-offset-4 hover:decoration-current focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:decoration-gray-600 dark:focus-visible:ring-white",
                    item.action.class,
                    item.action.className,
                  )}
                  onclick={(event) => activateAction(item, event)}
                >
                  {item.action.label}
                </a>
              {:else if item.action?.label}
                <button
                  type="button"
                  data-slot="toast-action"
                  class={twMerge(
                    "mt-2 inline-flex min-h-8 cursor-pointer items-center text-sm font-semibold text-gray-950 hover:text-gray-600 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:hover:text-gray-300 dark:focus-visible:ring-white",
                    item.action.class,
                    item.action.className,
                  )}
                  onclick={(event) => activateAction(item, event)}
                >
                  {item.action.label}
                </button>
              {/if}
            </div>
            {#if item.dismissible !== false}
              <button
                type="button"
                data-slot="toast-dismiss"
                class="-mr-2 -mt-1 grid size-9 cursor-pointer place-items-center rounded-lg text-lg leading-none text-gray-400 hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:ring-white"
                aria-label={item.dismissLabel ??
                  `Dismiss ${item.title || "notification"}`}
                onclick={() => controller.dismiss(item.id)}
              >
                <span aria-hidden="true">×</span>
              </button>
            {/if}
          {/if}
        </div>
      </li>
    {/each}
  </ol>
</section>

<style>
  @keyframes klean-toast-enter {
    0% {
      opacity: 0;
      transform: translate3d(
          var(--klean-toast-enter-x),
          var(--klean-toast-enter-y),
          0
        )
        scale(0.98);
    }
    100% {
      opacity: 1;
      transform: translate3d(0, 0, 0) scale(1);
    }
  }

  @keyframes klean-toast-leave {
    0% {
      opacity: 1;
      transform: translate3d(0, 0, 0) scale(1);
    }
    100% {
      opacity: 0;
      transform: translate3d(
          var(--klean-toast-leave-x),
          var(--klean-toast-leave-y),
          0
        )
        scale(0.98);
    }
  }

  @keyframes klean-toast-collapse {
    0% {
      grid-template-rows: 1fr;
      padding-block-end: 0.75rem;
    }
    100% {
      grid-template-rows: 0fr;
      padding-block-end: 0;
    }
  }

  [data-klean-toast-item][data-state="entering"] {
    animation: klean-toast-enter var(--klean-toast-enter-duration) ease-out both;
  }

  [data-klean-toast-item][data-state="closing"] {
    animation: klean-toast-leave var(--klean-toast-leave-duration) ease-in both;
    pointer-events: none;
  }

  [data-klean-toast-row][data-state="closing"] {
    animation: klean-toast-collapse var(--klean-toast-collapse-duration) ease-in
      var(--klean-toast-collapse-delay) both;
    overflow: hidden;
  }

  @media (prefers-reduced-motion: reduce) {
    [data-klean-toast-item][data-state] {
      animation-duration: 1ms;
      animation-timing-function: linear;
    }

    [data-klean-toast-row][data-state="closing"] {
      animation-delay: 0ms;
      animation-duration: 1ms;
    }
  }
</style>

  • Spinner — represents work in progress; Toast reports a useful outcome.
  • Button — a truthful toast action or dismissal control.
  • Slide — confirm consequential work before announcing its result.
  • Schedule Picker — choose an instant, then announce the server outcome.
  • Dialog — blocking decisions that cannot be reduced to a temporary notification.

All open source projects are released under the MIT License.