Skip to content

Toast ​

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

Preview Live · Vue
Usage
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>
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
<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>
View source Toast.vue
Toast.vue
vue
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  useTemplateRef,
  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' },
  /** Keep every notification visible instead of compacting the stack. */
  expanded: { type: Boolean, default: false }
})

const attrs = useAttrs()
const items = ref([])
const heights = ref({})
let resizeObserver
const hovered = ref(false)
const focused = ref(false)
const pinned = ref(false)
const isReading = computed(() => hovered.value || focused.value || pinned.value)
const isExpanded = computed(
  () => props.expanded || hovered.value || focused.value || pinned.value
)
const stackedItems = computed(() =>
  props.position.startsWith('top') ? [...items.value].reverse() : items.value
)
const stackCount = computed(
  () => items.value.filter((item) => item.state !== 'closing').length
)
const frontHeight = computed(() => heights.value[items.value.at(-1)?.id] ?? 0)
const listHeight = computed(() =>
  isExpanded.value
    ? items.value.reduce(
        (total, item) => total + (heights.value[item.id] ?? frontHeight.value),
        0
      )
    : frontHeight.value
)
function rowOffset(item) {
  const index = stackedItems.value.findIndex(
    (candidate) => candidate.id === item.id
  )
  return isExpanded.value
    ? stackedItems.value
        .slice(0, index)
        .reduce(
          (total, candidate) =>
            total + (heights.value[candidate.id] ?? frontHeight.value),
          0
        )
    : 0
}
function measureCards() {
  const next = {}
  for (const element of viewport.value?.querySelectorAll(
    '[data-klean-toast-item]'
  ) ?? []) {
    const row = element.parentElement
    next[row.dataset.toastId] =
      row.dataset.state === 'closing'
        ? (heights.value[row.dataset.toastId] ?? element.offsetHeight + 12)
        : element.offsetHeight + 12
  }
  heights.value = next
}
function observeCards() {
  resizeObserver?.disconnect()
  for (const element of viewport.value?.querySelectorAll(
    '[data-klean-toast-item]'
  ) ?? [])
    resizeObserver?.observe(element)
  measureCards()
}
function stackDepth(item) {
  return (
    items.value.length -
    1 -
    items.value.findIndex((candidate) => candidate.id === item.id)
  )
}
function setHover(event, value) {
  if (event.pointerType === 'mouse') hovered.value = value
}
function focusStack(event) {
  focused.value = Boolean(event.target.closest('[data-klean-toast-row]'))
  if (focused.value)
    nextTick(() =>
      event.target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
    )
}
function blurStack(event) {
  if (!event.currentTarget.contains(event.relatedTarget)) focused.value = false
}
const viewport = useTemplateRef('viewport')
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 = () => {}
let promotedItemId

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

