Toast
Brief notifications with actions, automatic dismissal, and loading states for async work.
@data-slot/toastSource ↗bun add @data-slot/toastnpm install @data-slot/toastpnpm add @data-slot/toastAnatomy
Only toast-viewport is required inside the root. Each call to show() clones toast-template and fills its title, description, and action slots. Without a template, the component creates default markup that you can style with the same slots. Include toast-close to let people dismiss a notification themselves.
<div data-slot="toast" data-position="bottom-right">
<template data-slot="toast-template">
<li data-slot="toast-item">
<strong data-slot="toast-title"></strong>
<p data-slot="toast-description"></p>
<button type="button" data-slot="toast-action"></button>
<button type="button" data-slot="toast-close" aria-label="Close notification">×</button>
</li>
</template>
<ol data-slot="toast-viewport" aria-label="Notifications"></ol>
</div>
Set the viewport’s position and the items’ appearance in CSS. data-position supplies a placement token; it does not position the viewport by itself. Add data-portal to move the viewport to document.body when it needs to escape a clipping container.
Examples
Show a notification
Call show() with a title, an optional description, and a type. Add an action such as Undo with its own callback. Notifications dismiss after five seconds by default; hover or focus the stack to pause the timer and expand older items. Use duration: 0 for a notification that stays until dismissed. The summary notification has a longer description, so the stack measures each item and keeps its offsets correct when heights differ.
Show code
<div data-slot="toast" data-position="bottom-right" class="toast-demo">
<div class="toast-demo-controls">
<button type="button" data-toast-success class="toast-demo-trigger">Save changes</button>
<button type="button" data-toast-error class="toast-demo-trigger">Show error</button>
<button type="button" data-toast-summary class="toast-demo-trigger">Show summary</button>
<button type="button" data-toast-clear class="toast-demo-trigger">Clear all</button>
</div>
<template data-slot="toast-template">
<li data-slot="toast-item" class="toast-demo-item">
<div class="toast-demo-copy">
<strong data-slot="toast-title" class="toast-demo-title"></strong>
<p data-slot="toast-description" class="toast-demo-description"></p>
</div>
<button type="button" data-slot="toast-action" class="toast-demo-button"></button>
<button type="button" data-slot="toast-close" aria-label="Close notification" class="toast-demo-button">×</button>
</li>
</template>
<ol data-slot="toast-viewport" aria-label="Notifications" class="toast-demo-viewport"></ol>
</div>
<style>
/* Keep the preview stack inside its example. Use position: fixed on the
viewport to anchor notifications to the browser window instead. */
.toast-demo {
position: relative;
min-height: 19rem;
width: 100%;
}
.toast-demo-viewport {
position: absolute;
right: 0;
bottom: 0;
width: min(24rem, 100%);
height: var(--toast-stack-size, 0px);
margin: 0;
padding: 0;
list-style: none;
--toast-gap: 12px;
--toast-collapsed-peek: 12px;
}
.toast-demo-viewport [data-slot="toast-item"] {
position: absolute;
inset-inline: 0;
bottom: 0;
box-sizing: border-box;
overflow-wrap: anywhere;
transform-origin: bottom center;
touch-action: none;
opacity: 0;
transform: translateY(1rem);
transition: transform 250ms ease, opacity 250ms ease, height 250ms ease;
}
.toast-demo-viewport [data-slot="toast-item"][data-mounted="true"] {
opacity: 1;
transform: translateY(0);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="false"][data-front="false"] {
transform: translateY(calc(-1 * var(--toast-collapsed-offset-y, 0px)))
scale(calc(1 - var(--toast-index, 0) * 0.05));
height: var(--toast-front-height);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="true"] {
transform: translateY(calc(-1 * var(--toast-offset, 0px)));
height: var(--toast-initial-height);
}
/* Bridge the gaps so hovering between notifications keeps the stack open. */
.toast-demo-viewport [data-slot="toast-item"][data-expanded="true"]::after {
content: "";
position: absolute;
inset-inline: 0;
bottom: 100%;
height: calc(var(--toast-gap) + 1px);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="false"][data-front="false"] > * {
opacity: 0;
}
.toast-demo-viewport [data-slot="toast-item"][data-visible="false"] {
opacity: 0;
pointer-events: none;
}
.toast-demo-viewport [data-slot="toast-item"][data-removed="true"] {
opacity: 0;
transform: translateY(1rem);
pointer-events: none;
}
.toast-demo-viewport [data-slot="toast-item"][data-swiping="true"] {
transition: none;
transform: translate(
var(--toast-swipe-amount-x, 0px),
calc(-1 * var(--toast-offset, 0px) + var(--toast-swipe-amount-y, 0px))
);
}
.toast-demo-viewport [data-slot="toast-item"][data-swipe-out="true"] {
transform: translate(
var(--toast-swipe-end-x, 0px),
calc(-1 * var(--toast-offset, 0px) + var(--toast-swipe-end-y, 0px))
);
}
.toast-demo :is(button, [data-slot="toast-item"]):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.toast-demo-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.toast-demo-trigger {
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg);
padding: 0.5rem 0.75rem;
font: inherit;
font-size: 0.875rem;
color: var(--text);
}
.toast-demo-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
border: 1px solid var(--border);
background: var(--surface);
padding: 1rem;
color: var(--text);
box-shadow: 0 8px 20px rgb(0 0 0 / 0.1);
}
.toast-demo-copy { min-width: 0; flex: 1; }
.toast-demo-title { display: block; font-size: 0.875rem; font-weight: 700; line-height: 1.25rem; }
.toast-demo-description { margin: 0.25rem 0 0; font-size: 0.75rem; line-height: 1.25rem; color: var(--muted); }
.toast-demo-button {
flex-shrink: 0;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
padding: 0.25rem 0.5rem;
font: inherit;
font-size: 0.75rem;
line-height: 1.25rem;
}
.toast-demo-trigger:hover, .toast-demo-button:hover { background: var(--code-bg); }
.toast-demo-viewport [hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
.toast-demo-viewport [data-slot="toast-item"] { transition: none; }
}
</style><div data-slot="toast" data-position="bottom-right" class="toast-demo">
<div class="flex flex-wrap justify-center gap-2">
<button type="button" data-toast-success class="cursor-pointer border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--code-bg)]">Save changes</button>
<button type="button" data-toast-error class="cursor-pointer border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--code-bg)]">Show error</button>
<button type="button" data-toast-summary class="cursor-pointer border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--code-bg)]">Show summary</button>
<button type="button" data-toast-clear class="cursor-pointer border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--code-bg)]">Clear all</button>
</div>
<template data-slot="toast-template">
<li data-slot="toast-item" class="flex items-start gap-3 border border-[var(--border)] bg-[var(--surface)] p-4 text-[var(--text)] shadow-lg">
<div class="min-w-0 flex-1">
<strong data-slot="toast-title" class="block text-sm font-bold leading-5"></strong>
<p data-slot="toast-description" class="m-0 mt-1 text-xs leading-5 text-[var(--muted)] hidden:hidden"></p>
</div>
<button type="button" data-slot="toast-action" class="shrink-0 cursor-pointer border border-[var(--border)] bg-transparent px-2 py-1 text-xs leading-5 hover:bg-[var(--code-bg)] hidden:hidden"></button>
<button type="button" data-slot="toast-close" aria-label="Close notification" class="shrink-0 cursor-pointer border border-[var(--border)] bg-transparent px-2 py-1 text-xs leading-5 hover:bg-[var(--code-bg)] hidden:hidden">×</button>
</li>
</template>
<ol data-slot="toast-viewport" aria-label="Notifications" class="toast-demo-viewport"></ol>
</div>
<style>
/* Keep the preview stack inside its example. Use position: fixed on the
viewport to anchor notifications to the browser window instead. */
.toast-demo {
position: relative;
min-height: 19rem;
width: 100%;
}
.toast-demo-viewport {
position: absolute;
right: 0;
bottom: 0;
width: min(24rem, 100%);
height: var(--toast-stack-size, 0px);
margin: 0;
padding: 0;
list-style: none;
--toast-gap: 12px;
--toast-collapsed-peek: 12px;
}
.toast-demo-viewport [data-slot="toast-item"] {
position: absolute;
inset-inline: 0;
bottom: 0;
box-sizing: border-box;
overflow-wrap: anywhere;
transform-origin: bottom center;
touch-action: none;
opacity: 0;
transform: translateY(1rem);
transition: transform 250ms ease, opacity 250ms ease, height 250ms ease;
}
.toast-demo-viewport [data-slot="toast-item"][data-mounted="true"] {
opacity: 1;
transform: translateY(0);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="false"][data-front="false"] {
transform: translateY(calc(-1 * var(--toast-collapsed-offset-y, 0px)))
scale(calc(1 - var(--toast-index, 0) * 0.05));
height: var(--toast-front-height);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="true"] {
transform: translateY(calc(-1 * var(--toast-offset, 0px)));
height: var(--toast-initial-height);
}
/* Bridge the gaps so hovering between notifications keeps the stack open. */
.toast-demo-viewport [data-slot="toast-item"][data-expanded="true"]::after {
content: "";
position: absolute;
inset-inline: 0;
bottom: 100%;
height: calc(var(--toast-gap) + 1px);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="false"][data-front="false"] > * {
opacity: 0;
}
.toast-demo-viewport [data-slot="toast-item"][data-visible="false"] {
opacity: 0;
pointer-events: none;
}
.toast-demo-viewport [data-slot="toast-item"][data-removed="true"] {
opacity: 0;
transform: translateY(1rem);
pointer-events: none;
}
.toast-demo-viewport [data-slot="toast-item"][data-swiping="true"] {
transition: none;
transform: translate(
var(--toast-swipe-amount-x, 0px),
calc(-1 * var(--toast-offset, 0px) + var(--toast-swipe-amount-y, 0px))
);
}
.toast-demo-viewport [data-slot="toast-item"][data-swipe-out="true"] {
transform: translate(
var(--toast-swipe-end-x, 0px),
calc(-1 * var(--toast-offset, 0px) + var(--toast-swipe-end-y, 0px))
);
}
.toast-demo :is(button, [data-slot="toast-item"]):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.toast-demo-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.toast-demo-trigger {
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg);
padding: 0.5rem 0.75rem;
font: inherit;
font-size: 0.875rem;
color: var(--text);
}
.toast-demo-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
border: 1px solid var(--border);
background: var(--surface);
padding: 1rem;
color: var(--text);
box-shadow: 0 8px 20px rgb(0 0 0 / 0.1);
}
.toast-demo-copy { min-width: 0; flex: 1; }
.toast-demo-title { display: block; font-size: 0.875rem; font-weight: 700; line-height: 1.25rem; }
.toast-demo-description { margin: 0.25rem 0 0; font-size: 0.75rem; line-height: 1.25rem; color: var(--muted); }
.toast-demo-button {
flex-shrink: 0;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
padding: 0.25rem 0.5rem;
font: inherit;
font-size: 0.75rem;
line-height: 1.25rem;
}
.toast-demo-trigger:hover, .toast-demo-button:hover { background: var(--code-bg); }
.toast-demo-viewport [hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
.toast-demo-viewport [data-slot="toast-item"] { transition: none; }
}
</style>import { createToast } from "@data-slot/toast";
function bindToastExample(root, toaster, signal) {
const timers = new Set();
signal.addEventListener('abort', () => {
timers.forEach(timer => clearTimeout(timer));
timers.clear();
}, { once: true });
root.querySelector('[data-toast-success]')?.addEventListener('click', () => {
toaster.show({
title: 'Changes saved',
description: 'Your preferences are up to date.',
type: 'success',
action: {
label: 'Undo',
onClick: () => toaster.show({ title: 'Changes undone', type: 'info' }),
},
});
}, { signal });
root.querySelector('[data-toast-error]')?.addEventListener('click', () => {
toaster.show({
title: 'Could not save',
description: 'Check your connection and try again.',
type: 'error',
});
}, { signal });
// A longer description wraps, so the stack mixes toast heights.
root.querySelector('[data-toast-summary]')?.addEventListener('click', () => {
toaster.show({
title: 'Weekly summary ready',
description: 'Three exports finished, one is still processing, and two drafts were saved automatically while you were away.',
type: 'info',
});
}, { signal });
root.querySelectorAll('[data-toast-promise]').forEach(button => {
button.addEventListener('click', () => {
// Simulate a request. Replace this promise with your own async operation.
const request = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
timers.delete(timer);
if (button.getAttribute('data-toast-promise') === 'error') {
reject(new Error('Please try again in a moment.'));
} else {
resolve('Your file is ready.');
}
}, 1500);
timers.add(timer);
});
toaster.promise(request, {
loading: 'Uploading file…',
success: description => ({ title: 'Upload complete', description }),
error: error => ({ title: 'Upload failed', description: error.message }),
});
}, { signal });
});
root.querySelector('[data-toast-clear]')?.addEventListener('click', () => {
toaster.dismissAll();
}, { signal });
}
// Initialize after the markup is in the document.
const abort = new AbortController();
const controllers = Array.from(document.querySelectorAll('[data-slot="toast"]'), root => {
const toaster = createToast(root);
bindToastExample(root, toaster, abort.signal);
return toaster;
});
// Clean up before removing the example.
// abort.abort();
// controllers.forEach(controller => controller.destroy());The viewport shows up to three notifications by default. Older items stay mounted but become hidden and non-interactive until space opens. Error and warning messages use role="alert"; other types use role="status" so they can be announced without moving focus.
Follow an async operation
Pass a promise to promise() to show a persistent loading message, then update that same notification when the operation resolves or rejects. The buttons below simulate both outcomes. Success and error notifications use the normal dismissal timer.
Show code
<div data-slot="toast" data-position="bottom-right" class="toast-demo">
<div class="toast-demo-controls">
<button type="button" data-toast-promise="success" class="toast-demo-trigger">Upload file</button>
<button type="button" data-toast-promise="error" class="toast-demo-trigger">Fail upload</button>
<button type="button" data-toast-clear class="toast-demo-trigger">Clear all</button>
</div>
<template data-slot="toast-template">
<li data-slot="toast-item" class="toast-demo-item">
<div class="toast-demo-copy">
<strong data-slot="toast-title" class="toast-demo-title"></strong>
<p data-slot="toast-description" class="toast-demo-description"></p>
</div>
<button type="button" data-slot="toast-action" class="toast-demo-button"></button>
<button type="button" data-slot="toast-close" aria-label="Close notification" class="toast-demo-button">×</button>
</li>
</template>
<ol data-slot="toast-viewport" aria-label="Notifications" class="toast-demo-viewport"></ol>
</div>
<style>
/* Keep the preview stack inside its example. Use position: fixed on the
viewport to anchor notifications to the browser window instead. */
.toast-demo {
position: relative;
min-height: 19rem;
width: 100%;
}
.toast-demo-viewport {
position: absolute;
right: 0;
bottom: 0;
width: min(24rem, 100%);
height: var(--toast-stack-size, 0px);
margin: 0;
padding: 0;
list-style: none;
--toast-gap: 12px;
--toast-collapsed-peek: 12px;
}
.toast-demo-viewport [data-slot="toast-item"] {
position: absolute;
inset-inline: 0;
bottom: 0;
box-sizing: border-box;
overflow-wrap: anywhere;
transform-origin: bottom center;
touch-action: none;
opacity: 0;
transform: translateY(1rem);
transition: transform 250ms ease, opacity 250ms ease, height 250ms ease;
}
.toast-demo-viewport [data-slot="toast-item"][data-mounted="true"] {
opacity: 1;
transform: translateY(0);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="false"][data-front="false"] {
transform: translateY(calc(-1 * var(--toast-collapsed-offset-y, 0px)))
scale(calc(1 - var(--toast-index, 0) * 0.05));
height: var(--toast-front-height);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="true"] {
transform: translateY(calc(-1 * var(--toast-offset, 0px)));
height: var(--toast-initial-height);
}
/* Bridge the gaps so hovering between notifications keeps the stack open. */
.toast-demo-viewport [data-slot="toast-item"][data-expanded="true"]::after {
content: "";
position: absolute;
inset-inline: 0;
bottom: 100%;
height: calc(var(--toast-gap) + 1px);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="false"][data-front="false"] > * {
opacity: 0;
}
.toast-demo-viewport [data-slot="toast-item"][data-visible="false"] {
opacity: 0;
pointer-events: none;
}
.toast-demo-viewport [data-slot="toast-item"][data-removed="true"] {
opacity: 0;
transform: translateY(1rem);
pointer-events: none;
}
.toast-demo-viewport [data-slot="toast-item"][data-swiping="true"] {
transition: none;
transform: translate(
var(--toast-swipe-amount-x, 0px),
calc(-1 * var(--toast-offset, 0px) + var(--toast-swipe-amount-y, 0px))
);
}
.toast-demo-viewport [data-slot="toast-item"][data-swipe-out="true"] {
transform: translate(
var(--toast-swipe-end-x, 0px),
calc(-1 * var(--toast-offset, 0px) + var(--toast-swipe-end-y, 0px))
);
}
.toast-demo :is(button, [data-slot="toast-item"]):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.toast-demo-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.toast-demo-trigger {
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg);
padding: 0.5rem 0.75rem;
font: inherit;
font-size: 0.875rem;
color: var(--text);
}
.toast-demo-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
border: 1px solid var(--border);
background: var(--surface);
padding: 1rem;
color: var(--text);
box-shadow: 0 8px 20px rgb(0 0 0 / 0.1);
}
.toast-demo-copy { min-width: 0; flex: 1; }
.toast-demo-title { display: block; font-size: 0.875rem; font-weight: 700; line-height: 1.25rem; }
.toast-demo-description { margin: 0.25rem 0 0; font-size: 0.75rem; line-height: 1.25rem; color: var(--muted); }
.toast-demo-button {
flex-shrink: 0;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
padding: 0.25rem 0.5rem;
font: inherit;
font-size: 0.75rem;
line-height: 1.25rem;
}
.toast-demo-trigger:hover, .toast-demo-button:hover { background: var(--code-bg); }
.toast-demo-viewport [hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
.toast-demo-viewport [data-slot="toast-item"] { transition: none; }
}
</style><div data-slot="toast" data-position="bottom-right" class="toast-demo">
<div class="flex flex-wrap justify-center gap-2">
<button type="button" data-toast-promise="success" class="cursor-pointer border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--code-bg)]">Upload file</button>
<button type="button" data-toast-promise="error" class="cursor-pointer border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--code-bg)]">Fail upload</button>
<button type="button" data-toast-clear class="cursor-pointer border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--code-bg)]">Clear all</button>
</div>
<template data-slot="toast-template">
<li data-slot="toast-item" class="flex items-start gap-3 border border-[var(--border)] bg-[var(--surface)] p-4 text-[var(--text)] shadow-lg">
<div class="min-w-0 flex-1">
<strong data-slot="toast-title" class="block text-sm font-bold leading-5"></strong>
<p data-slot="toast-description" class="m-0 mt-1 text-xs leading-5 text-[var(--muted)] hidden:hidden"></p>
</div>
<button type="button" data-slot="toast-action" class="shrink-0 cursor-pointer border border-[var(--border)] bg-transparent px-2 py-1 text-xs leading-5 hover:bg-[var(--code-bg)] hidden:hidden"></button>
<button type="button" data-slot="toast-close" aria-label="Close notification" class="shrink-0 cursor-pointer border border-[var(--border)] bg-transparent px-2 py-1 text-xs leading-5 hover:bg-[var(--code-bg)] hidden:hidden">×</button>
</li>
</template>
<ol data-slot="toast-viewport" aria-label="Notifications" class="toast-demo-viewport"></ol>
</div>
<style>
/* Keep the preview stack inside its example. Use position: fixed on the
viewport to anchor notifications to the browser window instead. */
.toast-demo {
position: relative;
min-height: 19rem;
width: 100%;
}
.toast-demo-viewport {
position: absolute;
right: 0;
bottom: 0;
width: min(24rem, 100%);
height: var(--toast-stack-size, 0px);
margin: 0;
padding: 0;
list-style: none;
--toast-gap: 12px;
--toast-collapsed-peek: 12px;
}
.toast-demo-viewport [data-slot="toast-item"] {
position: absolute;
inset-inline: 0;
bottom: 0;
box-sizing: border-box;
overflow-wrap: anywhere;
transform-origin: bottom center;
touch-action: none;
opacity: 0;
transform: translateY(1rem);
transition: transform 250ms ease, opacity 250ms ease, height 250ms ease;
}
.toast-demo-viewport [data-slot="toast-item"][data-mounted="true"] {
opacity: 1;
transform: translateY(0);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="false"][data-front="false"] {
transform: translateY(calc(-1 * var(--toast-collapsed-offset-y, 0px)))
scale(calc(1 - var(--toast-index, 0) * 0.05));
height: var(--toast-front-height);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="true"] {
transform: translateY(calc(-1 * var(--toast-offset, 0px)));
height: var(--toast-initial-height);
}
/* Bridge the gaps so hovering between notifications keeps the stack open. */
.toast-demo-viewport [data-slot="toast-item"][data-expanded="true"]::after {
content: "";
position: absolute;
inset-inline: 0;
bottom: 100%;
height: calc(var(--toast-gap) + 1px);
}
.toast-demo-viewport [data-slot="toast-item"][data-expanded="false"][data-front="false"] > * {
opacity: 0;
}
.toast-demo-viewport [data-slot="toast-item"][data-visible="false"] {
opacity: 0;
pointer-events: none;
}
.toast-demo-viewport [data-slot="toast-item"][data-removed="true"] {
opacity: 0;
transform: translateY(1rem);
pointer-events: none;
}
.toast-demo-viewport [data-slot="toast-item"][data-swiping="true"] {
transition: none;
transform: translate(
var(--toast-swipe-amount-x, 0px),
calc(-1 * var(--toast-offset, 0px) + var(--toast-swipe-amount-y, 0px))
);
}
.toast-demo-viewport [data-slot="toast-item"][data-swipe-out="true"] {
transform: translate(
var(--toast-swipe-end-x, 0px),
calc(-1 * var(--toast-offset, 0px) + var(--toast-swipe-end-y, 0px))
);
}
.toast-demo :is(button, [data-slot="toast-item"]):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.toast-demo-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.toast-demo-trigger {
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg);
padding: 0.5rem 0.75rem;
font: inherit;
font-size: 0.875rem;
color: var(--text);
}
.toast-demo-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
border: 1px solid var(--border);
background: var(--surface);
padding: 1rem;
color: var(--text);
box-shadow: 0 8px 20px rgb(0 0 0 / 0.1);
}
.toast-demo-copy { min-width: 0; flex: 1; }
.toast-demo-title { display: block; font-size: 0.875rem; font-weight: 700; line-height: 1.25rem; }
.toast-demo-description { margin: 0.25rem 0 0; font-size: 0.75rem; line-height: 1.25rem; color: var(--muted); }
.toast-demo-button {
flex-shrink: 0;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
padding: 0.25rem 0.5rem;
font: inherit;
font-size: 0.75rem;
line-height: 1.25rem;
}
.toast-demo-trigger:hover, .toast-demo-button:hover { background: var(--code-bg); }
.toast-demo-viewport [hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
.toast-demo-viewport [data-slot="toast-item"] { transition: none; }
}
</style>import { createToast } from "@data-slot/toast";
function bindToastExample(root, toaster, signal) {
const timers = new Set();
signal.addEventListener('abort', () => {
timers.forEach(timer => clearTimeout(timer));
timers.clear();
}, { once: true });
root.querySelector('[data-toast-success]')?.addEventListener('click', () => {
toaster.show({
title: 'Changes saved',
description: 'Your preferences are up to date.',
type: 'success',
action: {
label: 'Undo',
onClick: () => toaster.show({ title: 'Changes undone', type: 'info' }),
},
});
}, { signal });
root.querySelector('[data-toast-error]')?.addEventListener('click', () => {
toaster.show({
title: 'Could not save',
description: 'Check your connection and try again.',
type: 'error',
});
}, { signal });
// A longer description wraps, so the stack mixes toast heights.
root.querySelector('[data-toast-summary]')?.addEventListener('click', () => {
toaster.show({
title: 'Weekly summary ready',
description: 'Three exports finished, one is still processing, and two drafts were saved automatically while you were away.',
type: 'info',
});
}, { signal });
root.querySelectorAll('[data-toast-promise]').forEach(button => {
button.addEventListener('click', () => {
// Simulate a request. Replace this promise with your own async operation.
const request = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
timers.delete(timer);
if (button.getAttribute('data-toast-promise') === 'error') {
reject(new Error('Please try again in a moment.'));
} else {
resolve('Your file is ready.');
}
}, 1500);
timers.add(timer);
});
toaster.promise(request, {
loading: 'Uploading file…',
success: description => ({ title: 'Upload complete', description }),
error: error => ({ title: 'Upload failed', description: error.message }),
});
}, { signal });
});
root.querySelector('[data-toast-clear]')?.addEventListener('click', () => {
toaster.dismissAll();
}, { signal });
}
// Initialize after the markup is in the document.
const abort = new AbortController();
const controllers = Array.from(document.querySelectorAll('[data-slot="toast"]'), root => {
const toaster = createToast(root);
bindToastExample(root, toaster, abort.signal);
return toaster;
});
// Clean up before removing the example.
// abort.abort();
// controllers.forEach(controller => controller.destroy());promise() returns an object with id and unwrap(). Await unwrap() when you also need the result in your application, and handle a rejected promise with try / catch. For other updates, keep the id returned by show() and pass it to update() or dismiss().
API reference
create(scope?)
Auto-discover and bind uninitialized toast roots in a scope (document by default).
Roots already initialized with either create() or createToast() are skipped.
import { create } from "@data-slot/toast";
const controllers = create(); // ToastController[]
createToast(root, options?)
Create a controller for one toast root.
Repeated calls for the same root return the existing controller and keep its original options. Destroy that controller before rebinding with different options.
import { createToast } from "@data-slot/toast";
const toaster = createToast(element, {
limit: 3,
duration: 5000,
position: "bottom-right",
pauseOnHover: true,
pauseOnFocus: true,
portal: false,
onShow: (id) => console.log("shown", id),
onDismiss: (id) => console.log("dismissed", id),
onAction: (id, value) => console.log("action", id, value),
});
Options
| Option | Type | Default | Description |
|---|---|---|---|
limit |
number |
3 |
Maximum visible toasts at once; older toasts stay mounted with data-visible="false" |
duration |
number |
5000 |
Default auto-dismiss duration in ms (0 = persistent) |
position |
"top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" |
"bottom-right" |
Position token exposed as data-position on root and viewport |
pauseOnHover |
boolean |
true |
Pause all active timers while viewport is hovered |
pauseOnFocus |
boolean |
true |
Pause all active timers while viewport has focus within |
portal |
boolean |
false |
Portal viewport to document.body |
onShow |
(id: string) => void |
undefined |
Callback when a toast is shown |
onDismiss |
(id: string) => void |
undefined |
Callback when a toast starts dismissing |
onAction |
(id: string, value: string | undefined) => void |
undefined |
Callback when action button is clicked |
Timers also pause automatically while the document is hidden or the window loses focus.
show(options)
const id = toaster.show({
id: "save-1",
title: "Saved",
description: "Your profile was updated",
type: "success",
duration: 4000,
dismissible: true,
closeButtonAriaLabel: "Close notification",
testId: "save-toast",
action: {
label: "Undo",
value: "undo-save",
onClick: () => console.log("undo"),
},
});
title is required. If id is reused, the previous toast is force-replaced.
action.onClick may call event.preventDefault() to keep the toast open.
promise(input, options)
const handled = toaster.promise(fetch("/api/save"), {
loading: "Saving...",
success: "Saved",
error: (error) => ({
title: error instanceof Error ? error.message : "Save failed",
}),
});
await handled.unwrap();
promise() keeps a stable toast id across loading/success/error states and returns { id, unwrap() }.
Each state is applied as a patch: title, type, and duration fall back to state defaults, and any other field a state omits stays as the loading state set it.
Controller
| Method / Property | Description |
|---|---|
show(options) |
Create and show a toast, returns its id |
update(id, patch) |
Patch an existing active toast in place (visible or overflow-hidden). Omitted or undefined fields are unchanged; null clears description, action, closeButtonAriaLabel, or testId |
promise(input, options) |
Drive loading/success/error toast states from a promise, returns { id, unwrap() } |
dismiss(id) |
Dismiss one toast |
dismissAll() |
Dismiss all active toasts |
count |
Total active (non-exiting) toast count, including overflow-hidden items |
destroy() |
Cleanup listeners, timers, observers and restore portaled viewport |
Slots
Required
toast-viewport
Optional
toast-template(<template>)toast-itemtoast-titletoast-descriptiontoast-actiontoast-close
If toast-template is missing or invalid, the library generates a full fallback template.
Data Attributes
JS options take precedence over data attributes.
| Attribute | Type | Default | Description |
|---|---|---|---|
data-limit |
number | 3 |
Max visible toasts at once (older items remain mounted, hidden, and non-interactive when full) |
data-duration |
number | 5000 |
Default duration in ms |
data-position |
position token | "bottom-right" |
Placement hint for styling |
data-pause-on-hover |
boolean | true |
Hover-based timer pause |
data-pause-on-focus |
boolean | true |
Focus-based timer pause |
data-portal |
boolean | false |
Portal viewport to body |
Runtime attributes:
toast-item:data-id,data-type,data-state,data-open,data-closed,data-mounted,data-removed,data-front,data-visible,data-expandedtoast-item:data-swiping,data-swipe-out,data-dismissible="false"(when swiping is disabled)toast-item:aria-hidden,inertwhiledata-visible="false"(removed when visible again)toast-viewport:data-expanded(hover/focus fan-out state)
Animation Tokens
The controller computes and writes stack tokens for animation styling. Item heights are measured by briefly setting height: auto inline on the item, so do not pin the item height with !important.
--toast-index(0 = newest)--toast-initial-height(item’s measured natural height)--toast-offset(expanded stack offset)--toast-collapsed-offset-y(collapsed stack offset)--toast-count(on viewport, inherited by items)--toast-lift(on viewport, inherited by items;1for top stacks,-1for bottom stacks)--toast-front-height(on viewport)--toast-expanded-stack-size(on viewport)--toast-collapsed-stack-size(on viewport)--toast-stack-size(on viewport, active size; collapsed by default, expanded whiledata-expanded)--toast-collapsed-peek(on viewport; collapsed stack step)--toast-swipe-amount-x(item-level; live horizontal swipe offset for left/right stacks)--toast-swipe-amount-y(item-level; live vertical swipe offset)--toast-swipe-end-x/--toast-swipe-end-y(item-level; resolved swipe-out exit target)
These are updated on show, dismiss, exit complete, and item resize.
Events
Outbound (on root)
| Event | Detail |
|---|---|
toast:change |
{ id: string, action: "show" | "dismiss" } |
toast:action |
{ id: string, value: string | undefined } |
Inbound (on root)
| Event | Detail |
|---|---|
toast:show |
ToastShowOptions |
toast:update |
{ id: string } & ToastUpdateOptions |
toast:dismiss |
{ id: string } or string |
toast:clear |
none |
root.dispatchEvent(
new CustomEvent("toast:show", {
detail: { title: "Background sync complete", type: "success" },
}),
);
root.dispatchEvent(
new CustomEvent("toast:update", {
detail: { id: "save-1", title: "Saved", type: "success" },
}),
);
root.dispatchEvent(new CustomEvent("toast:dismiss", { detail: { id: "save-1" } }));
root.dispatchEvent(new CustomEvent("toast:clear"));
Styling Example
[data-slot="toast-viewport"] {
position: fixed;
right: 1rem;
bottom: 1rem;
width: min(360px, calc(100vw - 2rem));
height: var(--toast-stack-size, 0px);
--toast-gap: 8px;
--toast-collapsed-peek: 14px;
}
[data-slot="toast-item"] {
position: absolute;
inset-inline: 0;
bottom: 0;
box-sizing: border-box;
opacity: 0;
transform: translate3d(0, calc(var(--toast-lift, -1) * -100%), 0);
transition:
transform 400ms ease,
opacity 400ms ease,
height 400ms ease,
box-shadow 200ms ease;
}
[data-slot="toast-item"][data-mounted="true"] {
transform: translate3d(0, 0, 0);
opacity: 1;
}
[data-slot="toast-item"][data-mounted="true"][data-expanded="false"][data-front="false"] {
transform: translate3d(
0,
calc(var(--toast-collapsed-offset-y, 0px) * var(--toast-lift, -1)),
0
)
scale(calc(1 - var(--toast-index, 0) * 0.05));
height: var(--toast-front-height);
}
[data-slot="toast-item"][data-mounted="true"][data-expanded="true"] {
transform: translate3d(
0,
calc(var(--toast-offset, 0px) * var(--toast-lift, -1)),
0
);
height: var(--toast-initial-height);
}
[data-slot="toast-item"][data-expanded="false"][data-front="false"][data-state="open"] > * {
opacity: 0;
}
[data-slot="toast-item"][data-visible="false"] {
opacity: 0;
pointer-events: none;
}
[data-slot="toast-item"][data-removed="true"][data-swipe-out="true"][data-front="true"] {
transform: translate3d(
var(--toast-swipe-end-x, 0px),
var(--toast-swipe-end-y, 0px),
0
);
opacity: 0;
}
[data-slot="toast-item"][data-removed="true"][data-swipe-out="true"][data-front="false"][data-expanded="true"] {
transform: translate3d(
var(--toast-swipe-end-x, 0px),
calc(var(--toast-lift, -1) * var(--toast-offset, 0px) + var(--toast-swipe-end-y, 0px)),
0
);
opacity: 0;
}
[data-slot="toast-item"][data-removed="true"][data-swipe-out="true"][data-front="false"][data-expanded="false"] {
transform: translate3d(
var(--toast-swipe-end-x, 0px),
calc(
var(--toast-collapsed-offset-y, 0px) * var(--toast-lift, -1) +
var(--toast-swipe-end-y, 0px)
),
0
)
scale(calc(1 - var(--toast-index, 0) * 0.05));
opacity: 0;
}
[data-slot="toast-item"][data-expanded="true"]::after {
content: "";
position: absolute;
left: 0;
width: 100%;
height: calc(var(--toast-gap, 0px) + 1px);
bottom: 100%;
}
[data-slot="toast-item"][data-removed="true"][data-front="true"] {
transform: translate3d(0, calc(var(--toast-lift, -1) * -100%), 0);
opacity: 0;
}
[data-slot="toast-item"][data-removed="true"][data-front="false"][data-expanded="true"] {
transform: translate3d(
0,
calc(var(--toast-lift, -1) * var(--toast-offset, 0px) + var(--toast-lift, -1) * -100%),
0
);
opacity: 0;
}
[data-slot="toast-item"][data-removed="true"][data-front="false"][data-expanded="false"] {
transform: translate3d(0, 40%, 0);
opacity: 0;
transition:
transform 500ms ease,
opacity 200ms ease;
}
[data-slot="toast-item"][data-swiping="true"] {
transition: none;
}
[data-slot="toast-item"][data-swiping="true"][data-front="true"] {
transform: translate3d(
var(--toast-swipe-amount-x, 0px),
var(--toast-swipe-amount-y, 0px),
0
);
}
[data-slot="toast-item"][data-swiping="true"][data-expanded="false"][data-front="false"] {
transform: translate3d(
var(--toast-swipe-amount-x, 0px),
calc(
var(--toast-collapsed-offset-y, 0px) * var(--toast-lift, -1) +
var(--toast-swipe-amount-y, 0px)
),
0
)
scale(calc(1 - var(--toast-index, 0) * 0.05));
}
[data-slot="toast-item"][data-swiping="true"][data-expanded="true"] {
transform: translate3d(
var(--toast-swipe-amount-x, 0px),
calc(var(--toast-offset, 0px) * var(--toast-lift, -1) + var(--toast-swipe-amount-y, 0px)),
0
);
}
Migrating from Sonner
The stack model, the data-* state attributes, and the layout tokens follow Sonner, so existing Sonner CSS ports with a rename pass. Tokens carry a --toast- prefix so they do not clash with page-level custom properties.
| Sonner | @data-slot/toast |
|---|---|
[data-sonner-toaster] |
[data-slot="toast-viewport"] |
[data-sonner-toast] |
[data-slot="toast-item"] |
[data-y-position="bottom"][data-x-position="right"] |
[data-position="bottom-right"] (on root and viewport) |
[data-title], [data-description], [data-button], [data-close-button] |
[data-slot="toast-title"], toast-description, toast-action, toast-close |
--index |
--toast-index |
--toasts-before |
--toast-index (same value) |
--offset |
--toast-offset |
--initial-height |
--toast-initial-height |
--front-toast-height |
--toast-front-height |
--swipe-amount-x, --swipe-amount-y |
--toast-swipe-amount-x, --toast-swipe-amount-y |
--lift |
--toast-lift (written by the controller, so no position rule is needed) |
--gap |
--toast-gap (declare it on the viewport; the controller reads it) |
--z-index |
inline z-index on the item |
--width |
set the viewport width in CSS |
Unchanged: data-mounted, data-removed, data-front, data-visible, data-expanded, data-swiping, data-swipe-out, data-type, and data-dismissible.
Differences to account for:
- Sonner derives
--lift-amountand--yin its own stylesheet from the position attributes. If your CSS uses them, declare them yourself, for example--lift-amount: calc(var(--toast-lift) * var(--toast-gap)). - Sonner sets
--gap,--width, and the viewport offsets from props. Here they are plain CSS on the viewport. - After a swipe-out, the exit target lives in
--toast-swipe-end-xand--toast-swipe-end-y. Sonner reuses--swipe-amount-*for that. - The viewport also receives
--toast-stack-size,--toast-expanded-stack-size, and--toast-collapsed-stack-sizeso it can size itself; Sonner has no equivalent.
Accessibility
- Viewport defaults:
role="region",aria-label="Notifications" - Item defaults:
aria-atomic="true" errorandwarningtoasts are assertive (role="alert")- Other toasts are polite (
role="status")