Navigation

v0.0.4
Feedback

Toast

A brief message over the page that can leave by itself.

On this page

A toast is a brief message over the page about something that just happened: saved, deployed, copied. Each toast is a non-modal <dialog> inside a fixed toast-region. Being a dialog, it can be dismissed without script, and it enters and leaves with motion the stylesheet writes in CSS.

Toast

<div class="toast-region" role="region" aria-label="Notifications" aria-live="polite">  <dialog open class="toast toast-success" aria-labelledby="toast-deployed">    <svg class="icon toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">      <circle cx="12" cy="12" r="9" />      <path d="m8.5 12.5 2.5 2.5 4.5-5.5" />    </svg>    <div class="toast-body">      <p class="toast-title" id="toast-deployed">Deployed to production</p>      <p class="toast-description">atlas.dev is live in 14 regions.</p>    </div>    <form method="dialog" class="toast-close">      <button class="btn btn-quiet btn-square btn-sm" aria-label="Dismiss">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M6 6l12 12M18 6 6 18" />        </svg>      </button>    </form>  </dialog></div>
Dismiss the toast with its button. No script is involved.

#Showing and dismissing

  • A toast with the open attribute is shown when the page loads, which suits a message rendered by the server after a form post.
  • From script, call toast.show() on a toast in the region, or append a new <dialog open class="toast"> to it. @starting-style gives the toast the frame it arrives from in both cases.
  • A <form method="dialog"> around a button dismisses the toast, as does a button with commandfor and command="close". display transitions with allow-discrete, so the exit plays before the toast stops taking space.

#Timed

toast-timed makes a toast leave by itself after six seconds. The clock is a CSS animation that ends in display: none, and it pauses while the pointer or the keyboard is on the toast, so a reader is never raced. A hairline along the toast's foot runs out with the clock and stops when the clock does. Set --toast-duration to change the time. Keep a timed toast free of actions a reader might still need: anything important belongs in an alert that stays.

Timed

<div class="toast-region" role="region" aria-label="Notifications" aria-live="polite">  <dialog open class="toast toast-timed" aria-label="Link copied">    <div class="toast-body">      <p class="toast-description">Link copied to the clipboard.</p>    </div>  </dialog></div>
This toast leaves after six seconds. Reload the page to see it again.

#With an action

A button after the body sits at the end of the row. A status modifier colors the icon only; the words stay in ink.

With an action

<div class="toast-region" role="region" aria-label="Notifications" aria-live="polite">  <dialog open class="toast" aria-labelledby="toast-archived">    <div class="toast-body">      <p class="toast-title" id="toast-archived">Project archived</p>      <p class="toast-description">Atlas is hidden from the dashboard.</p>    </div>    <form method="dialog">      <button class="btn btn-sm">Undo</button>    </form>  </dialog>  <dialog open class="toast toast-danger" aria-labelledby="toast-failed">    <svg class="icon toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">      <circle cx="12" cy="12" r="9" />      <path d="M12 8v5M12 16v.01" />    </svg>    <div class="toast-body">      <p class="toast-title" id="toast-failed">The build failed</p>      <p class="toast-description">The install step exited with code 1.</p>    </div>    <form method="dialog" class="toast-close">      <button class="btn btn-quiet btn-square btn-sm" aria-label="Dismiss">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M6 6l12 12M18 6 6 18" />        </svg>      </button>    </form>  </dialog></div>

#The region

toast-region is fixed to the bottom end corner and stacks toasts with the newest at the bottom. It takes no pointer events itself, so the page under its empty space stays usable. On a narrow screen it spans the width.

ClassPlacement
toast-regionBottom, at the end edge
toast-region-topTop; toasts arrive downward and the newest is on top
toast-region-centerCentered instead of at the end edge

The region is an ordinary fixed element with a z-index. To show toasts above an open modal dialog, give the region popover="manual" and call showPopover() on it once: it then joins the top layer.

