Toast
Toast shows short, non-blocking messages for confirmations, failures, and long-running work.
Installation
One command detects Vue, React, or Svelte and adds Toast:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add toast- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
The command creates toast/toast.js and toast/Toast.vue, Toast.jsx, or Toast.svelte under the conventional components directory. Both files immediately belong to the application. There is no Klean runtime dependency, klean-ui.json, provider wrapper, alias questionnaire, or generated cn.js.
Usage
Mount <Toast /> near the application root. Then call toast() wherever a notification is needed.
Vue
<script setup>
import Toast from '@/components/ui/toast/Toast.vue'
import { toast } from '@/components/ui/toast/toast.js'
function save() {
toast({
title: 'Changes saved',
message: 'Your draft is ready.',
action: { label: 'View draft', href: '/drafts/42' }
})
}
</script>
<template>
<Toast />
<button type="button" @click="save">Save changes</button>
</template>
React
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>
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 tests or embedded surfaces. 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. |
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.
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.
Long-running work
Use duration: false when an external event controls completion. Keep the returned ID and update the same notification instead of adding one toast for every status message.
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.
Accessibility and Durable UI contract
- New notifications are announced politely and never steal focus.
- Timers pause while a toast is hovered or focused, and while the page is inactive. They resume with the remaining time.
- Dismiss controls have specific accessible names. Actions use real links or buttons.
update()keeps long-running work in one notification.- Reduced-motion preferences are respected.
- Notifications are temporary and are never persisted in storage or the URL.
Complete framework source
Copy, inspect, and change the complete source for your framework.
Controller
const DEFAULT_DURATION = 5000
const DEFAULT_MAX = 4
const ENTER_FALLBACK = 500
const LEAVE_FALLBACK = 450
function normalizeDuration(value, fallback) {
if (value === false || value === 0) return 0
const duration = Number(value)
return Number.isFinite(duration) && duration > 0 ? duration : fallback
}
export function createToast({
duration = DEFAULT_DURATION,
max = DEFAULT_MAX
} = {}) {
const listeners = new Set()
const timers = new Map()
const lifecycleTimers = new Map()
const pauseReasons = new Map()
const globalPauseReasons = new Set()
let items = []
let nextId = 0
function getSnapshot() {
return items
}
function subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
}
function publish(nextItems) {
items = nextItems
for (const listener of listeners) listener()
}
function clearTimer(id) {
const timer = timers.get(id)
if (!timer?.timeout) return
clearTimeout(timer.timeout)
timer.timeout = null
}
function clearLifecycleTimer(id) {
const timeout = lifecycleTimers.get(id)
if (timeout) clearTimeout(timeout)
lifecycleTimers.delete(id)
}
function reasonsFor(id) {
if (!pauseReasons.has(id)) {
pauseReasons.set(id, new Set(globalPauseReasons))
}
return pauseReasons.get(id)
}
function schedule(id) {
const timer = timers.get(id)
const item = items.find((candidate) => candidate.id === id)
if (
!timer ||
!item ||
item.state === 'closing' ||
timer.remaining <= 0 ||
reasonsFor(id).size > 0
) {
return
}
timer.startedAt = Date.now()
timer.timeout = setTimeout(() => dismiss(id), timer.remaining)
}
function resetTimer(id, itemDuration) {
clearTimer(id)
timers.delete(id)
if (!itemDuration) return
timers.set(id, {
remaining: itemDuration,
startedAt: 0,
timeout: null
})
schedule(id)
}
function completeEnter(id) {
clearLifecycleTimer(id)
const index = items.findIndex((item) => item.id === id)
if (index === -1 || items[index].state !== 'entering') return false
const nextItems = [...items]
nextItems[index] = { ...nextItems[index], state: 'open' }
publish(nextItems)
return true
}
function remove(id) {
const exists = items.some((item) => item.id === id)
if (!exists) return false
clearTimer(id)
timers.delete(id)
clearLifecycleTimer(id)
pauseReasons.delete(id)
publish(items.filter((item) => item.id !== id))
return true
}
function dismiss(id) {
const index = items.findIndex((item) => item.id === id)
if (index === -1 || items[index].state === 'closing') return false
clearTimer(id)
timers.delete(id)
clearLifecycleTimer(id)
const nextItems = [...items]
nextItems[index] = { ...nextItems[index], state: 'closing' }
publish(nextItems)
lifecycleTimers.set(
id,
setTimeout(() => remove(id), LEAVE_FALLBACK)
)
return true
}
function pause(id, reason = 'interaction') {
if (!items.some((item) => item.id === id)) return false
const reasons = reasonsFor(id)
if (reasons.has(reason)) return false
reasons.add(reason)
const timer = timers.get(id)
if (timer?.timeout) {
timer.remaining = Math.max(
0,
timer.remaining - (Date.now() - timer.startedAt)
)
clearTimer(id)
}
return true
}
function resume(id, reason = 'interaction') {
const reasons = pauseReasons.get(id)
if (!reasons?.has(reason)) return false
reasons.delete(reason)
schedule(id)
return true
}
function pauseAll(reason = 'page') {
globalPauseReasons.add(reason)
for (const item of items) pause(item.id, reason)
}
function resumeAll(reason = 'page') {
globalPauseReasons.delete(reason)
for (const item of items) resume(item.id, reason)
}
function update(id, patch = {}) {
const index = items.findIndex((item) => item.id === id)
if (index === -1 || items[index].state === 'closing') return false
const current = items[index]
const itemDuration =
patch.duration === undefined
? current.duration
: normalizeDuration(patch.duration, duration)
const nextItems = [...items]
nextItems[index] = {
...current,
...patch,
id,
duration: itemDuration,
state: current.state
}
publish(nextItems)
if (patch.duration !== undefined) resetTimer(id, itemDuration)
return true
}
function clear() {
for (const item of [...items]) dismiss(item.id)
}
function destroy() {
for (const id of [...timers.keys()]) clearTimer(id)
for (const id of [...lifecycleTimers.keys()]) clearLifecycleTimer(id)
timers.clear()
pauseReasons.clear()
globalPauseReasons.clear()
items = []
listeners.clear()
}
function notify(input = {}, options = {}) {
const item =
typeof input === 'string' ? { ...options, message: input } : { ...input }
const id = item.id ?? `toast-${++nextId}`
const existing = items.find(
(candidate) => candidate.id === id && candidate.state !== 'closing'
)
if (existing) {
update(id, item)
return id
}
const openItems = items.filter((candidate) => candidate.state !== 'closing')
if (max > 0 && openItems.length >= max) dismiss(openItems[0].id)
const itemDuration = normalizeDuration(item.duration, duration)
const nextItem = {
id,
title: '',
message: '',
class: '',
...item,
duration: itemDuration,
state: 'entering'
}
pauseReasons.set(id, new Set(globalPauseReasons))
publish([...items, nextItem])
resetTimer(id, itemDuration)
lifecycleTimers.set(
id,
setTimeout(() => completeEnter(id), ENTER_FALLBACK)
)
return id
}
Object.assign(notify, {
clear,
completeEnter,
destroy,
dismiss,
getSnapshot,
pause,
pauseAll,
remove,
resume,
resumeAll,
subscribe,
update
})
return notify
}
export const toast = createToast()
Vue source
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import { toast } from './toast.js'
defineOptions({ inheritAttrs: false })
const POSITIONS = {
'top-left': 'left-4 top-4 items-start',
'top-center': 'left-1/2 top-4 -translate-x-1/2 items-center',
'top-right': 'right-4 top-4 items-end',
'bottom-left': 'bottom-4 left-4 items-start',
'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2 items-center',
'bottom-right': 'bottom-4 right-4 items-end'
}
const POSITION_EDGES = {
'top-left': ['top', 'left'],
'top-center': ['top'],
'top-right': ['top', 'right'],
'bottom-left': ['bottom', 'left'],
'bottom-center': ['bottom'],
'bottom-right': ['bottom', 'right']
}
const NEARBY_DURATION = { enter: 300, leave: 200 }
const CROSS_VIEWPORT_DURATION = { enter: 450, leave: 320 }
function motionVector(direction, position) {
if (direction === 'fade' || direction === 'none') {
return { x: '0px', y: '0px' }
}
const nearby = POSITION_EDGES[position]?.includes(direction)
const horizontal = direction === 'left' || direction === 'right'
const distance = nearby
? 'calc(100% + 1rem)'
: horizontal
? '100vw'
: '100dvh'
const negative = direction === 'left' || direction === 'top'
const signedDistance = negative
? nearby
? 'calc(-100% - 1rem)'
: horizontal
? '-100vw'
: '-100dvh'
: distance
return horizontal
? { x: signedDistance, y: '0px' }
: { x: '0px', y: signedDistance }
}
function motionDuration(phase, direction, position) {
if (direction === 'none') return 0
if (['fade', ...POSITION_EDGES[position]].includes(direction)) {
return NEARBY_DURATION[phase]
}
return CROSS_VIEWPORT_DURATION[phase]
}
const props = defineProps({
/** Optional isolated controller. The shared `toast` works without a provider. */
controller: { type: Function, default: undefined },
/** Fixed viewport position. */
position: {
type: String,
default: 'top-right',
validator: (value) =>
[
'top-left',
'top-center',
'top-right',
'bottom-left',
'bottom-center',
'bottom-right'
].includes(value)
},
/** Direction new notifications enter from. */
from: {
type: String,
default: undefined,
validator: (value) =>
['left', 'right', 'top', 'bottom', 'fade', 'none'].includes(value)
},
/** Direction dismissed notifications leave toward. */
to: {
type: String,
default: undefined,
validator: (value) =>
['left', 'right', 'top', 'bottom', 'fade', 'none'].includes(value)
},
/** Accessible name for the persistent live region. */
label: { type: String, default: 'Notifications' }
})
const attrs = useAttrs()
const items = ref([])
const activeController = computed(() => props.controller ?? toast)
const defaultDirection = computed(() =>
props.position.endsWith('-left') ? 'left' : 'right'
)
const resolvedFrom = computed(() => props.from ?? defaultDirection.value)
const resolvedTo = computed(() => props.to ?? defaultDirection.value)
let unsubscribe = () => {}
const viewportClasses = computed(() =>
twMerge(
'pointer-events-none fixed z-100 m-0 flex w-[min(24rem,calc(100vw-2rem))] flex-col',
POSITIONS[props.position],
attrs.class
)
)
const viewportAttrs = computed(() => {
const {
class: _class,
style: _style,
'aria-label': _ariaLabel,
'aria-live': _ariaLive,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
const motionStyle = computed(() => {
const enter = motionVector(resolvedFrom.value, props.position)
const leave = motionVector(resolvedTo.value, props.position)
const enterDuration = motionDuration(
'enter',
resolvedFrom.value,
props.position
)
const leaveDuration = motionDuration(
'leave',
resolvedTo.value,
props.position
)
const collapseDelay = Math.min(80, Math.round(leaveDuration * 0.4))
return {
'--klean-toast-enter-x': enter.x,
'--klean-toast-enter-y': enter.y,
'--klean-toast-leave-x': leave.x,
'--klean-toast-leave-y': leave.y,
'--klean-toast-enter-duration': `${enterDuration}ms`,
'--klean-toast-leave-duration': `${leaveDuration}ms`,
'--klean-toast-collapse-delay': `${collapseDelay}ms`,
'--klean-toast-collapse-duration': `${Math.max(0, leaveDuration - collapseDelay)}ms`
}
})
function activateAction(item, event) {
item.action?.onClick?.(event, item)
activeController.value.dismiss(item.id)
}
function subscribe(controller) {
unsubscribe()
const sync = () => {
items.value = controller.getSnapshot()
if (resolvedFrom.value === 'none' || resolvedTo.value === 'none') {
queueMicrotask(() => {
for (const item of controller.getSnapshot()) {
if (item.state === 'entering' && resolvedFrom.value === 'none') {
controller.completeEnter(item.id)
} else if (item.state === 'closing' && resolvedTo.value === 'none') {
controller.remove(item.id)
}
}
})
}
}
sync()
unsubscribe = controller.subscribe(sync)
}
function handleAnimationEnd(item, event) {
if (event.target !== event.currentTarget) return
if (item.state === 'entering') activeController.value.completeEnter(item.id)
else if (item.state === 'closing') activeController.value.remove(item.id)
}
function handleFocusOut(item, event) {
if (!event.currentTarget.contains(event.relatedTarget)) {
activeController.value.resume(item.id, 'focus')
}
}
function handleVisibility() {
if (document.hidden) activeController.value.pauseAll('page-hidden')
else activeController.value.resumeAll('page-hidden')
}
function handleWindowBlur() {
activeController.value.pauseAll('window-blur')
}
function handleWindowFocus() {
activeController.value.resumeAll('window-blur')
}
watch(activeController, subscribe)
onMounted(() => {
subscribe(activeController.value)
document.addEventListener('visibilitychange', handleVisibility)
window.addEventListener('blur', handleWindowBlur)
window.addEventListener('focus', handleWindowFocus)
handleVisibility()
})
onBeforeUnmount(() => {
unsubscribe()
document.removeEventListener('visibilitychange', handleVisibility)
window.removeEventListener('blur', handleWindowBlur)
window.removeEventListener('focus', handleWindowFocus)
activeController.value.resumeAll('page-hidden')
activeController.value.resumeAll('window-blur')
})
</script>
<template>
<section
v-bind="viewportAttrs"
data-slot="toast-viewport"
:data-position="position"
:data-from="resolvedFrom"
:data-to="resolvedTo"
:aria-label="label"
aria-live="polite"
aria-atomic="false"
aria-relevant="additions text"
:class="viewportClasses"
:style="[motionStyle, attrs.style]"
>
<ol data-slot="toast-list" class="m-0 flex w-full list-none flex-col p-0">
<li
v-for="item in items"
:key="item.id"
data-klean-toast-row
:data-state="item.state"
aria-atomic="true"
class="grid grid-rows-[1fr] pb-3"
@mouseenter="activeController.pause(item.id, 'hover')"
@mouseleave="activeController.resume(item.id, 'hover')"
@focusin="activeController.pause(item.id, 'focus')"
@focusout="handleFocusOut(item, $event)"
>
<div
data-slot="toast"
data-klean-toast-item
:data-state="item.state"
:data-from="resolvedFrom"
:data-to="resolvedTo"
:class="
twMerge(
'pointer-events-auto grid min-h-0 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 text-gray-950 shadow-xl ring-1 ring-gray-950/10 dark:bg-gray-950 dark:text-white dark:ring-white/15',
item.class
)
"
@animationend="handleAnimationEnd(item, $event)"
>
<slot :item="item" :dismiss="() => activeController.dismiss(item.id)">
<div class="min-w-0 pt-0.5">
<p
v-if="item.title"
data-slot="toast-title"
class="text-sm font-semibold leading-5"
>
{{ item.title }}
</p>
<p
v-if="item.message"
data-slot="toast-message"
:class="
twMerge(
'text-sm leading-5 text-gray-600 dark:text-gray-300',
item.title && 'mt-0.5'
)
"
>
{{ item.message }}
</p>
<a
v-if="item.action?.href"
data-slot="toast-action"
:href="item.action.href"
:class="
twMerge(
'mt-2 inline-flex min-h-8 items-center text-sm font-semibold text-gray-950 underline decoration-gray-300 underline-offset-4 hover:decoration-current focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:decoration-gray-600 dark:focus-visible:ring-white',
item.action.class
)
"
@click="activateAction(item, $event)"
>
{{ item.action.label }}
</a>
<button
v-else-if="item.action?.label"
type="button"
data-slot="toast-action"
:class="
twMerge(
'mt-2 inline-flex min-h-8 cursor-pointer items-center text-sm font-semibold text-gray-950 hover:text-gray-600 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:hover:text-gray-300 dark:focus-visible:ring-white',
item.action.class
)
"
@click="activateAction(item, $event)"
>
{{ item.action.label }}
</button>
</div>
<button
v-if="item.dismissible !== false"
type="button"
data-slot="toast-dismiss"
class="-mr-2 -mt-1 grid size-9 cursor-pointer place-items-center rounded-lg text-lg leading-none text-gray-400 hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:ring-white"
:aria-label="
item.dismissLabel ?? `Dismiss ${item.title || 'notification'}`
"
@click="activeController.dismiss(item.id)"
>
<span aria-hidden="true">×</span>
</button>
</slot>
</div>
</li>
</ol>
</section>
</template>
<style>
@keyframes klean-toast-enter {
0% {
opacity: 0;
transform: translate3d(
var(--klean-toast-enter-x),
var(--klean-toast-enter-y),
0
)
scale(0.98);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes klean-toast-leave {
0% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate3d(
var(--klean-toast-leave-x),
var(--klean-toast-leave-y),
0
)
scale(0.98);
}
}
@keyframes klean-toast-collapse {
0% {
grid-template-rows: 1fr;
padding-block-end: 0.75rem;
}
100% {
grid-template-rows: 0fr;
padding-block-end: 0;
}
}
[data-klean-toast-item][data-state='entering'] {
animation: klean-toast-enter var(--klean-toast-enter-duration) ease-out both;
}
[data-klean-toast-item][data-state='closing'] {
animation: klean-toast-leave var(--klean-toast-leave-duration) ease-in both;
pointer-events: none;
}
[data-klean-toast-row][data-state='closing'] {
animation: klean-toast-collapse var(--klean-toast-collapse-duration) ease-in
var(--klean-toast-collapse-delay) both;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
[data-klean-toast-item][data-state] {
animation-duration: 1ms;
animation-timing-function: linear;
}
[data-klean-toast-row][data-state='closing'] {
animation-delay: 0ms;
animation-duration: 1ms;
}
}
</style>
React source
import { useEffect, useMemo, useSyncExternalStore } from 'react'
import { twMerge } from 'tailwind-merge'
import { toast } from './toast.js'
const POSITIONS = {
'top-left': 'left-4 top-4 items-start',
'top-center': 'left-1/2 top-4 -translate-x-1/2 items-center',
'top-right': 'right-4 top-4 items-end',
'bottom-left': 'bottom-4 left-4 items-start',
'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2 items-center',
'bottom-right': 'bottom-4 right-4 items-end'
}
const POSITION_EDGES = {
'top-left': ['top', 'left'],
'top-center': ['top'],
'top-right': ['top', 'right'],
'bottom-left': ['bottom', 'left'],
'bottom-center': ['bottom'],
'bottom-right': ['bottom', 'right']
}
const NEARBY_DURATION = { enter: 300, leave: 200 }
const CROSS_VIEWPORT_DURATION = { enter: 450, leave: 320 }
function motionVector(direction, position) {
if (direction === 'fade' || direction === 'none') return ['0px', '0px']
const nearby = POSITION_EDGES[position]?.includes(direction)
const horizontal = direction === 'left' || direction === 'right'
const negative = direction === 'left' || direction === 'top'
const distance = nearby
? negative
? 'calc(-100% - 1rem)'
: 'calc(100% + 1rem)'
: horizontal
? negative
? '-100vw'
: '100vw'
: negative
? '-100dvh'
: '100dvh'
return horizontal ? [distance, '0px'] : ['0px', distance]
}
function motionDuration(phase, direction, position) {
if (direction === 'none') return 0
if (['fade', ...POSITION_EDGES[position]].includes(direction)) {
return NEARBY_DURATION[phase]
}
return CROSS_VIEWPORT_DURATION[phase]
}
const MOTION_CSS = `
@keyframes klean-toast-enter {
0% { opacity: 0; transform: translate3d(var(--klean-toast-enter-x), var(--klean-toast-enter-y), 0) scale(.98); }
100% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}
@keyframes klean-toast-leave {
0% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
100% { opacity: 0; transform: translate3d(var(--klean-toast-leave-x), var(--klean-toast-leave-y), 0) scale(.98); }
}
@keyframes klean-toast-collapse {
0% { grid-template-rows: 1fr; padding-block-end: .75rem; }
100% { grid-template-rows: 0fr; padding-block-end: 0; }
}
[data-klean-toast-item][data-state="entering"] { animation: klean-toast-enter var(--klean-toast-enter-duration) ease-out both; }
[data-klean-toast-item][data-state="closing"] { animation: klean-toast-leave var(--klean-toast-leave-duration) ease-in both; pointer-events: none; }
[data-klean-toast-row][data-state="closing"] { animation: klean-toast-collapse var(--klean-toast-collapse-duration) ease-in var(--klean-toast-collapse-delay) both; overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
[data-klean-toast-item][data-state] { animation-duration: 1ms; animation-timing-function: linear; }
[data-klean-toast-row][data-state="closing"] { animation-delay: 0ms; animation-duration: 1ms; }
}`
function motionStyle(from, to, position, style) {
const enter = motionVector(from, position)
const leave = motionVector(to, position)
const enterDuration = motionDuration('enter', from, position)
const leaveDuration = motionDuration('leave', to, position)
const collapseDelay = Math.min(80, Math.round(leaveDuration * 0.4))
return {
'--klean-toast-enter-x': enter[0],
'--klean-toast-enter-y': enter[1],
'--klean-toast-leave-x': leave[0],
'--klean-toast-leave-y': leave[1],
'--klean-toast-enter-duration': `${enterDuration}ms`,
'--klean-toast-leave-duration': `${leaveDuration}ms`,
'--klean-toast-collapse-delay': `${collapseDelay}ms`,
'--klean-toast-collapse-duration': `${Math.max(0, leaveDuration - collapseDelay)}ms`,
...style
}
}
export default function Toast({
controller = toast,
position = 'top-right',
from,
to,
label = 'Notifications',
className,
style,
children,
...viewportProps
}) {
const defaultDirection = position.endsWith('-left') ? 'left' : 'right'
const resolvedFrom = from ?? defaultDirection
const resolvedTo = to ?? defaultDirection
const items = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot
)
const resolvedStyle = useMemo(
() => motionStyle(resolvedFrom, resolvedTo, position, style),
[position, resolvedFrom, resolvedTo, style]
)
useEffect(() => {
function syncInstantMotion() {
queueMicrotask(() => {
for (const item of controller.getSnapshot()) {
if (item.state === 'entering' && resolvedFrom === 'none') {
controller.completeEnter(item.id)
} else if (item.state === 'closing' && resolvedTo === 'none') {
controller.remove(item.id)
}
}
})
}
syncInstantMotion()
return controller.subscribe(syncInstantMotion)
}, [controller, resolvedFrom, resolvedTo])
useEffect(() => {
function handleVisibility() {
if (document.hidden) controller.pauseAll('page-hidden')
else controller.resumeAll('page-hidden')
}
function handleBlur() {
controller.pauseAll('window-blur')
}
function handleFocus() {
controller.resumeAll('window-blur')
}
document.addEventListener('visibilitychange', handleVisibility)
window.addEventListener('blur', handleBlur)
window.addEventListener('focus', handleFocus)
handleVisibility()
return () => {
document.removeEventListener('visibilitychange', handleVisibility)
window.removeEventListener('blur', handleBlur)
window.removeEventListener('focus', handleFocus)
controller.resumeAll('page-hidden')
controller.resumeAll('window-blur')
}
}, [controller])
function handleAnimationEnd(item, event) {
if (event.target !== event.currentTarget) return
if (item.state === 'entering') controller.completeEnter(item.id)
else if (item.state === 'closing') controller.remove(item.id)
}
function activateAction(item, event) {
item.action?.onClick?.(event, item)
controller.dismiss(item.id)
}
function defaultContent(item) {
return (
<>
<div className="min-w-0 pt-0.5">
{item.title ? (
<p
data-slot="toast-title"
className="text-sm font-semibold leading-5"
>
{item.title}
</p>
) : null}
{item.message ? (
<p
data-slot="toast-message"
className={twMerge(
'text-sm leading-5 text-gray-600 dark:text-gray-300',
item.title && 'mt-0.5'
)}
>
{item.message}
</p>
) : null}
{item.action?.href ? (
<a
data-slot="toast-action"
href={item.action.href}
className={twMerge(
'mt-2 inline-flex min-h-8 items-center text-sm font-semibold text-gray-950 underline decoration-gray-300 underline-offset-4 hover:decoration-current focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:decoration-gray-600 dark:focus-visible:ring-white',
item.action.class,
item.action.className
)}
onClick={(event) => activateAction(item, event)}
>
{item.action.label}
</a>
) : item.action?.label ? (
<button
type="button"
data-slot="toast-action"
className={twMerge(
'mt-2 inline-flex min-h-8 cursor-pointer items-center text-sm font-semibold text-gray-950 hover:text-gray-600 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:hover:text-gray-300 dark:focus-visible:ring-white',
item.action.class,
item.action.className
)}
onClick={(event) => activateAction(item, event)}
>
{item.action.label}
</button>
) : null}
</div>
{item.dismissible !== false ? (
<button
type="button"
data-slot="toast-dismiss"
className="-mr-2 -mt-1 grid size-9 cursor-pointer place-items-center rounded-lg text-lg leading-none text-gray-400 hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:ring-white"
aria-label={
item.dismissLabel ?? `Dismiss ${item.title || 'notification'}`
}
onClick={() => controller.dismiss(item.id)}
>
<span aria-hidden="true">×</span>
</button>
) : null}
</>
)
}
return (
<section
{...viewportProps}
data-slot="toast-viewport"
data-position={position}
data-from={resolvedFrom}
data-to={resolvedTo}
aria-label={label}
aria-live="polite"
aria-atomic="false"
aria-relevant="additions text"
className={twMerge(
'pointer-events-none fixed z-100 m-0 flex w-[min(24rem,calc(100vw-2rem))] flex-col',
POSITIONS[position],
className
)}
style={resolvedStyle}
>
<style>{MOTION_CSS}</style>
<ol
data-slot="toast-list"
className="m-0 flex w-full list-none flex-col p-0"
>
{items.map((item) => (
<li
key={item.id}
data-klean-toast-row=""
data-state={item.state}
aria-atomic="true"
className="grid grid-rows-[1fr] pb-3"
onMouseEnter={() => controller.pause(item.id, 'hover')}
onMouseLeave={() => controller.resume(item.id, 'hover')}
onFocus={() => controller.pause(item.id, 'focus')}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
controller.resume(item.id, 'focus')
}
}}
>
<div
data-slot="toast"
data-klean-toast-item=""
data-state={item.state}
data-from={resolvedFrom}
data-to={resolvedTo}
className={twMerge(
'pointer-events-auto grid min-h-0 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 text-gray-950 shadow-xl ring-1 ring-gray-950/10 dark:bg-gray-950 dark:text-white dark:ring-white/15',
item.class,
item.className
)}
onAnimationEnd={(event) => handleAnimationEnd(item, event)}
>
{typeof children === 'function'
? children({
item,
dismiss: () => controller.dismiss(item.id)
})
: defaultContent(item)}
</div>
</li>
))}
</ol>
</section>
)
}
Svelte source
<script>
import { onMount } from "svelte";
import { twMerge } from "tailwind-merge";
import { toast } from "./toast.js";
const POSITIONS = {
"top-left": "left-4 top-4 items-start",
"top-center": "left-1/2 top-4 -translate-x-1/2 items-center",
"top-right": "right-4 top-4 items-end",
"bottom-left": "bottom-4 left-4 items-start",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2 items-center",
"bottom-right": "bottom-4 right-4 items-end",
};
const POSITION_EDGES = {
"top-left": ["top", "left"],
"top-center": ["top"],
"top-right": ["top", "right"],
"bottom-left": ["bottom", "left"],
"bottom-center": ["bottom"],
"bottom-right": ["bottom", "right"],
};
const NEARBY_DURATION = { enter: 300, leave: 200 };
const CROSS_VIEWPORT_DURATION = { enter: 450, leave: 320 };
function motionVector(direction, position) {
if (direction === "fade" || direction === "none") return ["0px", "0px"];
const nearby = POSITION_EDGES[position]?.includes(direction);
const horizontal = direction === "left" || direction === "right";
const negative = direction === "left" || direction === "top";
const distance = nearby
? negative
? "calc(-100% - 1rem)"
: "calc(100% + 1rem)"
: horizontal
? negative
? "-100vw"
: "100vw"
: negative
? "-100dvh"
: "100dvh";
return horizontal ? [distance, "0px"] : ["0px", distance];
}
function motionDuration(phase, direction, position) {
if (direction === "none") return 0;
if (["fade", ...POSITION_EDGES[position]].includes(direction)) {
return NEARBY_DURATION[phase];
}
return CROSS_VIEWPORT_DURATION[phase];
}
let {
controller = toast,
position = "top-right",
from,
to,
label = "Notifications",
class: className = "",
style = "",
children,
...viewportProps
} = $props();
let items = $state([]);
let defaultDirection = $derived(
position.endsWith("-left") ? "left" : "right",
);
let resolvedFrom = $derived(from ?? defaultDirection);
let resolvedTo = $derived(to ?? defaultDirection);
let motionStyle = $derived.by(() => {
const enter = motionVector(resolvedFrom, position);
const leave = motionVector(resolvedTo, position);
const enterDuration = motionDuration("enter", resolvedFrom, position);
const leaveDuration = motionDuration("leave", resolvedTo, position);
const collapseDelay = Math.min(80, Math.round(leaveDuration * 0.4));
return [
`--klean-toast-enter-x:${enter[0]}`,
`--klean-toast-enter-y:${enter[1]}`,
`--klean-toast-leave-x:${leave[0]}`,
`--klean-toast-leave-y:${leave[1]}`,
`--klean-toast-enter-duration:${enterDuration}ms`,
`--klean-toast-leave-duration:${leaveDuration}ms`,
`--klean-toast-collapse-delay:${collapseDelay}ms`,
`--klean-toast-collapse-duration:${Math.max(0, leaveDuration - collapseDelay)}ms`,
style,
]
.filter(Boolean)
.join(";");
});
function syncInstantMotion() {
queueMicrotask(() => {
for (const item of controller.getSnapshot()) {
if (item.state === "entering" && resolvedFrom === "none") {
controller.completeEnter(item.id);
} else if (item.state === "closing" && resolvedTo === "none") {
controller.remove(item.id);
}
}
});
}
$effect(() => {
const activeController = controller;
const sync = () => {
items = activeController.getSnapshot();
syncInstantMotion();
};
sync();
const unsubscribe = activeController.subscribe(sync);
return unsubscribe;
});
function handleAnimationEnd(item, event) {
if (event.target !== event.currentTarget) return;
if (item.state === "entering") controller.completeEnter(item.id);
else if (item.state === "closing") controller.remove(item.id);
}
function handleFocusOut(item, event) {
if (!event.currentTarget.contains(event.relatedTarget)) {
controller.resume(item.id, "focus");
}
}
function activateAction(item, event) {
item.action?.onClick?.(event, item);
controller.dismiss(item.id);
}
onMount(() => {
function handleVisibility() {
if (document.hidden) controller.pauseAll("page-hidden");
else controller.resumeAll("page-hidden");
}
function handleBlur() {
controller.pauseAll("window-blur");
}
function handleFocus() {
controller.resumeAll("window-blur");
}
document.addEventListener("visibilitychange", handleVisibility);
window.addEventListener("blur", handleBlur);
window.addEventListener("focus", handleFocus);
handleVisibility();
return () => {
document.removeEventListener("visibilitychange", handleVisibility);
window.removeEventListener("blur", handleBlur);
window.removeEventListener("focus", handleFocus);
controller.resumeAll("page-hidden");
controller.resumeAll("window-blur");
};
});
</script>
<section
{...viewportProps}
data-slot="toast-viewport"
data-position={position}
data-from={resolvedFrom}
data-to={resolvedTo}
aria-label={label}
aria-live="polite"
aria-atomic="false"
aria-relevant="additions text"
class={twMerge(
"pointer-events-none fixed z-100 m-0 flex w-[min(24rem,calc(100vw-2rem))] flex-col",
POSITIONS[position],
className,
)}
style={motionStyle}
>
<ol data-slot="toast-list" class="m-0 flex w-full list-none flex-col p-0">
{#each items as item (item.id)}
<li
data-klean-toast-row
data-state={item.state}
aria-atomic="true"
class="grid grid-rows-[1fr] pb-3"
onmouseenter={() => controller.pause(item.id, "hover")}
onmouseleave={() => controller.resume(item.id, "hover")}
onfocusin={() => controller.pause(item.id, "focus")}
onfocusout={(event) => handleFocusOut(item, event)}
>
<div
data-slot="toast"
data-klean-toast-item
data-state={item.state}
data-from={resolvedFrom}
data-to={resolvedTo}
class={twMerge(
"pointer-events-auto grid min-h-0 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 overflow-hidden rounded-xl bg-white px-4 py-3 text-gray-950 shadow-xl ring-1 ring-gray-950/10 dark:bg-gray-950 dark:text-white dark:ring-white/15",
item.class,
item.className,
)}
onanimationend={(event) => handleAnimationEnd(item, event)}
>
{#if children}
{@render children({
item,
dismiss: () => controller.dismiss(item.id),
})}
{:else}
<div class="min-w-0 pt-0.5">
{#if item.title}
<p
data-slot="toast-title"
class="text-sm font-semibold leading-5"
>
{item.title}
</p>
{/if}
{#if item.message}
<p
data-slot="toast-message"
class={twMerge(
"text-sm leading-5 text-gray-600 dark:text-gray-300",
item.title && "mt-0.5",
)}
>
{item.message}
</p>
{/if}
{#if item.action?.href}
<a
data-slot="toast-action"
href={item.action.href}
class={twMerge(
"mt-2 inline-flex min-h-8 items-center text-sm font-semibold text-gray-950 underline decoration-gray-300 underline-offset-4 hover:decoration-current focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:decoration-gray-600 dark:focus-visible:ring-white",
item.action.class,
item.action.className,
)}
onclick={(event) => activateAction(item, event)}
>
{item.action.label}
</a>
{:else if item.action?.label}
<button
type="button"
data-slot="toast-action"
class={twMerge(
"mt-2 inline-flex min-h-8 cursor-pointer items-center text-sm font-semibold text-gray-950 hover:text-gray-600 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-white dark:hover:text-gray-300 dark:focus-visible:ring-white",
item.action.class,
item.action.className,
)}
onclick={(event) => activateAction(item, event)}
>
{item.action.label}
</button>
{/if}
</div>
{#if item.dismissible !== false}
<button
type="button"
data-slot="toast-dismiss"
class="-mr-2 -mt-1 grid size-9 cursor-pointer place-items-center rounded-lg text-lg leading-none text-gray-400 hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:ring-white"
aria-label={item.dismissLabel ??
`Dismiss ${item.title || "notification"}`}
onclick={() => controller.dismiss(item.id)}
>
<span aria-hidden="true">×</span>
</button>
{/if}
{/if}
</div>
</li>
{/each}
</ol>
</section>
<style>
@keyframes klean-toast-enter {
0% {
opacity: 0;
transform: translate3d(
var(--klean-toast-enter-x),
var(--klean-toast-enter-y),
0
)
scale(0.98);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes klean-toast-leave {
0% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
100% {
opacity: 0;
transform: translate3d(
var(--klean-toast-leave-x),
var(--klean-toast-leave-y),
0
)
scale(0.98);
}
}
@keyframes klean-toast-collapse {
0% {
grid-template-rows: 1fr;
padding-block-end: 0.75rem;
}
100% {
grid-template-rows: 0fr;
padding-block-end: 0;
}
}
[data-klean-toast-item][data-state="entering"] {
animation: klean-toast-enter var(--klean-toast-enter-duration) ease-out both;
}
[data-klean-toast-item][data-state="closing"] {
animation: klean-toast-leave var(--klean-toast-leave-duration) ease-in both;
pointer-events: none;
}
[data-klean-toast-row][data-state="closing"] {
animation: klean-toast-collapse var(--klean-toast-collapse-duration) ease-in
var(--klean-toast-collapse-delay) both;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
[data-klean-toast-item][data-state] {
animation-duration: 1ms;
animation-timing-function: linear;
}
[data-klean-toast-row][data-state="closing"] {
animation-delay: 0ms;
animation-duration: 1ms;
}
}
</style>
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.