Toast
A brief message about an action, shown in a corner without interrupting, such as Saved or Message sent.
- v1.0.0
- WAI-ARIA: live region
- SSR
- Zoneless
- 3.6 kB gzip
- +6.9 kB Angular CDK, shared
import { Component, inject } from '@angular/core';
import { Button } from '@/ui/button/button';
import { Toaster } from '@/ui/toast/toast';
/**
* A toast with a description and an Undo action. Toasts show in the
* toast region your app's shell renders once (see the Toast page).
*/
@Component({
selector: 'example-toast-demo',
imports: [Button],
template: `
<button ndButton variant="outline" (click)="schedule()">
Add to calendar
</button>
`,
})
export class ToastDemoExample {
private readonly toaster = inject(Toaster);
/** Shows the confirmation, with an Undo that says so. */
protected schedule(): void {
this.toaster.show({
title: 'Event has been created',
description: 'Sunday, December 3 at 9:00 AM',
action: {
label: 'Undo',
onClick: () => this.toaster.show({ title: 'Event removed' }),
},
});
}
}
A toast is a brief message about something that just happened, such as
"Saved" or "Message sent", in a corner of the screen. Toasts stack, newest
in front, and spread out while hovered or focused; they're announced
politely by screen readers, without moving focus, and go away after four
seconds unless the pointer or focus is on them. F8 jumps to them. Show
one with inject(Toaster).show({ title: 'Saved' }), and put the
region in your app's shell once, with toaster = inject(Toaster):
<ng-template ndToaster>
<section ndToastRegion>
<ol ndToastList>
@for (toast of toaster.toasts(); track toast.id) {
<li [ndToast]="toast">
<div ndToastContent>
<p ndToastTitle>{{ toast.title }}</p>
@if (toast.description) {
<p ndToastDescription>{{ toast.description }}</p>
}
</div>
@if (toast.action; as action) {
<button ndToastAction>{{ action.label }}</button>
}
<button ndToastClose aria-label="Close"><svg ndIcon="x"></svg></button>
</li>
}
</ol>
</section>
</ng-template>Installation
npx @ndop/cli add toastpnpm dlx @ndop/cli add toastyarn dlx @ndop/cli add toastbunx @ndop/cli add toastInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/toast/toast.tsimport { computed, Directive, inject, input } from '@angular/core'; import { NdToast, NdToastAction, NdToastClose, NdToastDescription, NdToastRegion, NdToastTitle, NdToasterOutlet, } from '@ndop/primitives/toast'; import { cn } from '@/lib/utils'; export { NdToaster as Toaster, type NdToastActionOptions as ToastActionOptions, type NdToastOptions as ToastOptions, type NdToastRecord as ToastRecord, type NdToastType as ToastType, } from '@ndop/primitives/toast'; /** * Renders the toast region in its template above everything else, once the * app has hydrated. Put one in the app's shell. * * @example * ```html * <ng-template ndToaster><section ndToastRegion>…</section></ng-template> * ``` */ @Directive({ selector: 'ng-template[ndToaster]', hostDirectives: [NdToasterOutlet], }) export class ToasterOutlet {} /** How many toasts show in the collapsed stack. */ const VISIBLE = 3; /** * The toast region (`@ndop/primitives/toast`), in the corner at the end * side, bottom: a polite live region and landmark that F8 focuses. Put it * in an `ng-template[ndToaster]` once, in the app's shell, with an `ol` of * the toasts in `NdToaster.toasts()`. Hovering it or focusing inside it * pauses the toasts and spreads the stack out. * * @example * ```html * <ng-template ndToaster> * <section ndToastRegion> * <ol ndToastList> * @for (toast of toaster.toasts(); track toast.id) { * <li [ndToast]="toast">…</li> * } * </ol> * </section> * </ng-template> * ``` */ @Directive({ selector: '[ndToastRegion]', hostDirectives: [NdToastRegion], host: { 'data-slot': 'toaster', '[class]': 'classes()' }, }) export class ToastRegion { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'toaster group fixed end-4 bottom-4 z-[1100] w-[calc(100%-2rem)] rounded-(--radius) outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 sm:w-[356px]', this.class(), ), ); } /** * The list of toasts: stacked on top of each other, newest in front, and * spread out while its region is hovered or focused. * * @example * ```html * <ol ndToastList>…</ol> * ``` */ @Directive({ selector: 'ol[ndToastList]', host: { 'data-slot': 'toast-list', '[class]': 'classes()' }, }) export class ToastList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'grid [grid-template-areas:"stack"] items-end group-data-expanded:flex group-data-expanded:flex-col-reverse group-data-expanded:gap-2', this.class(), ), ); } /** * One toast, styled like Sonner's: in the stack, older toasts sit * slightly behind and above the newest, and only three show until the * stack spreads out. It follows a swipe toward the end side, which * dismisses it past 50 pixels. * * @example * ```html * <li [ndToast]="toast"> * <svg ndIcon="circle-check"></svg> * <div ndToastContent> * <p ndToastTitle>{{ toast.title }}</p> * </div> * </li> * ``` */ @Directive({ selector: '[ndToast]', hostDirectives: [{ directive: NdToast, inputs: ['ndToast'] }], host: { 'data-slot': 'toast', '[class]': 'classes()' }, }) export class Toast { /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly toast = inject(NdToast); protected readonly classes = computed(() => cn( 'relative flex w-full items-center gap-3 rounded-(--radius) border border-border bg-popover p-4 text-sm text-popover-foreground shadow-lg [grid-area:stack] [&>svg]:size-4 [&>svg]:shrink-0', 'z-[calc(100-var(--nd-toast-index))] origin-bottom transition-[translate,scale,opacity] duration-300 ease-out motion-reduce:transition-none data-swiping:transition-none', 'translate-x-(--nd-toast-swipe) rtl:-translate-x-(--nd-toast-swipe) translate-y-[calc(var(--nd-toast-index)*-14px)] scale-[calc(1-var(--nd-toast-index)*0.05)]', 'group-data-expanded:translate-y-0 group-data-expanded:scale-100', this.toast.index() >= VISIBLE && 'pointer-events-none opacity-0 group-data-expanded:pointer-events-auto group-data-expanded:opacity-100', this.class(), ), ); } /** * Stacks a toast's title and description. * * @example * ```html * <div ndToastContent>…</div> * ``` */ @Directive({ selector: '[ndToastContent]', host: { 'data-slot': 'toast-content', '[class]': 'classes()' }, }) export class ToastContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('grid flex-1 gap-0.5', this.class()), ); } /** * A toast's message. * * @example * ```html * <p ndToastTitle>{{ toast.title }}</p> * ``` */ @Directive({ selector: '[ndToastTitle]', hostDirectives: [NdToastTitle], host: { 'data-slot': 'toast-title', '[class]': 'classes()' }, }) export class ToastTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-medium', this.class())); } /** * A toast's detail, under its title. * * @example * ```html * <p ndToastDescription>{{ toast.description }}</p> * ``` */ @Directive({ selector: '[ndToastDescription]', hostDirectives: [NdToastDescription], host: { 'data-slot': 'toast-description', '[class]': 'classes()' }, }) export class ToastDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-muted-foreground', this.class()), ); } /** * The button of a toast's action, such as Undo: it runs the action, then * dismisses the toast. * * @example * ```html * <button ndToastAction>{{ action.label }}</button> * ``` */ @Directive({ selector: 'button[ndToastAction]', hostDirectives: [NdToastAction], host: { 'data-slot': 'toast-action', '[class]': 'classes()' }, }) export class ToastAction { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'ms-auto inline-flex h-6 shrink-0 items-center rounded-md bg-primary px-2 text-xs font-medium text-primary-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', this.class(), ), ); } /** * The button that dismisses its toast, at its top start corner; name it, * such as `aria-label="Close"`, and put an icon in it. * * @example * ```html * <button ndToastClose aria-label="Close"><svg ndIcon="x"></svg></button> * ``` */ @Directive({ selector: 'button[ndToastClose]', hostDirectives: [NdToastClose], host: { 'data-slot': 'toast-close', '[class]': 'classes()' }, }) export class ToastClose { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute -top-2 -start-2 flex size-5 items-center justify-center rounded-full border border-border bg-popover text-popover-foreground opacity-0 transition-opacity outline-none group-data-expanded:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-3', this.class(), ), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import { Button } from '@/ui/button/button';
import { Toaster } from '@/ui/toast/toast';<button ndButton variant="outline" (click)="schedule()">
Add to calendar
</button>Examples
Toast types
Success, info, warning, error and loading toasts, the loading one replaced when it finishes.
import { Component, inject } from '@angular/core';
import { Button } from '@/ui/button/button';
import { Toaster, type ToastType } from '@/ui/toast/toast';
/**
* Success, info, warning, error and loading toasts. The loading one is
* replaced, under the same id, when the work finishes.
*/
@Component({
selector: 'example-toast-types',
imports: [Button],
template: `
<div class="flex flex-wrap gap-2">
@for (type of types; track type.type) {
<button
ndButton
variant="outline"
(click)="show(type.type, type.title)"
>
{{ type.label }}
</button>
}
<button ndButton variant="outline" (click)="load()">Loading</button>
</div>
`,
})
export class ToastTypesExample {
private readonly toaster = inject(Toaster);
protected readonly types: readonly {
type: ToastType;
label: string;
title: string;
}[] = [
{ type: 'success', label: 'Success', title: 'Changes saved' },
{ type: 'info', label: 'Info', title: 'A new version is available' },
{ type: 'warning', label: 'Warning', title: 'Your storage is almost full' },
{ type: 'error', label: 'Error', title: "Couldn't upload the file" },
];
/**
* Shows a toast of a type.
*
* @param type - What it's about.
* @param title - Its message.
*/
protected show(type: ToastType, title: string): void {
this.toaster.show({ type, title });
}
/** Shows a loading toast, then replaces it once the upload is done. */
protected load(): void {
const id = this.toaster.show({
type: 'loading',
title: 'Uploading report.pdf…',
duration: Infinity,
});
setTimeout(
() =>
this.toaster.show({ id, type: 'success', title: 'Report uploaded' }),
2000,
);
}
}
Accessibility
Implements the live region pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| F8 | Focuses the toast region from anywhere on the page. |
| Tab | Moves through the toasts' buttons, then on to the page. |
| Escape | With focus in a toast, dismisses it. |
Focus
- Toasts never take focus. F8 moves focus to the region; while it's there, nothing times out.
- When a toast with focus inside is dismissed, focus moves to the region.
Right-to-left
Toasts swipe toward the end side, so to the left in right-to-left layouts. Direction comes from the nearest dir attribute.
API reference
ToasterOutlet
ng-template[ndToaster]Renders the toast region in its template above everything else, once the app has hydrated. Put one in the app's shell.
ToastRegion
[ndToastRegion]role regionThe toast region (@ndop/primitives/toast), in the corner at the end
side, bottom: a polite live region and landmark that F8 focuses. Put it
in an ng-template[ndToaster] once, in the app's shell, with an ol of
the toasts in NdToaster.toasts(). Hovering it or focusing inside it
pauses the toasts and spreads the stack out.
Manages aria-label, aria-live, aria-relevant.
| ToastRegion binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | toaster |
data-expanded | — |
ToastList
ol[ndToastList]The list of toasts: stacked on top of each other, newest in front, and spread out while its region is hovered or focused.
| ToastList binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | toast-list |
Toast
[ndToast]One toast, styled like Sonner's: in the stack, older toasts sit slightly behind and above the newest, and only three show until the stack spreads out. It follows a swipe toward the end side, which dismisses it past 50 pixels.
Manages aria-atomic.
| Toast binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[ndToast]requiredThe toast's record. | NdToastRecord | — |
| Data attribute | Values |
|---|---|
data-slot | toast |
data-type | — |
data-index | — |
data-front | — |
data-swiping | — |
ToastContent
[ndToastContent]Stacks a toast's title and description.
| ToastContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | toast-content |
ToastTitle
[ndToastTitle]A toast's message.
| ToastTitle binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | toast-title |
ToastDescription
[ndToastDescription]A toast's detail, under its title.
| ToastDescription binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | toast-description |
ToastAction
button[ndToastAction]The button of a toast's action, such as Undo: it runs the action, then dismisses the toast.
| ToastAction binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | toast-action |
ToastClose
button[ndToastClose]The button that dismisses its toast, at its top start corner; name it,
such as aria-label="Close", and put an icon in it.
| ToastClose binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | toast-close |
Agent notes
Do
- Keep it short, and say what happened: "Event created".
- Offer Undo as its action for things that can be undone.
- Use
duration: Infinityfor toasts people must act on.
Don't
- Don't use a toast as the only place an error is shown: it disappears.
- Don't show several at once for one action.