#Accessibility

  • Make the region a live region with aria-live="polite", so a toast is announced when it appears, and name the region with aria-label.
  • Name each toast with aria-labelledby pointing at its title, or with aria-label.
  • A toast is for confirmation. A reader must be able to miss it without losing anything, so do not put the only way to an action in one.
  • With reduced motion a toast fades in place.

#React, Svelte and Vue

The toast component is a queue: a toast() function to call from anywhere, and the region, rendered once, that shows what is in it. Copy it into the project with npx master-ui add toast; React, Svelte and Vue says how the components are built and used.

ui/toast.tsx
'use client'import { useSyncExternalStore, type ComponentProps, type CSSProperties, type ReactNode } from 'react'export interface ToastMessage {  title?: ReactNode  description?: ReactNode  /** Colors the icon; give the icon itself in `icon`, with the classes `icon toast-icon`. */  status?: 'info' | 'success' | 'warning' | 'danger'  icon?: ReactNode  /** How long the toast stays, in milliseconds. 6000 by default; `false` keeps it until it is dismissed. */  duration?: number | false}interface Shown extends ToastMessage { id: number }let shown: Shown[] = []let count = 0const listeners = new Set<() => void>()const none: Shown[] = []function publish(next: Shown[]) {  shown = next  listeners.forEach(listener => listener())}function subscribe(listener: () => void) {  listeners.add(listener)  return () => { listeners.delete(listener) }}const remove = (id: number) => publish(shown.filter(toast => toast.id !== id))/** * Show a toast in the `Toaster` of the page. Returns a function that removes it. * @example toast({ title: 'Deployed to production', status: 'success' }) */export function toast(message: ToastMessage | string) {  // A server has no page to show a toast in, and what it queued would reach every reader.  if (typeof document === 'undefined') return () => {}  const id = ++count  publish([...shown, { id, ...(typeof message === 'string' ? { description: message } : message) }])  return () => remove(id)}export interface ToasterProps extends ComponentProps<'div'> {  /** The name of the region, for assistive technology. */  label?: string  /** The name of the button that dismisses a toast. */  dismissLabel?: string}/** The stack that shows the toasts. Render it once, anywhere in the page. */export function Toaster({ label = 'Notifications', dismissLabel = 'Dismiss', className, ...root }: ToasterProps) {  const toasts = useSyncExternalStore(subscribe, () => shown, () => none)  return (    <div className={className ? `toast-region ${className}` : 'toast-region'} role="region" aria-label={label} aria-live="polite" {...root}>      {toasts.map(({ id, title, description, status, icon, duration = 6000 }) => (        <dialog          open          className={['toast', duration !== false && 'toast-timed', status && `toast-${status}`].filter(Boolean).join(' ')}          key={id}          aria-labelledby={`toast-${id}`}          style={duration === false ? undefined : { '--toast-duration': `${duration}ms` } as CSSProperties}          // A dismissed toast leaves through its transition before it is removed; an expired one has left already.          onClose={() => setTimeout(() => remove(id), 300)}          onAnimationEnd={event => event.animationName === 'toast-expire' && event.target === event.currentTarget && remove(id)}        >          {icon}          <div className="toast-body">            {title !== undefined && <p className="toast-title" id={`toast-${id}`}>{title}</p>}            {description !== undefined && <p className="toast-description" id={title === undefined ? `toast-${id}` : undefined}>{description}</p>}          </div>          <form method="dialog" className="toast-close">            <button className="btn btn-quiet btn-square btn-sm" aria-label={dismissLabel}>              <svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>            </button>          </form>        </dialog>      ))}    </div>  )}
ui/Toaster.svelte
<script lang="ts" module>  import type { Snippet } from 'svelte'  import type { HTMLAttributes } from 'svelte/elements'  export interface ToastMessage {    title?: string    description?: string    /** Colors the icon; give the icon itself in `icon`, with the classes `icon toast-icon`. */    status?: 'info' | 'success' | 'warning' | 'danger'    icon?: Snippet    /** How long the toast stays, in milliseconds. 6000 by default; `false` keeps it until it is dismissed. */    duration?: number | false  }  export interface ToasterProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {    /** The name of the region, for assistive technology. */    label?: string    /** The name of the button that dismisses a toast. */    dismissLabel?: string  }  interface Shown extends ToastMessage { id: number }  let shown = $state.raw<Shown[]>([])  let count = 0  const remove = (id: number) => { shown = shown.filter(toast => toast.id !== id) }  /**   * Show a toast in the `Toaster` of the page. Returns a function that removes it.   * @example toast({ title: 'Deployed to production', status: 'success' })   */  export function toast(message: ToastMessage | string) {    // A server has no page to show a toast in, and what it queued would reach every reader.    if (typeof document === 'undefined') return () => {}    const id = ++count    shown = [...shown, { id, ...(typeof message === 'string' ? { description: message } : message) }]    return () => remove(id)  }</script><script lang="ts">  let { label = 'Notifications', dismissLabel = 'Dismiss', class: className, ...root }: ToasterProps = $props()</script><!--  @component  The stack that shows the toasts. Render it once, anywhere in the page.--><div class={['toast-region', className]} role="region" aria-label={label} aria-live="polite" {...root}>  {#each shown as { id, title, description, status, icon, duration = 6000 } (id)}    <!-- A dismissed toast leaves through its transition before it is removed; an expired one has left already. -->    <dialog      open      class={['toast', duration !== false && 'toast-timed', status && `toast-${status}`]}      aria-labelledby="toast-{id}"      style:--toast-duration={duration === false ? undefined : `${duration}ms`}      onclose={() => setTimeout(() => remove(id), 300)}      onanimationend={(event) => { if (event.animationName === 'toast-expire' && event.target === event.currentTarget) remove(id) }}    >      {@render icon?.()}      <div class="toast-body">        {#if title !== undefined}<p class="toast-title" id="toast-{id}">{title}</p>{/if}        {#if description !== undefined}<p class="toast-description" id={title === undefined ? `toast-${id}` : undefined}>{description}</p>{/if}      </div>      <form method="dialog" class="toast-close">        <button class="btn btn-quiet btn-square btn-sm" aria-label={dismissLabel}>          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>        </button>      </form>    </dialog>  {/each}</div>
ui/Toaster.vue
<script lang="ts">import { shallowRef } from 'vue'export interface ToastMessage {  title?: string  description?: string  /** Colors the icon; give the icon itself in the `icon` slot of the `Toaster`, with the classes `icon toast-icon`. */  status?: 'info' | 'success' | 'warning' | 'danger'  /** How long the toast stays, in milliseconds. 6000 by default; `false` keeps it until it is dismissed. */  duration?: number | false}export interface ShownToast extends ToastMessage { id: number }const shown = shallowRef<ShownToast[]>([])let count = 0const remove = (id: number) => { shown.value = shown.value.filter(toast => toast.id !== id) }/** * Show a toast in the `Toaster` of the page. Returns a function that removes it. * @example toast({ title: 'Deployed to production', status: 'success' }) */export function toast(message: ToastMessage | string) {  // A server has no page to show a toast in, and what it queued would reach every reader.  if (typeof document === 'undefined') return () => {}  const id = ++count  shown.value = [...shown.value, { id, ...(typeof message === 'string' ? { description: message } : message) }]  return () => remove(id)}</script><script setup lang="ts">import { onMounted } from 'vue'defineOptions({ inheritAttrs: false })const { label = 'Notifications', dismissLabel = 'Dismiss' } = defineProps<{  /** The name of the region, for assistive technology. */  label?: string  /** The name of the button that dismisses a toast. */  dismissLabel?: string}>()defineSlots<{  /** The icon of a toast, with the classes `icon toast-icon`. */  icon?: (scope: { toast: ShownToast }) => unknown}>()/* The stack that shows the toasts. Render it once, anywhere in the page. */// A server renders no toast, and neither does the first render over its HTML.const mounted = shallowRef(false)onMounted(() => { mounted.value = true })// A dismissed toast leaves through its transition before it is removed; an expired one has left already.const dismissed = (id: number) => setTimeout(() => remove(id), 300)const expired = (event: AnimationEvent, id: number) => {  if (event.animationName === 'toast-expire' && event.target === event.currentTarget) remove(id)}</script><template>  <div class="toast-region" role="region" :aria-label="label" aria-live="polite" v-bind="$attrs">    <dialog      v-for="each in mounted ? shown : []"      :key="each.id"      open      class="toast"      :class="{        'toast-timed': each.duration !== false,        'toast-info': each.status === 'info',        'toast-success': each.status === 'success',        'toast-warning': each.status === 'warning',        'toast-danger': each.status === 'danger',      }"      :aria-labelledby="`toast-${each.id}`"      :style="each.duration === false ? undefined : { '--toast-duration': `${each.duration ?? 6000}ms` }"      @close="dismissed(each.id)"      @animationend="expired($event, each.id)"    >      <slot name="icon" :toast="each" />      <div class="toast-body">        <p v-if="each.title !== undefined" class="toast-title" :id="`toast-${each.id}`">{{ each.title }}</p>        <p v-if="each.description !== undefined" class="toast-description" :id="each.title === undefined ? `toast-${each.id}` : undefined">{{ each.description }}</p>      </div>      <form method="dialog" class="toast-close">        <button class="btn btn-quiet btn-square btn-sm" :aria-label="dismissLabel">          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>        </button>      </form>    </dialog>  </div></template>

#Classes

ClassDescription
toast-regionThe fixed stack at the bottom, at the end edge, with the newest toast last. Make it a live region with aria-live="polite" and name it with aria-label. It takes no pointer events itself, and spans the width on a narrow screen
toast-region-topThe stack at the top; toasts arrive downward and the newest is on top. With toast-region
toast-region-centerThe stack centered instead of at the end edge; it combines with toast-region-top. With toast-region
toastA message, on a dialog element inside the region, named with aria-labelledby pointing at its title or with aria-label
toast-timedLeaves by itself after --toast-duration. The clock pauses while the toast is hovered or holds focus, and a hairline along its foot runs out with the clock. With toast
toast-infoColors the icon with the info hue; the words stay in ink. With toast
toast-successColors the icon with the success hue; the words stay in ink. With toast
toast-warningColors the icon with the warning hue; the words stay in ink. With toast
toast-dangerColors the icon with the danger hue; the words stay in ink. With toast
toast-iconBeside icon on the status icon: centers it on the first line of text
toast-bodyHolds the words and takes the remaining width; a button after it sits at the end of the row
toast-titleThe few strong words that say what happened
toast-descriptionA quiet sentence under the title, or the whole message of a toast without a title
toast-closeThe form with method="dialog", or the button, that dismisses the toast, pulled into the padding at the end

#Custom properties

PropertyDefaultDescription
--toast-durationHow long a toast-timed stays; 6s when not set. On toast
--toast-inset1remDistance of the region from the viewport edge. On toast-region
--toast-gap.5remSpace between toasts. On toast-region

#Source

@master/ui/components/toast.css
/* * Toast: a brief message over the page about something that just happened. * Each toast is a non-modal <dialog>, so it can be dismissed without script by a form with method="dialog" or * a button with command="close", and it enters and leaves through @starting-style and discrete transitions. * A timed toast leaves by itself and waits while it is hovered or focused. Toasts stack in a fixed region. */@layer components {  /*   * The stack. It takes no pointer events itself, so the page under its empty space stays usable.   * With popover="manual" and showPopover() it joins the top layer, above any open modal dialog.   */  .toast-region {    --toast-gap: .5rem;    --toast-inset: 1rem;    position: fixed;    inset: auto;    inset-block-end: var(--toast-inset);    inset-inline-end: var(--toast-inset);    z-index: 100;    display: flex;    flex-direction: column;    gap: var(--toast-gap);    width: min(22.5rem, calc(100vw - var(--toast-inset) * 2));    margin: 0;    padding: 0;    border: 0;    overflow: visible;    background: none;    pointer-events: none;    &.toast-region-top {      inset: auto;      inset-block-start: var(--toast-inset);      inset-inline-end: var(--toast-inset);      flex-direction: column-reverse;    }    &.toast-region-center {      inset-inline: 0;      margin-inline: auto;    }    /* On a narrow screen the stack spans the width, clear of the home indicator. */    @media (width < 30rem) {      inset-inline: var(--toast-inset);      width: auto;      padding-bottom: env(safe-area-inset-bottom);    }  }  .toast {    --toast-shift: .75rem;    --icon-size: 1rem;    --surface-color: var(--color-surface-floating);    position: relative;    inset: auto;    flex: none;    align-items: flex-start;    gap: .625rem .75rem;    width: auto;    max-width: none;    margin: 0;    padding: .75rem .875rem;    border: 0;    border-radius: calc(var(--radius-control-lg) * var(--corner-scale));    corner-shape: var(--corner-shape);    background: var(--color-surface-floating);    color: var(--color-text-body);    box-shadow: var(--shadow-floating);    font-size: .84375rem;    line-height: 1.5;    pointer-events: auto;    /* Closed is where it leaves to: it fades and drops back a little. */    opacity: 0;    scale: .97;    translate: 0 var(--toast-shift);    transition-property: opacity, scale, translate, display;    transition-duration: var(--duration-quick);    transition-timing-function: var(--easing-glide);    transition-behavior: allow-discrete;    &[open] {      display: flex;      opacity: 1;      scale: 1;      translate: 0 0;      transition-duration: var(--duration-base);      @starting-style {        opacity: 0;        scale: .97;        translate: 0 var(--toast-shift);      }    }    /*     * Timed: after --toast-duration the toast leaves by itself. The clock stops while the pointer or the     * keyboard is on it, so a reader is never raced.     */    &.toast-timed[open] {      animation: toast-expire var(--toast-duration, 6s) linear forwards;      /* A hairline along the foot runs out with the clock, and stops when the clock does. */      &::after {        content: '';        position: absolute;        inset-inline: .875rem;        bottom: 0;        height: 1.5px;        border-radius: 999px;        background: var(--color-line-mark);        transform-origin: 0 50%;        animation: toast-clock var(--toast-duration, 6s) linear forwards;      }      &:dir(rtl)::after {        transform-origin: 100% 50%;      }      &:is(:hover, :focus-within), &:is(:hover, :focus-within)::after {        animation-play-state: paused;      }    }    /* A status colors the icon; the words stay in ink. */    &.toast-info { --icon-color: var(--color-info); }    &.toast-success { --icon-color: var(--color-success); }    &.toast-warning { --icon-color: var(--color-warning); }    &.toast-danger { --icon-color: var(--color-danger); }    @media (prefers-reduced-motion: reduce) {      scale: none;      translate: none;      &.toast-timed[open]::after {        display: none;      }      &[open] {        scale: none;        translate: none;        @starting-style {          scale: none;          translate: none;        }      }    }    @media (forced-colors: active) {      outline: 1px solid CanvasText;      outline-offset: -1px;    }  }  /* From the top the toast arrives downward. */  .toast-region-top .toast {    --toast-shift: -.75rem;  }  /* The icon centers on the first line of text. */  .toast-icon {    margin-block: calc((1lh - var(--icon-size)) / 2);  }  .toast-body {    display: grid;    flex: 1 1 0;    gap: .125rem;    min-width: 0;  }  .toast-title {    margin: 0;    color: var(--color-text-strong);    font-weight: var(--font-weight-control);    text-wrap: balance;    overflow-wrap: anywhere;  }  .toast-description {    margin: 0;    color: var(--color-text-muted);    text-wrap: pretty;    overflow-wrap: anywhere;  }  /* The form or button that dismisses the toast sits at the end, pulled into the padding. */  .toast-close {    flex: none;    margin-block: -.25rem;    margin-inline: 0 -.375rem;  }}@keyframes toast-clock {  to {    scale: 0 1;  }}/* The toast rests for most of its time, then leaves the way a closed one does and stops taking space. */@keyframes toast-expire {  0%, 96% {    opacity: 1;    translate: 0 0;    visibility: visible;  }  100% {    display: none;    opacity: 0;    translate: 0 var(--toast-shift);    visibility: hidden;  }}