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
<divclass="toast-region"role="region"aria-label="Notifications"aria-live="polite"><dialogopenclass="toast toast-success"aria-labelledby="toast-deployed"><svgclass="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"><circlecx="12"cy="12"r="9"/><pathd="m8.5 12.5 2.5 2.5 4.5-5.5"/></svg><divclass="toast-body"><pclass="toast-title"id="toast-deployed">Deployed to production</p><pclass="toast-description">atlas.dev is live in 14 regions.</p></div><formmethod="dialog"class="toast-close"><buttonclass="btn btn-quiet btn-square btn-sm"aria-label="Dismiss"><svgclass="icon"viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="1.5"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true"><pathd="M6 6l12 12M18 6 6 18"/></svg></button></form></dialog></div>
Dismiss the toast with its button. No script is involved.
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.
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
<divclass="toast-region"role="region"aria-label="Notifications"aria-live="polite"><dialogopenclass="toast toast-timed"aria-label="Link copied"><divclass="toast-body"><pclass="toast-description">Link copied to the clipboard.</p></div></dialog></div>
This toast leaves after six seconds. Reload the page to see it again.
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.
Class
Placement
toast-region
Bottom, at the end edge
toast-region-top
Top; toasts arrive downward and the newest is on top
toast-region-center
Centered 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.
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'exportinterfaceToastMessage{title?:ReactNodedescription?: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}interfaceShownextendsToastMessage{id:number}let shown:Shown[]=[]let count =0const listeners =newSet<()=>void>()const none:Shown[]=[]functionpublish(next:Shown[]){ shown = next listeners.forEach(listener =>listener())}functionsubscribe(listener:()=>void){ listeners.add(listener)return()=>{ listeners.delete(listener)}}constremove=(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' }) */exportfunctiontoast(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 =++countpublish([...shown,{ id,...(typeof message ==='string'?{description: message }: message)}])return()=>remove(id)}exportinterfaceToasterPropsextendsComponentProps<'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. */exportfunctionToaster({ label ='Notifications', dismissLabel ='Dismiss', className,...root }:ToasterProps){const toasts =useSyncExternalStore(subscribe,()=> shown,()=> none)return(<divclassName={className ?`toast-region ${className}`:'toast-region'}role="region"aria-label={label}aria-live="polite"{...root}>{toasts.map(({ id, title, description, status, icon, duration =6000})=>(<dialogopenclassName={['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`}asCSSProperties}// 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}<divclassName="toast-body">{title !==undefined&&<pclassName="toast-title"id={`toast-${id}`}>{title}</p>}{description !==undefined&&<pclassName="toast-description"id={title ===undefined?`toast-${id}`:undefined}>{description}</p>}</div><formmethod="dialog"className="toast-close"><buttonclassName="btn btn-quiet btn-square btn-sm"aria-label={dismissLabel}><svgclassName="icon"viewBox="0 0 24 24"fill="none"stroke="currentColor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"aria-hidden="true"><pathd="M6 6l12 12M18 6 6 18"/></svg></button></form></dialog>))}</div>)}
ui/Toaster.svelte
<scriptlang="ts"module>importtype{ Snippet }from'svelte'importtype{ HTMLAttributes }from'svelte/elements'exportinterfaceToastMessage{title?:stringdescription?: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}exportinterfaceToasterPropsextendsOmit<HTMLAttributes<HTMLDivElement>,'children'>{/** The name of the region, for assistive technology. */label?:string/** The name of the button that dismisses a toast. */dismissLabel?:string}interfaceShownextendsToastMessage{id:number}let shown =$state.raw<Shown[]>([])let count =0constremove=(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' }) */exportfunctiontoast(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><scriptlang="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.--><divclass={['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. --><dialogopenclass={['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)}}>{@rendericon?.()}<divclass="toast-body">{#if title !==undefined}<pclass="toast-title"id="toast-{id}">{title}</p>{/if}{#if description !==undefined}<pclass="toast-description"id={title ===undefined?`toast-${id}`:undefined}>{description}</p>{/if}</div><formmethod="dialog"class="toast-close"><buttonclass="btn btn-quiet btn-square btn-sm"aria-label={dismissLabel}><svgclass="icon"viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="1.5"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true"><pathd="M6 6l12 12M18 6 6 18"/></svg></button></form></dialog>{/each}</div>
ui/Toaster.vue
<scriptlang="ts">import{ shallowRef }from'vue'exportinterfaceToastMessage{title?:stringdescription?: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}exportinterfaceShownToastextendsToastMessage{id:number}const shown =shallowRef<ShownToast[]>([])let count =0constremove=(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' }) */exportfunctiontoast(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><scriptsetuplang="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.constdismissed=(id:number)=>setTimeout(()=>remove(id),300)constexpired=(event:AnimationEvent, id:number)=>{if(event.animationName==='toast-expire'&& event.target=== event.currentTarget)remove(id)}</script><template><divclass="toast-region"role="region":aria-label="label"aria-live="polite"v-bind="$attrs"><dialogv-for="each in mounted ? shown :[]":key="each.id"openclass="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)"><slotname="icon":toast="each" /><divclass="toast-body"><pv-if="each.title!==undefined"class="toast-title":id="`toast-${each.id}`">{{ each.title}}</p><pv-if="each.description!==undefined"class="toast-description":id="each.title===undefined?`toast-${each.id}`:undefined">{{ each.description}}</p></div><formmethod="dialog"class="toast-close"><buttonclass="btn btn-quiet btn-square btn-sm":aria-label="dismissLabel"><svgclass="icon"viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="1.5"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true"><pathd="M6 6l12 12M18 6 6 18"/></svg></button></form></dialog></div></template>
The 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-top
The stack at the top; toasts arrive downward and the newest is on top. With toast-region
toast-region-center
The stack centered instead of at the end edge; it combines with toast-region-top. With toast-region
toast
A message, on a dialog element inside the region, named with aria-labelledby pointing at its title or with aria-label
toast-timed
Leaves 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-info
Colors the icon with the info hue; the words stay in ink. With toast
toast-success
Colors the icon with the success hue; the words stay in ink. With toast
toast-warning
Colors the icon with the warning hue; the words stay in ink. With toast
toast-danger
Colors the icon with the danger hue; the words stay in ink. With toast
toast-icon
Beside icon on the status icon: centers it on the first line of text
toast-body
Holds the words and takes the remaining width; a button after it sits at the end of the row
toast-title
The few strong words that say what happened
toast-description
A quiet sentence under the title, or the whole message of a toast without a title
toast-close
The form with method="dialog", or the button, that dismisses the toast, pulled into the padding at the end
/* * 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:0var(--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:00;transition-duration:var(--duration-base);@starting-style{opacity:0;scale:.97;translate:0var(--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)linearforwards;/* 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:050%;animation: toast-clock var(--toast-duration,6s)linearforwards;}&: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:1pxsolid 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:110;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:01;}}/* 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:00;visibility:visible;}100%{display:none;opacity:0;translate:0var(--toast-shift);visibility:hidden;}}