Toast
Toast shows short, non-blocking messages for confirmations, failures, and long-running work.
<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>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>
</>
)
}<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
<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.
npx klean-ui add toastUsage
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.
<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>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. */}
</>
)
}<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:
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'
})| Input | Default | Purpose |
|---|---|---|
| string input | — | Shorthand for the notification message. |
title | '' | Short notification heading. |
message | '' | Supporting detail. |
duration | 5000 | Visible time in milliseconds. Use false or 0 for externally completed work. |
dismissible | true | Whether 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:
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
| Input | Default | Purpose |
|---|---|---|
controller | shared toast | An optional independent toast instance. |
position | top-right | top-left, top-center, top-right, bottom-left, bottom-center, or bottom-right. |
from | nearest horizontal edge | Entry direction: left, right, top, bottom, fade, or none. |
to | nearest horizontal edge | Exit direction using the same values. |
label | Notifications | Accessible name for the persistent live region. |
expanded | false | Keep the notification list open instead of compacting it. |
class / className | — | Tailwind for the viewport shelf. Item styling belongs to toast({ class }). |
| default content | built-in body | Vue 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.
View source 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.
View source 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.
View source 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.
View source 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
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()
<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>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>
)
}<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>Related components
- 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.