const viewportAttrs = computed(() => {
  const {
    class: _class,
    style: _style,
    'aria-label': _ariaLabel,
    'aria-live': _ariaLive,
    popover: _popover,
    '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()
  promotedItemId = undefined
  const sync = () => {
    const snapshot = controller.getSnapshot()
    items.value = snapshot

    const enteringItem = snapshot.findLast((item) => item.state === 'entering')
    if (enteringItem && enteringItem.id !== promotedItemId) {
      promotedItemId = enteringItem.id
      const focusedElement = viewport.value?.contains(document.activeElement)
        ? document.activeElement
        : null
      hideTopLayer()
      showTopLayer()
      focusedElement?.focus({ preventScroll: true })
    }

    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')
}

function showTopLayer() {
  try {
    viewport.value?.showPopover?.()
  } catch {
    // Already open or rejected by a partial Popover API implementation.
  }
}

function hideTopLayer() {
  try {
    viewport.value?.hidePopover?.()
  } catch {
    // Already closed during teardown.
  }
}

watch(activeController, subscribe)
watch(items, observeCards, { flush: 'post' })
watch(
  isExpanded,
  (expanded) => {
    if (expanded && !focused.value && props.position.startsWith('bottom')) {
      const list = viewport.value?.querySelector('[data-slot="toast-list"]')
      if (list) list.scrollTop = list.scrollHeight
    }
  },
  { flush: 'post' }
)
watch(stackCount, (count) => {
  if (count === 0) {
    pinned.value = false
    focused.value = false
    hovered.value = false
  }
})
watch(
  [isReading, activeController],
  ([expanded, controller], previous) => {
    previous?.[1]?.resumeAll('stack-reading')
    if (expanded) controller.pauseAll('stack-reading')
    else controller.resumeAll('stack-reading')
  },
  { immediate: true }
)

onMounted(() => {
  if (typeof ResizeObserver !== 'undefined')
    resizeObserver = new ResizeObserver(measureCards)
  showTopLayer()
  subscribe(activeController.value)
  document.addEventListener('visibilitychange', handleVisibility)
  window.addEventListener('blur', handleWindowBlur)
  window.addEventListener('focus', handleWindowFocus)
  handleVisibility()
})

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

<template>
  <section
    ref="viewport"
    v-bind="viewportAttrs"
    popover="manual"
    data-slot="toast-viewport"
    :data-position="position"
    :data-from="resolvedFrom"
    :data-to="resolvedTo"
    :data-expanded="isExpanded"
    :data-focused="focused"
    :data-stack-count="stackCount"
    :aria-label="label"
    aria-live="polite"
    aria-atomic="false"
    aria-relevant="additions text"
    :class="viewportClasses"
    :style="[motionStyle, attrs.style]"
    @pointerenter="setHover($event, true)"
    @pointerleave="setHover($event, false)"
    @focusin="focusStack"
    @focusout="blurStack"
  >
    <ol
      data-slot="toast-list"
      :style="{
        height: listHeight ? `${listHeight}px` : undefined,
        '--klean-toast-front-height': frontHeight
          ? `${frontHeight}px`
          : undefined
      }"
      class="m-0 flex min-w-0 w-full list-none flex-col p-0"
    >
      <li
        v-for="item in stackedItems"
        :key="item.id"
        data-klean-toast-row
        :data-state="item.state"
        :data-depth="stackDepth(item)"
        :data-toast-id="item.id"
        :style="{
          '--klean-toast-depth': Math.min(stackDepth(item), 2),
          zIndex: items.length - stackDepth(item),
          top: `${rowOffset(item)}px`
        }"
        aria-atomic="true"
        class="grid min-w-0 grid-cols-1 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 min-w-0 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 wrap-anywhere text-gray-950 shadow-sm 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 max-w-full items-center text-left text-sm font-semibold whitespace-normal 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 max-w-full cursor-pointer items-center text-left text-sm font-semibold whitespace-normal 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>
    <button
      v-if="stackCount > 1"
      type="button"
      data-slot="toast-expand"
      aria-live="off"
      :aria-expanded="isExpanded"
      class="pointer-events-auto mt-2 min-h-9 cursor-pointer self-end rounded-full bg-white px-3 text-xs font-medium text-gray-600 shadow-none ring-1 ring-gray-950/10 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-gray-950 dark:text-gray-300 dark:ring-white/15"
      @click="pinned = !pinned"
    >
      {{ pinned ? 'Collapse' : isExpanded ? 'Keep open' : 'View all' }} ·
      {{ stackCount }}
    </button>
  </section>
</template>

<style>
[data-slot='toast-viewport'] [data-slot='toast-list'] {
  position: relative;
  display: block;
  pointer-events: auto;
  max-height: calc(100dvh - 7rem);
  overscroll-behavior: contain;
  transition: height 220ms ease;
}
[data-slot='toast-viewport'] [data-klean-toast-row] {
  position: absolute;
  width: 100%;
  transition:
    top 220ms ease,
    translate 220ms ease,
    scale 220ms ease,
    opacity 150ms ease;
}
[data-slot='toast-viewport'][data-expanded='false'] [data-slot='toast-list'] {
  margin-block-end: 1.5rem;
}
[data-slot='toast-viewport'][data-expanded='false'][data-position^='bottom']
  [data-slot='toast-list'] {
  margin-block-start: 1.5rem;
  margin-block-end: 0;
}
[data-slot='toast-viewport'][data-expanded='false'][data-stack-count='1']
  [data-slot='toast-list'] {
  margin-block: 0;
}
[data-slot='toast-viewport'][data-expanded='false'] [data-klean-toast-row] {
  align-self: start;
  transform-origin: center top;
  translate: 0 calc(var(--klean-toast-depth) * 12px);
  scale: calc(1 - var(--klean-toast-depth) * 0.04);
}
[data-slot='toast-viewport'][data-expanded='false'][data-position^='bottom']
  [data-klean-toast-row] {
  align-self: end;
  transform-origin: center bottom;
  translate: 0 calc(var(--klean-toast-depth) * -12px);
}
[data-slot='toast-viewport'][data-expanded='false']
  [data-klean-toast-row]:not([data-depth='0']) {
  pointer-events: none;
  max-height: var(--klean-toast-front-height);
  overflow: clip;
}
[data-slot='toast-viewport'][data-expanded='false']
  [data-klean-toast-row]:not([data-depth='0'])
  [data-klean-toast-item] {
  pointer-events: none;
  max-height: var(--klean-toast-front-height);
}
[data-slot='toast-viewport'][data-expanded='false']
  [data-klean-toast-row]:not([data-depth='0']):not([data-depth='1']):not(
    [data-depth='2']
  ) {
  opacity: 0;
}
[data-slot='toast-viewport'][data-expanded='true'] [data-slot='toast-list'] {
  overflow-y: auto;
  padding-inline: 0.25rem;
  margin-inline: -0.25rem;
  width: calc(100% + 0.5rem);
}
[data-slot='toast-viewport'][data-expanded='true'] [data-klean-toast-row] {
  width: calc(100% - 0.5rem);
}
[data-slot='toast-viewport'][data-focused='true'] [data-klean-toast-row],
[data-slot='toast-viewport'][data-focused='true'] [data-slot='toast-list'] {
  transition: none;
}
@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-slot='toast-viewport'] [data-klean-toast-row] {
    transition: none;
  }
  [data-slot='toast-viewport'] [data-slot='toast-list'] {
    transition: none;
  }
  [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>

Installation ​

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

Run from your application’s directory. Klean detects your framework and copies the matching source.

Terminal
npx klean-ui add toast

Source you own. Edit the installed files to make them yours.

Usage ​

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

Copy the example above for your framework.

Notification stacks ​

Multiple notifications form a compact stack, with the newest message in front. Hover over the stack or focus a notification to read the others. The count button lets touch and keyboard users keep the stack open. Timers pause while you read, including for notifications that arrive while it is open.

The expanded list scrolls within the viewport. Persistent notifications remain available until you dismiss them or the application completes their work. Use expanded when you want the list to stay open; omit it for the compact default.

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

<template>
  <button type="button" @click="toast('Changes saved')">Save</button>
  <Toast position="bottom-right" />
  <!-- Add expanded to keep the list open. -->
</template>
jsx
import Toast from '@/components/ui/toast/Toast.jsx'
import { toast } from '@/components/ui/toast/toast.js'

export default function Notifications() {
  return (
    <>
      <button type="button" onClick={() => toast('Changes saved')}>
        Save
      </button>
      <Toast position="bottom-right" />
      {/* Add expanded to keep the list open. */}
    </>
  )
}
svelte
<script>
  import Toast from '@/components/ui/toast/Toast.svelte'
  import { toast } from '@/components/ui/toast/toast.js'
</script>

<button type="button" onclick={() => toast('Changes saved')}>Save</button>
<Toast position="bottom-right" />
<!-- Add expanded to keep the list open. -->

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 input—Shorthand 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 metadata—Any 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 an embedded or isolated surface. 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.
expandedfalseKeep the notification list open instead of compacting it.
class / className—Tailwind 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.

Preview Live · Vue
View source toast-actions.vue
toast-actions.vue
<script setup>
import Toast from '@/components/ui/toast/Toast.vue'
import { createToast } from '@/components/ui/toast/toast.js'

const notifications = createToast()

function showLinkAction() {
  notifications({
    title: 'Draft saved',
    message: 'Continue when you are ready.',
    action: { label: 'View draft', href: '/drafts/42' }
  })
}

function showButtonAction() {
  notifications({
    title: 'Connection lost',
    action: { label: 'Retry', onClick: reconnect }
  })
}
</script>

<template>
  <button type="button" @click="showLinkAction">Show link action</button>
  <button type="button" @click="showButtonAction">Show button action</button>
  <Toast :controller="notifications" />
</template>

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.

Preview Live · Vue
View source toast-motion.vue
toast-motion.vue
<script setup>
import { ref } from 'vue'
import Toast from '@/components/ui/toast/Toast.vue'
import { createToast } from '@/components/ui/toast/toast.js'

const notifications = createToast()
const from = ref('right')
const to = ref('right')

function notify(nextFrom, nextTo = nextFrom) {
  from.value = nextFrom
  to.value = nextTo
  notifications({ title: `${nextFrom} in · ${nextTo} out` })
}
</script>

<template>
  <button type="button" @click="notify('right')">Nearest edge</button>
  <button type="button" @click="notify('right', 'left')">
    Cross the screen
  </button>
  <Toast :controller="notifications" :from="from" :to="to" />
</template>

Long-running work ​

When custom content already draws its own card, keep the wrapper neutral with class: 'block bg-transparent p-0 shadow-none ring-0 dark:bg-transparent'. Choose the card's border and shadow in the application so it has one surface, not two.

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.

Preview Live · Vue
View source deployment-toast.vue
deployment-toast.vue
<script setup>
import Toast from '@/components/ui/toast/Toast.vue'
import { createToast } from '@/components/ui/toast/toast.js'

const notifications = createToast()
let deploymentId

function startDeployment() {
  deploymentId = notifications({
    title: 'Preparing deployment',
    message: 'Reading service configuration',
    progress: 12,
    duration: false,
    dismissible: false
  })
}

function completeDeployment() {
  notifications.update(deploymentId, {
    title: 'Deployment live',
    message: 'production.example.com',
    progress: 100,
    duration: 5000,
    dismissible: true
  })
}
</script>

<template>
  <button type="button" @click="startDeployment">Deploy</button>
  <button type="button" @click="completeDeployment">Complete</button>
  <Toast :controller="notifications">
    <template #default="{ item, dismiss }">
      <!-- Application-owned progress markup and Tailwind. -->
    </template>
  </Toast>
</template>

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.

Preview Live · Vue
View source product-toasts.vue
product-toasts.vue
<script setup>
import Toast from '@/components/ui/toast/Toast.vue'
import { createToast } from '@/components/ui/toast/toast.js'

const invoiceNotifications = createToast()

function showInvoiceToast() {
  invoiceNotifications({
    title: 'Invoice sent',
    message: 'INV-1042 is on its way to Ada.',
    class:
      'rounded-none border-2 border-black bg-white text-black shadow-[6px_6px_0_0_#000]'
  })
}
</script>

<template>
  <button type="button" @click="showInvoiceToast">Send invoice</button>
  <Toast :controller="invoiceNotifications" position="top-left" />
</template>

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.
  • The notification shelf stays above non-modal floating application surfaces, so actions and dismissal remain reachable while a tooltip, menu, or popover is present. A modal Dialog still owns interaction while it is open.
  • 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
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  useTemplateRef,
  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' },
  /** Keep every notification visible instead of compacting the stack. */
  expanded: { type: Boolean, default: false }
})

