Skip to content
data-slotv1.0.0
Esc
↑↓navigate↵open⌘Jpreview
On this page

Toast

Brief notifications with actions, automatic dismissal, and loading states for async work.

@data-slot/toastSource ↗
bun add @data-slot/toast
npm install @data-slot/toast
pnpm add @data-slot/toast

Anatomy

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.

Preview
      Show codeHide 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>

      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.

      Preview
          Show codeHide 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>

          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-item
          • toast-title
          • toast-description
          • toast-action
          • toast-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-expanded
          • toast-item: data-swiping, data-swipe-out, data-dismissible="false" (when swiping is disabled)
          • toast-item: aria-hidden, inert while data-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; 1 for top stacks, -1 for 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 while data-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-amount and --y in 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-x and --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-size so it can size itself; Sonner has no equivalent.

          Accessibility

          • Viewport defaults: role="region", aria-label="Notifications"
          • Item defaults: aria-atomic="true"
          • error and warning toasts are assertive (role="alert")
          • Other toasts are polite (role="status")