Run the CLI
Use the CLI to add sonner to your project.
An opinionated toast component for Angular.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { ZardSonnerService } from '@/shared/components/sonner/sonner.service';
@Component({
selector: 'z-demo-sonner-default',
imports: [ZardButtonComponent],
template: `
<button type="button" z-button zType="outline" (click)="show()">Show Toast</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSonnerDefaultComponent {
private readonly sonner = inject(ZardSonnerService);
show() {
this.sonner.show('Event has been created', {
description: 'Sunday, December 03, 2023 at 9:00 AM',
action: {
label: 'Undo',
onClick: () => console.log('Undo'),
},
});
}
}
Use the CLI to add sonner to your project.
npx zard-cli@latest add sonnerpnpm dlx zard-cli@latest add sonneryarn dlx zard-cli@latest add sonnerbunx zard-cli@latest add sonner
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ZardSonnerComponent } from '@/shared/components/sonner/sonner.component';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ZardSonnerComponent],
template: `
<router-outlet></router-outlet>
<z-sonner />
`,
})
export class AppComponent {}Install the required dependencies for this component.
npm install ngx-sonnerpnpm add ngx-sonneryarn add ngx-sonnerbun add ngx-sonnerCreate the following file in your project.
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
input,
NgZone,
ViewEncapsulation,
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideCircleCheck, lucideInfo, lucideLoader2, lucideOctagonX, lucideTriangleAlert } from '@ng-icons/lucide';
import type { ClassValue } from 'clsx';
import { NgxSonnerToaster, toastState, type ToasterProps } from 'ngx-sonner';
import { mergeClasses } from '@/shared/utils/merge-classes';
import { sonnerVariants } from './sonner.variants';
export type ZardSonnerPosition =
| 'top-left'
| 'top-center'
| 'top-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
const DEFAULT_STYLE: Record<string, string> = {
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
};
const DEFAULT_TOAST_OPTIONS: ToasterProps['toastOptions'] = {
classes: {
toast: 'cn-toast',
},
};
@Component({
selector: 'z-sonner',
imports: [NgIcon, NgxSonnerToaster],
template: `
<ngx-sonner-toaster
[theme]="theme()"
[class]="classes()"
[style]="resolvedStyle()"
[position]="position()"
[richColors]="richColors()"
[expand]="expand()"
[duration]="duration()"
[visibleToasts]="visibleToasts()"
[closeButton]="closeButton()"
[toastOptions]="resolvedToastOptions()"
[dir]="dir()"
>
<ng-icon loading-icon name="lucideLoader2" class="size-4 [&>svg]:animate-spin" />
<ng-icon success-icon name="lucideCircleCheck" class="size-4" />
<ng-icon error-icon name="lucideOctagonX" class="size-4" />
<ng-icon warning-icon name="lucideTriangleAlert" class="size-4" />
<ng-icon info-icon name="lucideInfo" class="size-4" />
</ngx-sonner-toaster>
`,
styles: `
/*
* Neutralize the UA styles of a popover host: the toaster inside is already
* fixed positioned, so the host must be a zero-sized, invisible anchor that
* never paints or captures pointer events.
*/
z-sonner[popover] {
position: fixed;
inset: auto;
display: block;
width: 0;
height: 0;
max-width: none;
max-height: none;
margin: 0;
border: 0;
padding: 0;
overflow: visible;
background: transparent;
color: inherit;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
viewProviders: [provideIcons({ lucideCircleCheck, lucideInfo, lucideLoader2, lucideOctagonX, lucideTriangleAlert })],
host: {
'data-slot': 'sonner',
},
exportAs: 'zSonner',
})
export class ZardSonnerComponent {
readonly class = input<ClassValue>('');
readonly theme = input<'light' | 'dark' | 'system'>('system');
readonly position = input<ZardSonnerPosition>('top-center');
readonly richColors = input<boolean>(false);
readonly expand = input<boolean>(false);
readonly duration = input<number>(4000);
readonly visibleToasts = input<number>(3);
readonly closeButton = input<boolean>(false);
readonly toastOptions = input<ToasterProps['toastOptions']>();
readonly style = input<Record<string, string>>();
readonly dir = input<'ltr' | 'rtl' | 'auto'>('auto');
/**
* Renders the toaster in the native top layer so toasts stay above CDK
* overlays (dialog, drawer, sheet, ...), which also live in the top layer
* and would otherwise cover them regardless of `z-index`.
*/
readonly topLayer = input(true, { transform: booleanAttribute });
protected readonly classes = computed(() => mergeClasses(sonnerVariants(), this.class()));
protected readonly resolvedStyle = computed(() => ({ ...DEFAULT_STYLE, ...(this.style() ?? {}) }));
protected readonly resolvedToastOptions = computed<ToasterProps['toastOptions']>(() => {
const provided = this.toastOptions();
if (!provided) {
return DEFAULT_TOAST_OPTIONS;
}
return {
...DEFAULT_TOAST_OPTIONS,
...provided,
classes: { ...DEFAULT_TOAST_OPTIONS.classes, ...(provided.classes ?? {}) },
};
});
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
private readonly zone = inject(NgZone);
private readonly supportsTopLayer = typeof this.host.showPopover === 'function';
constructor() {
effect(() => {
// The top layer stacks by promotion order, so the toaster has to be
// re-promoted every time the toast list changes to stay on top of
// overlays opened after it.
toastState.toasts();
if (this.topLayer() && this.supportsTopLayer) {
this.promote();
} else {
this.demote();
}
});
if (this.supportsTopLayer) {
this.zone.runOutsideAngular(() => {
// Any other element entering the top layer (a dialog opened while a
// toast is visible) would be painted above the toaster.
this.host.ownerDocument.addEventListener('toggle', this.onDocumentToggle, true);
});
inject(DestroyRef).onDestroy(() =>
this.host.ownerDocument.removeEventListener('toggle', this.onDocumentToggle, true),
);
}
}
private readonly onDocumentToggle = (event: Event) => {
const isOpening = (event as Event & { newState?: string }).newState === 'open';
if (isOpening && event.target !== this.host && this.topLayer() && toastState.toasts().length > 0) {
this.promote();
}
};
private promote(): void {
this.host.setAttribute('popover', 'manual');
// `showPopover()` on an already open popover is a no-op, so it has to be
// closed first to be moved back to the front of the top layer.
try {
this.host.hidePopover();
} catch {
// Not open yet.
}
try {
this.host.showPopover();
} catch {
// Not connected to the document yet: drop the attribute so the toaster
// keeps rendering in the normal flow instead of being hidden by the
// closed-popover UA styles. The next toast promotes it again.
this.host.removeAttribute('popover');
}
}
private demote(): void {
if (!this.host.hasAttribute('popover')) {
return;
}
try {
this.host.hidePopover();
} catch {
// Already hidden or disconnected.
}
this.host.removeAttribute('popover');
}
}
Create the following file in your project.
import { cva } from 'class-variance-authority';
export const sonnerVariants = cva('toaster group');
Create the following file in your project.
import { Injectable, type Type } from '@angular/core';
import { toast, type ExternalToast, type PromiseData, type PromiseT } from 'ngx-sonner';
/**
* Type-safe Angular wrapper around the underlying `ngx-sonner` toast API.
*
* Use this service from any component / service to dispatch toasts
* without coupling the consumer code to the third-party library.
*
* @example
* private readonly sonner = inject(ZardSonnerService);
*
* this.sonner.success('Saved!');
* this.sonner.error('Failed', { description: 'Try again later.' });
* this.sonner.promise(savePromise, {
* loading: 'Saving...',
* success: 'Saved!',
* error: 'Failed.',
* });
*/
@Injectable({ providedIn: 'root' })
export class ZardSonnerService {
/** Dispatch a default toast. Returns the toast id. */
show(message: string | Type<unknown>, options?: ExternalToast): string | number {
return toast(message, options);
}
/** Dispatch a success-styled toast (green). */
success(message: string | Type<unknown>, options?: ExternalToast): string | number {
return toast.success(message, options);
}
/** Dispatch an error-styled toast (red). */
error(message: string | Type<unknown>, options?: ExternalToast): string | number {
return toast.error(message, options);
}
/** Dispatch a warning-styled toast (yellow). */
warning(message: string | Type<unknown>, options?: ExternalToast): string | number {
return toast.warning(message, options);
}
/** Dispatch an info-styled toast (blue). */
info(message: string | Type<unknown>, options?: ExternalToast): string | number {
return toast.info(message, options);
}
/** Dispatch a loading-styled toast (with spinner). */
loading(message: string | Type<unknown>, options?: ExternalToast): string | number {
return toast.loading(message, options);
}
/** Dispatch a neutral message-styled toast. */
message(message: string | Type<unknown>, options?: ExternalToast): string | number {
return toast.message(message, options);
}
/**
* Bind a toast lifecycle to a promise. The toast updates from `loading`
* to `success` / `error` based on the promise resolution.
*/
promise<T>(promise: PromiseT<T>, options?: PromiseData<T>): string | number | undefined {
return toast.promise(promise, options);
}
/** Dismiss a toast by id, or all toasts when no id is provided. */
dismiss(id?: string | number): void {
toast.dismiss(id);
}
/** Dispatch a toast that renders a custom Angular component. */
custom<T>(component: Type<T>, options?: ExternalToast): string | number {
return toast.custom(component, options);
}
}
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ZardSonnerComponent } from '@/shared/components/sonner/sonner.component';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ZardSonnerComponent],
template: `
<router-outlet></router-outlet>
<z-sonner />
`,
})
export class AppComponent {}import { ZardSonnerComponent } from '@/shared/components/sonner/sonner.component';<z-sonner />import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { ZardSonnerService } from '@/shared/components/sonner/sonner.service';
@Component({
selector: 'z-demo-sonner-types',
imports: [ZardButtonComponent],
template: `
<div class="flex flex-wrap gap-2">
<button type="button" z-button zType="outline" (click)="sonner.show('Event has been created')">Default</button>
<button type="button" z-button zType="outline" (click)="sonner.success('Event has been created')">Success</button>
<button
type="button"
z-button
zType="outline"
(click)="sonner.info('Be at the area 10 minutes before the event time')"
>
Info
</button>
<button
type="button"
z-button
zType="outline"
(click)="sonner.warning('Event start time cannot be earlier than 8am')"
>
Warning
</button>
<button type="button" z-button zType="outline" (click)="sonner.error('Event has not been created')">Error</button>
<button type="button" z-button zType="outline" (click)="showPromise()">Promise</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSonnerTypesComponent {
protected readonly sonner = inject(ZardSonnerService);
showPromise() {
this.sonner.promise<{ name: string }>(
() => new Promise(resolve => setTimeout(() => resolve({ name: 'Event' }), 2000)),
{
loading: 'Loading...',
success: data => `${data.name} has been created`,
error: 'Error',
},
);
}
}
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { ZardSonnerService } from '@/shared/components/sonner/sonner.service';
@Component({
selector: 'z-demo-sonner-description',
imports: [ZardButtonComponent],
template: `
<button type="button" z-button zType="outline" class="w-fit" (click)="show()">Show Toast</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSonnerDescriptionComponent {
private readonly sonner = inject(ZardSonnerService);
show() {
this.sonner.show('Event has been created', {
description: 'Monday, January 3rd at 6:00pm',
});
}
}
position option to change the position of the toast.import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { type ZardSonnerPosition } from '@/shared/components/sonner/sonner.component';
import { ZardSonnerService } from '@/shared/components/sonner/sonner.service';
@Component({
selector: 'z-demo-sonner-position',
imports: [ZardButtonComponent],
template: `
<div class="flex flex-wrap justify-center gap-2">
<button type="button" z-button zType="outline" (click)="show('top-left')">Top Left</button>
<button type="button" z-button zType="outline" (click)="show('top-center')">Top Center</button>
<button type="button" z-button zType="outline" (click)="show('top-right')">Top Right</button>
<button type="button" z-button zType="outline" (click)="show('bottom-left')">Bottom Left</button>
<button type="button" z-button zType="outline" (click)="show('bottom-center')">Bottom Center</button>
<button type="button" z-button zType="outline" (click)="show('bottom-right')">Bottom Right</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSonnerPositionComponent {
private readonly sonner = inject(ZardSonnerService);
show(position: ZardSonnerPosition) {
this.sonner.show('Event has been created', { position });
}
}
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ZardDialogImports } from '@/shared/components/dialog/dialog.imports';
import { ZardDialogService } from '@/shared/components/dialog/dialog.service';
import { ZardSonnerService } from '@/shared/components/sonner/sonner.service';
@Component({
selector: 'z-demo-sonner-with-dialog',
imports: [ZardDialogImports],
template: `
<button type="button" z-button zType="outline" (click)="openDialog()">Open dialog</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSonnerWithDialogComponent {
private readonly dialogService = inject(ZardDialogService);
private readonly sonner = inject(ZardSonnerService);
openDialog() {
this.dialogService.create({
zTitle: 'Save changes',
zDescription: 'The toast stays above the dialog and its backdrop.',
zContent: 'Confirm to dispatch a toast without closing the dialog.',
zOkText: 'Save',
zMaskClosable: false,
zOnOk: () => {
this.sonner.success('Changes saved');
return false;
},
});
}
}