const attrs = useAttrs()
const items = ref([])
const heights = ref({})
let resizeObserver
const hovered = ref(false)
const focused = ref(false)
const pinned = ref(false)
const isReading = computed(() => hovered.value || focused.value || pinned.value)
const isExpanded = computed(
  () => props.expanded || hovered.value || focused.value || pinned.value
)
const stackedItems = computed(() =>
  props.position.startsWith('top') ? [...items.value].reverse() : items.value
)
const stackCount = computed(
  () => items.value.filter((item) => item.state !== 'closing').length
)
const frontHeight = computed(() => heights.value[items.value.at(-1)?.id] ?? 0)
const listHeight = computed(() =>
  isExpanded.value
    ? items.value.reduce(
        (total, item) => total + (heights.value[item.id] ?? frontHeight.value),
        0
      )
    : frontHeight.value
)
function rowOffset(item) {
  const index = stackedItems.value.findIndex(
    (candidate) => candidate.id === item.id
  )
  return isExpanded.value
    ? stackedItems.value
        .slice(0, index)
        .reduce(
          (total, candidate) =>
            total + (heights.value[candidate.id] ?? frontHeight.value),
          0
        )
    : 0
}
function measureCards() {
  const next = {}
  for (const element of viewport.value?.querySelectorAll(
    '[data-klean-toast-item]'
  ) ?? []) {
    const row = element.parentElement
    next[row.dataset.toastId] =
      row.dataset.state === 'closing'
        ? (heights.value[row.dataset.toastId] ?? element.offsetHeight + 12)
        : element.offsetHeight + 12
  }
  heights.value = next
}
function observeCards() {
  resizeObserver?.disconnect()
  for (const element of viewport.value?.querySelectorAll(
    '[data-klean-toast-item]'
  ) ?? [])
    resizeObserver?.observe(element)
  measureCards()
}
function stackDepth(item) {
  return (
    items.value.length -
    1 -
    items.value.findIndex((candidate) => candidate.id === item.id)
  )
}
function setHover(event, value) {
  if (event.pointerType === 'mouse') hovered.value = value
}
function focusStack(event) {
  focused.value = Boolean(event.target.closest('[data-klean-toast-row]'))
  if (focused.value)
    nextTick(() =>
      event.target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
    )
}
function blurStack(event) {
  if (!event.currentTarget.contains(event.relatedTarget)) focused.value = false
}
const viewport = useTemplateRef('viewport')
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 = () => {}
let promotedItemId

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

const viewportAttrs = computed(() => {
  const {
    class: _class,
    style: _style,
    'aria-label': _ariaLabel,
    'aria-live': _ariaLive,
    popover: _popover,
    '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()
  promotedItemId = undefined
  const sync = () => {
    const snapshot = controller.getSnapshot()
    items.value = snapshot

    const enteringItem = snapshot.findLast((item) => item.state === 'entering')
    if (enteringItem && enteringItem.id !== promotedItemId) {
      promotedItemId = enteringItem.id
      const focusedElement = viewport.value?.contains(document.activeElement)
        ? document.activeElement
        : null
      hideTopLayer()
      showTopLayer()
      focusedElement?.focus({ preventScroll: true })
    }

    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')
}

function showTopLayer() {
  try {
    viewport.value?.showPopover?.()
  } catch {
    // Already open or rejected by a partial Popover API implementation.
  }
}

function hideTopLayer() {
  try {
    viewport.value?.hidePopover?.()
  } catch {
    // Already closed during teardown.
  }
}

watch(activeController, subscribe)
watch(items, observeCards, { flush: 'post' })
watch(
  isExpanded,
  (expanded) => {
    if (expanded && !focused.value && props.position.startsWith('bottom')) {
      const list = viewport.value?.querySelector('[data-slot="toast-list"]')
      if (list) list.scrollTop = list.scrollHeight
    }
  },
  { flush: 'post' }
)
watch(stackCount, (count) => {
  if (count === 0) {
    pinned.value = false
    focused.value = false
    hovered.value = false
  }
})
watch(
  [isReading, activeController],
  ([expanded, controller], previous) => {
    previous?.[1]?.resumeAll('stack-reading')
    if (expanded) controller.pauseAll('stack-reading')
    else controller.resumeAll('stack-reading')
  },
  { immediate: true }
)

onMounted(() => {
  if (typeof ResizeObserver !== 'undefined')
    resizeObserver = new ResizeObserver(measureCards)
  showTopLayer()
  subscribe(activeController.value)
  document.addEventListener('visibilitychange', handleVisibility)
  window.addEventListener('blur', handleWindowBlur)
  window.addEventListener('focus', handleWindowFocus)
  handleVisibility()
})

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

<template>
  <section
    ref="viewport"
    v-bind="viewportAttrs"
    popover="manual"
    data-slot="toast-viewport"
    :data-position="position"
    :data-from="resolvedFrom"
    :data-to="resolvedTo"
    :data-expanded="isExpanded"
    :data-focused="focused"
    :data-stack-count="stackCount"
    :aria-label="label"
    aria-live="polite"
    aria-atomic="false"
    aria-relevant="additions text"
    :class="viewportClasses"
    :style="[motionStyle, attrs.style]"
    @pointerenter="setHover($event, true)"
    @pointerleave="setHover($event, false)"
    @focusin="focusStack"
    @focusout="blurStack"
  >
    <ol
      data-slot="toast-list"
      :style="{
        height: listHeight ? `${listHeight}px` : undefined,
        '--klean-toast-front-height': frontHeight
          ? `${frontHeight}px`
          : undefined
      }"
      class="m-0 flex min-w-0 w-full list-none flex-col p-0"
    >
      <li
        v-for="item in stackedItems"
        :key="item.id"
        data-klean-toast-row
        :data-state="item.state"
        :data-depth="stackDepth(item)"
        :data-toast-id="item.id"
        :style="{
          '--klean-toast-depth': Math.min(stackDepth(item), 2),
          zIndex: items.length - stackDepth(item),
          top: `${rowOffset(item)}px`
        }"
        aria-atomic="true"
        class="grid min-w-0 grid-cols-1 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 min-w-0 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 wrap-anywhere text-gray-950 shadow-sm 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 max-w-full items-center text-left text-sm font-semibold whitespace-normal 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 max-w-full cursor-pointer items-center text-left text-sm font-semibold whitespace-normal 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>
    <button
      v-if="stackCount > 1"
      type="button"
      data-slot="toast-expand"
      aria-live="off"
      :aria-expanded="isExpanded"
      class="pointer-events-auto mt-2 min-h-9 cursor-pointer self-end rounded-full bg-white px-3 text-xs font-medium text-gray-600 shadow-none ring-1 ring-gray-950/10 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-gray-950 dark:text-gray-300 dark:ring-white/15"
      @click="pinned = !pinned"
    >
      {{ pinned ? 'Collapse' : isExpanded ? 'Keep open' : 'View all' }} ·
      {{ stackCount }}
    </button>
  </section>
</template>

<style>
[data-slot='toast-viewport'] [data-slot='toast-list'] {
  position: relative;
  display: block;
  pointer-events: auto;
  max-height: calc(100dvh - 7rem);
  overscroll-behavior: contain;
  transition: height 220ms ease;
}
[data-slot='toast-viewport'] [data-klean-toast-row] {
  position: absolute;
  width: 100%;
  transition:
    top 220ms ease,
    translate 220ms ease,
    scale 220ms ease,
    opacity 150ms ease;
}
[data-slot='toast-viewport'][data-expanded='false'] [data-slot='toast-list'] {
  margin-block-end: 1.5rem;
}
[data-slot='toast-viewport'][data-expanded='false'][data-position^='bottom']
  [data-slot='toast-list'] {
  margin-block-start: 1.5rem;
  margin-block-end: 0;
}
[data-slot='toast-viewport'][data-expanded='false'][data-stack-count='1']
  [data-slot='toast-list'] {
  margin-block: 0;
}
[data-slot='toast-viewport'][data-expanded='false'] [data-klean-toast-row] {
  align-self: start;
  transform-origin: center top;
  translate: 0 calc(var(--klean-toast-depth) * 12px);
  scale: calc(1 - var(--klean-toast-depth) * 0.04);
}
[data-slot='toast-viewport'][data-expanded='false'][data-position^='bottom']
  [data-klean-toast-row] {
  align-self: end;
  transform-origin: center bottom;
  translate: 0 calc(var(--klean-toast-depth) * -12px);
}
[data-slot='toast-viewport'][data-expanded='false']
  [data-klean-toast-row]:not([data-depth='0']) {
  pointer-events: none;
  max-height: var(--klean-toast-front-height);
  overflow: clip;
}
[data-slot='toast-viewport'][data-expanded='false']
  [data-klean-toast-row]:not([data-depth='0'])
  [data-klean-toast-item] {
  pointer-events: none;
  max-height: var(--klean-toast-front-height);
}
[data-slot='toast-viewport'][data-expanded='false']
  [data-klean-toast-row]:not([data-depth='0']):not([data-depth='1']):not(
    [data-depth='2']
  ) {
  opacity: 0;
}
[data-slot='toast-viewport'][data-expanded='true'] [data-slot='toast-list'] {
  overflow-y: auto;
  padding-inline: 0.25rem;
  margin-inline: -0.25rem;
  width: calc(100% + 0.5rem);
}
[data-slot='toast-viewport'][data-expanded='true'] [data-klean-toast-row] {
  width: calc(100% - 0.5rem);
}
[data-slot='toast-viewport'][data-focused='true'] [data-klean-toast-row],
[data-slot='toast-viewport'][data-focused='true'] [data-slot='toast-list'] {
  transition: none;
}
@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-slot='toast-viewport'] [data-klean-toast-row] {
    transition: none;
  }
  [data-slot='toast-viewport'] [data-slot='toast-list'] {
    transition: none;
  }
  [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>
jsx
import {
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  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 = `
[data-slot="toast-viewport"] [data-slot="toast-list"] {
  position: relative;
  display: block;
  pointer-events: auto;
  max-height: calc(100dvh - 7rem);
  overscroll-behavior: contain;
  transition: height 220ms ease;
}
[data-slot="toast-viewport"] [data-klean-toast-row] {
  position: absolute;
  width: 100%;
  transition:
    top 220ms ease,
    translate 220ms ease,
    scale 220ms ease,
    opacity 150ms ease;
}
[data-slot="toast-viewport"][data-expanded="false"] [data-slot="toast-list"] {
  margin-block-end: 1.5rem;
}
[data-slot="toast-viewport"][data-expanded="false"][data-position^="bottom"]
  [data-slot="toast-list"] {
  margin-block-start: 1.5rem;
  margin-block-end: 0;
}
[data-slot="toast-viewport"][data-expanded="false"][data-stack-count="1"]
  [data-slot="toast-list"] {
  margin-block: 0;
}
[data-slot="toast-viewport"][data-expanded="false"] [data-klean-toast-row] {
  align-self: start;
  transform-origin: center top;
  translate: 0 calc(var(--klean-toast-depth) * 12px);
  scale: calc(1 - var(--klean-toast-depth) * 0.04);
}
[data-slot="toast-viewport"][data-expanded="false"][data-position^="bottom"]
  [data-klean-toast-row] {
  align-self: end;
  transform-origin: center bottom;
  translate: 0 calc(var(--klean-toast-depth) * -12px);
}
[data-slot="toast-viewport"][data-expanded="false"]
  [data-klean-toast-row]:not([data-depth="0"]) {
  pointer-events: none;
  max-height: var(--klean-toast-front-height);
  overflow: clip;
}
[data-slot="toast-viewport"][data-expanded="false"]
  [data-klean-toast-row]:not([data-depth="0"])
  [data-klean-toast-item] {
  pointer-events: none;
  max-height: var(--klean-toast-front-height);
}
[data-slot="toast-viewport"][data-expanded="false"]
  [data-klean-toast-row]:not([data-depth="0"]):not([data-depth="1"]):not(
    [data-depth="2"]
  ) {
  opacity: 0;
}
[data-slot="toast-viewport"][data-expanded="true"] [data-slot="toast-list"] {
  overflow-y: auto;
  padding-inline: 0.25rem;
  margin-inline: -0.25rem;
  width: calc(100% + 0.5rem);
}
[data-slot="toast-viewport"][data-expanded="true"] [data-klean-toast-row] {
  width: calc(100% - 0.5rem);
}
[data-slot="toast-viewport"][data-focused="true"] [data-klean-toast-row],
[data-slot="toast-viewport"][data-focused="true"] [data-slot="toast-list"] { transition: none; }
@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-slot="toast-viewport"] [data-klean-toast-row] {
    transition: none;
  }
  [data-slot="toast-viewport"] [data-slot="toast-list"] {
    transition: none;
  }
  [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',
  expanded = false,
  className,
  style,
  children,
  ...viewportProps
}) {
  const viewportRef = useRef(null)
  const [hovered, setHovered] = useState(false)
  const [focused, setFocused] = useState(false)
  const [pinned, setPinned] = useState(false)
  const [heights, setHeights] = useState({})
  const isReading = hovered || focused || pinned
  const isExpanded = expanded || hovered || focused || pinned
  const promotedItemRef = useRef()
  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 stackCount = items.filter((item) => item.state !== 'closing').length
  const stackedItems = position.startsWith('top') ? [...items].reverse() : items
  const stackDepth = (item) =>
    items.length - 1 - items.findIndex((candidate) => candidate.id === item.id)
  const frontHeight = heights[items.at(-1)?.id] ?? 0
  const listHeight = isExpanded
    ? items.reduce(
        (total, item) => total + (heights[item.id] ?? frontHeight),
        0
      )
    : frontHeight
  function rowOffset(item) {
    const index = stackedItems.findIndex(
      (candidate) => candidate.id === item.id
    )
    return isExpanded
      ? stackedItems
          .slice(0, index)
          .reduce(
            (total, candidate) =>
              total + (heights[candidate.id] ?? frontHeight),
            0
          )
      : 0
  }
  useLayoutEffect(() => {
    const cards = [
      ...(viewportRef.current?.querySelectorAll('[data-klean-toast-item]') ??
        [])
    ]
    function measure() {
      setHeights((previous) => {
        const next = Object.fromEntries(
          cards.map((element) => {
            const row = element.parentElement
            return [
              row.dataset.toastId,
              row.dataset.state === 'closing'
                ? (previous[row.dataset.toastId] ?? element.offsetHeight + 12)
                : element.offsetHeight + 12
            ]
          })
        )
        return JSON.stringify(previous) === JSON.stringify(next)
          ? previous
          : next
      })
    }
    const observer =
      typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure)
    for (const card of cards) observer?.observe(card)
    measure()
    return () => observer?.disconnect()
  }, [items])
  useLayoutEffect(() => {
    const list = viewportRef.current?.querySelector('[data-slot="toast-list"]')
    if (focused)
      document.activeElement?.scrollIntoView?.({
        block: 'nearest',
        inline: 'nearest'
      })
    else if (isExpanded && position.startsWith('bottom') && list)
      list.scrollTop = list.scrollHeight
  }, [isExpanded, focused, position])
  useEffect(() => {
    if (stackCount === 0) {
      setPinned(false)
      setFocused(false)
      setHovered(false)
    }
  }, [stackCount])
  useEffect(() => {
    if (isReading) controller.pauseAll('stack-reading')
    else controller.resumeAll('stack-reading')
    return () => controller.resumeAll('stack-reading')
  }, [controller, isReading])
  const resolvedStyle = useMemo(
    () => motionStyle(resolvedFrom, resolvedTo, position, style),
    [position, resolvedFrom, resolvedTo, style]
  )

  useEffect(() => {
    const viewport = viewportRef.current
    try {
      viewport?.showPopover?.()
    } catch {
      // Already open or rejected by a partial Popover API implementation.
    }

    return () => {
      try {
        viewport?.hidePopover?.()
      } catch {
        // Already closed during teardown.
      }
    }
  }, [])

  useEffect(() => {
    const enteringItem = items.findLast((item) => item.state === 'entering')
    if (!enteringItem || enteringItem.id === promotedItemRef.current) return

    promotedItemRef.current = enteringItem.id
    const viewport = viewportRef.current
    const focusedElement = viewport?.contains(document.activeElement)
      ? document.activeElement
      : null
    try {
      viewport?.hidePopover?.()
    } catch {
      // Not open yet or already closed.
    }
    try {
      viewport?.showPopover?.()
    } catch {
      // Rejected by a partial Popover API implementation.
    }
    focusedElement?.focus({ preventScroll: true })
  }, [items])

  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 max-w-full items-center whitespace-normal text-left 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 max-w-full cursor-pointer items-center whitespace-normal text-left 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
      ref={viewportRef}
      {...viewportProps}
      popover="manual"
      data-slot="toast-viewport"
      data-position={position}
      data-from={resolvedFrom}
      data-to={resolvedTo}
      data-expanded={isExpanded}
      data-focused={focused}
      data-stack-count={stackCount}
      aria-label={label}
      aria-live="polite"
      aria-atomic="false"
      aria-relevant="additions text"
      className={twMerge(
        'pointer-events-none fixed inset-auto z-100 m-0 flex w-[min(24rem,calc(100vw-2rem))] flex-col border-0 bg-transparent p-0',
        POSITIONS[position],
        className
      )}
      style={resolvedStyle}
      onPointerEnter={(event) => {
        if (event.pointerType === 'mouse') setHovered(true)
        viewportProps.onPointerEnter?.(event)
      }}
      onPointerLeave={(event) => {
        if (event.pointerType === 'mouse') setHovered(false)
        viewportProps.onPointerLeave?.(event)
      }}
      onFocus={(event) => {
        setFocused(Boolean(event.target.closest('[data-klean-toast-row]')))
        viewportProps.onFocus?.(event)
      }}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget))
          setFocused(false)
        viewportProps.onBlur?.(event)
      }}
    >
      <style>{MOTION_CSS}</style>
      <ol
        data-slot="toast-list"
        style={{
          height: listHeight ? `${listHeight}px` : undefined,
          '--klean-toast-front-height': frontHeight
            ? `${frontHeight}px`
            : undefined
        }}
        className="m-0 flex w-full min-w-0 list-none flex-col p-0"
      >
        {stackedItems.map((item) => (
          <li
            key={item.id}
            data-klean-toast-row=""
            data-state={item.state}
            data-depth={stackDepth(item)}
            data-toast-id={item.id}
            style={{
              '--klean-toast-depth': Math.min(stackDepth(item), 2),
              zIndex: items.length - stackDepth(item),
              top: `${rowOffset(item)}px`
            }}
            aria-atomic="true"
            className="grid min-w-0 grid-cols-1 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 min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 wrap-anywhere text-gray-950 shadow-sm 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>
      {stackCount > 1 ? (
        <button
          type="button"
          data-slot="toast-expand"
          aria-live="off"
          aria-expanded={isExpanded}
          className="pointer-events-auto mt-2 min-h-9 cursor-pointer self-end rounded-full bg-white px-3 text-xs font-medium text-gray-600 shadow-none ring-1 ring-gray-950/10 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-gray-950 dark:text-gray-300 dark:ring-white/15"
          onClick={() => setPinned(!pinned)}
        >
          {pinned ? 'Collapse' : isExpanded ? 'Keep open' : 'View all'} ·{' '}
          {stackCount}
        </button>
      ) : null}
    </section>
  )
}
svelte
<script>
  import { onMount, tick } 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",
    expanded = false,
    class: className = "",
    style = "",
    children,
    ...viewportProps
  } = $props();

  let viewport;
  let items = $state([]);
  let hovered = $state(false);
  let focused = $state(false);
  let pinned = $state(false);
  let heights = $state({});
  let isReading = $derived(hovered || focused || pinned);
  let isExpanded = $derived(expanded || hovered || focused || pinned);
  let stackCount = $derived(
    items.filter((item) => item.state !== "closing").length,
  );
  let stackedItems = $derived(
    position.startsWith("top") ? [...items].reverse() : items,
  );
  function stackDepth(item) {
    return (
      items.length -
      1 -
      items.findIndex((candidate) => candidate.id === item.id)
    );
  }
  let frontHeight = $derived(heights[items.at(-1)?.id] ?? 0);
  let listHeight = $derived(
    isExpanded
      ? items.reduce(
          (total, item) => total + (heights[item.id] ?? frontHeight),
          0,
        )
      : frontHeight,
  );
  $effect(() => {
    const expandedNow = isExpanded;
    const focusedNow = focused;
    const bottom = position.startsWith("bottom");
    tick().then(() => {
      const list = viewport?.querySelector('[data-slot="toast-list"]');
      if (focusedNow)
        document.activeElement?.scrollIntoView?.({
          block: "nearest",
          inline: "nearest",
        });
      else if (expandedNow && bottom && list)
        list.scrollTop = list.scrollHeight;
    });
  });
  function rowOffset(item) {
    const index = stackedItems.findIndex(
      (candidate) => candidate.id === item.id,
    );
    return isExpanded
      ? stackedItems
          .slice(0, index)
          .reduce(
            (total, candidate) =>
              total + (heights[candidate.id] ?? frontHeight),
            0,
          )
      : 0;
  }
  $effect(() => {
    const snapshot = items;
    let observer;
    let active = true;
    tick().then(() => {
      if (!active) return;
      const cards = [
        ...(viewport?.querySelectorAll("[data-klean-toast-item]") ?? []),
      ];
      function measure() {
        heights = Object.fromEntries(
          cards.map((element) => {
            const row = element.parentElement;
            return [
              row.dataset.toastId,
              row.dataset.state === "closing"
                ? (heights[row.dataset.toastId] ?? element.offsetHeight + 12)
                : element.offsetHeight + 12,
            ];
          }),
        );
      }
      observer =
        typeof ResizeObserver === "undefined"
          ? null
          : new ResizeObserver(measure);
      for (const card of cards) observer?.observe(card);
      measure();
    });
    return () => {
      active = false;
      observer?.disconnect();
    };
  });
  function setHover(event, value) {
    if (event.pointerType === "mouse") hovered = value;
  }
  function focusStack(event) {
    focused = Boolean(event.target.closest("[data-klean-toast-row]"));
  }
  function blurStack(event) {
    if (!event.currentTarget.contains(event.relatedTarget)) focused = false;
  }
  $effect(() => {
    if (stackCount === 0) {
      pinned = false;
      focused = false;
      hovered = false;
    }
  });
  $effect(() => {
    const activeController = controller;
    if (isReading) activeController.pauseAll("stack-reading");
    else activeController.resumeAll("stack-reading");
    return () => activeController.resumeAll("stack-reading");
  });
  let promotedItemId;
  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;
    promotedItemId = undefined;
    const sync = () => {
      const snapshot = activeController.getSnapshot();
      items = snapshot;
      const enteringItem = snapshot.findLast(
        (item) => item.state === "entering",
      );
      if (enteringItem && enteringItem.id !== promotedItemId) {
        promotedItemId = enteringItem.id;
        const focusedElement = viewport?.contains(document.activeElement)
          ? document.activeElement
          : null;
        try {
          viewport?.hidePopover?.();
        } catch {
          // Not open yet or already closed.
        }
        try {
          viewport?.showPopover?.();
        } catch {
          // Rejected by a partial Popover API implementation.
        }
        focusedElement?.focus({ preventScroll: true });
      }
      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(() => {
    try {
      viewport?.showPopover?.();
    } catch {
      // Already open or rejected by a partial Popover API implementation.
    }

    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 () => {
      try {
        viewport?.hidePopover?.();
      } catch {
        // Already closed during teardown.
      }
      document.removeEventListener("visibilitychange", handleVisibility);
      window.removeEventListener("blur", handleBlur);
      window.removeEventListener("focus", handleFocus);
      controller.resumeAll("page-hidden");
      controller.resumeAll("window-blur");
    };
  });
</script>

<section
  bind:this={viewport}
  {...viewportProps}
  popover="manual"
  data-slot="toast-viewport"
  data-position={position}
  data-from={resolvedFrom}
  data-to={resolvedTo}
  data-expanded={isExpanded}
  data-focused={focused}
  data-stack-count={stackCount}
  aria-label={label}
  aria-live="polite"
  aria-atomic="false"
  aria-relevant="additions text"
  class={twMerge(
    "pointer-events-none fixed inset-auto z-100 m-0 flex w-[min(24rem,calc(100vw-2rem))] flex-col border-0 bg-transparent p-0",
    POSITIONS[position],
    className,
  )}
  style={motionStyle}
  onpointerenter={(event) => {
    setHover(event, true);
    viewportProps.onpointerenter?.(event);
  }}
  onpointerleave={(event) => {
    setHover(event, false);
    viewportProps.onpointerleave?.(event);
  }}
  onfocusin={(event) => {
    focusStack(event);
    viewportProps.onfocusin?.(event);
  }}
  onfocusout={(event) => {
    blurStack(event);
    viewportProps.onfocusout?.(event);
  }}
>
  <ol
    data-slot="toast-list"
    style={`height:${listHeight ? `${listHeight}px` : "auto"};--klean-toast-front-height:${frontHeight ? `${frontHeight}px` : "none"}`}
    class="m-0 flex w-full min-w-0 list-none flex-col p-0"
  >
    {#each stackedItems as item (item.id)}
      <li
        data-klean-toast-row
        data-state={item.state}
        data-depth={stackDepth(item)}
        data-toast-id={item.id}
        style={`--klean-toast-depth:${Math.min(stackDepth(item), 2)};z-index:${items.length - stackDepth(item)};top:${rowOffset(item)}px`}
        aria-atomic="true"
        class="grid min-w-0 grid-cols-1 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 min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 wrap-anywhere text-gray-950 shadow-sm 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 max-w-full items-center whitespace-normal text-left 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 max-w-full cursor-pointer items-center whitespace-normal text-left 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>
  {#if stackCount > 1}
    <button
      type="button"
      data-slot="toast-expand"
      aria-live="off"
      aria-expanded={isExpanded}
      class="pointer-events-auto mt-2 min-h-9 cursor-pointer self-end rounded-full bg-white px-3 text-xs font-medium text-gray-600 shadow-none ring-1 ring-gray-950/10 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-gray-950 dark:text-gray-300 dark:ring-white/15"
      onclick={() => (pinned = !pinned)}
      >{pinned ? "Collapse" : isExpanded ? "Keep open" : "View all"} · {stackCount}</button
    >
  {/if}
</section>

<style>
  [data-slot="toast-viewport"] [data-slot="toast-list"] {
    position: relative;
    display: block;
    pointer-events: auto;
    max-height: calc(100dvh - 7rem);
    overscroll-behavior: contain;
    transition: height 220ms ease;
  }
  [data-slot="toast-viewport"] [data-klean-toast-row] {
    position: absolute;
    width: 100%;
    transition:
      top 220ms ease,
      translate 220ms ease,
      scale 220ms ease,
      opacity 150ms ease;
  }
  [data-slot="toast-viewport"][data-expanded="false"] [data-slot="toast-list"] {
    margin-block-end: 1.5rem;
  }
  [data-slot="toast-viewport"][data-expanded="false"][data-position^="bottom"]
    [data-slot="toast-list"] {
    margin-block-start: 1.5rem;
    margin-block-end: 0;
  }
  [data-slot="toast-viewport"][data-expanded="false"][data-stack-count="1"]
    [data-slot="toast-list"] {
    margin-block: 0;
  }
  [data-slot="toast-viewport"][data-expanded="false"] [data-klean-toast-row] {
    align-self: start;
    transform-origin: center top;
    translate: 0 calc(var(--klean-toast-depth) * 12px);
    scale: calc(1 - var(--klean-toast-depth) * 0.04);
  }
  [data-slot="toast-viewport"][data-expanded="false"][data-position^="bottom"]
    [data-klean-toast-row] {
    align-self: end;
    transform-origin: center bottom;
    translate: 0 calc(var(--klean-toast-depth) * -12px);
  }
  [data-slot="toast-viewport"][data-expanded="false"]
    [data-klean-toast-row]:not([data-depth="0"]) {
    pointer-events: none;
    max-height: var(--klean-toast-front-height);
    overflow: clip;
  }
  [data-slot="toast-viewport"][data-expanded="false"]
    [data-klean-toast-row]:not([data-depth="0"])
    [data-klean-toast-item] {
    pointer-events: none;
    max-height: var(--klean-toast-front-height);
  }
  [data-slot="toast-viewport"][data-expanded="false"]
    [data-klean-toast-row]:not([data-depth="0"]):not([data-depth="1"]):not(
      [data-depth="2"]
    ) {
    opacity: 0;
  }
  [data-slot="toast-viewport"][data-expanded="true"] [data-slot="toast-list"] {
    overflow-y: auto;
    padding-inline: 0.25rem;
    margin-inline: -0.25rem;
    width: calc(100% + 0.5rem);
  }
  [data-slot="toast-viewport"][data-expanded="true"] [data-klean-toast-row] {
    width: calc(100% - 0.5rem);
  }
  [data-slot="toast-viewport"][data-focused="true"] [data-klean-toast-row],
  [data-slot="toast-viewport"][data-focused="true"] [data-slot="toast-list"] {
    transition: none;
  }
  @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-slot="toast-viewport"] [data-klean-toast-row] {
      transition: none;
    }
    [data-slot="toast-viewport"] [data-slot="toast-list"] {
      transition: none;
    }
    [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.