Sonner
Toaster shows short, temporary notifications. Mount it once near your app root, then call toast() from anywhere. For a message that stays on the page, use Alert.
'use client';
import { Button } from '@/components/nxds/components/button/button';
import { toast, Toaster } from '@/components/nxds/components/sonner/sonner';
export default function SonnerDefault() {
const showUndoToast = () =>
toast('File deleted', {
action: { label: 'Undo', onClick: () => toast('File restored') },
});
const showPromiseToast = () =>
toast.promise(
new Promise<void>((resolve) => {
setTimeout(resolve, 1500);
}),
{
loading: 'Saving…',
success: 'Settings saved',
error: 'Could not save',
}
);
return (
<>
<div className="nx:flex nx:flex-wrap nx:gap-2">
<Button
variant="outline"
onClick={() => toast('Event has been created')}
>
Default
</Button>
<Button
variant="outline"
onClick={() => toast.success('Changes saved')}
>
Success
</Button>
<Button
variant="outline"
onClick={() => toast.error('Something went wrong')}
>
Error
</Button>
<Button
variant="outline"
onClick={() => toast.warning('Your session is about to expire')}
>
Warning
</Button>
<Button
variant="outline"
onClick={() => toast.info('A new version is available')}
>
Info
</Button>
<Button variant="outline" onClick={showUndoToast}>
With action
</Button>
<Button variant="outline" onClick={showPromiseToast}>
Promise
</Button>
</div>
<Toaster richColors />
</>
);
}
This example also needs: Button.
Installation
npx nxds-cli@latest add sonner
Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.
Usage
import { Toaster } from '@/components/nxds/components/sonner/sonner';
Props
Toaster
Toaster adds no props of its own; it accepts the props of the element or primitive it wraps.
Toaster shows short, temporary notifications. Mount it once near your app root, then call toast() from anywhere. For a message that stays on the page, use Alert.
<script setup lang="ts">
import { Button } from '@/components/button';
import { Toaster, toast } from '@/components/sonner';
const showToast = () => toast('Event has been created');
const showSuccessToast = () => toast.success('Changes saved');
const showErrorToast = () => toast.error('Something went wrong');
const showWarningToast = () => toast.warning('Your session is about to expire');
const showInfoToast = () => toast.info('A new version is available');
const showUndoToast = () =>
toast('File deleted', {
action: { label: 'Undo', onClick: () => toast('File restored') },
});
const showPromiseToast = () =>
toast.promise(
new Promise<void>((resolve) => {
setTimeout(resolve, 1500);
}),
{
loading: 'Saving…',
success: 'Settings saved',
error: 'Could not save',
}
);
</script>
<template>
<div class="nx:flex nx:flex-wrap nx:gap-2">
<Button variant="outline" @click="showToast">Default</Button>
<Button variant="outline" @click="showSuccessToast">Success</Button>
<Button variant="outline" @click="showErrorToast">Error</Button>
<Button variant="outline" @click="showWarningToast">Warning</Button>
<Button variant="outline" @click="showInfoToast">Info</Button>
<Button variant="outline" @click="showUndoToast">With action</Button>
<Button variant="outline" @click="showPromiseToast">Promise</Button>
</div>
<Toaster rich-colors />
</template>
This example also needs: Button.
Installation
- 1
Install the following dependencies:
npm install @phosphor-icons/vue@^2.2.1 vue-sonner@^2.0.9 - 2
Copy and paste the following code into your project.
components/sonner/Toaster.vue <script lang="ts"> // nxds toasts render only in the nxds Toaster, and a host app's own sonner // Toaster never shows them, so mounting both never doubles a toast. export const NXDS_TOASTER_ID = 'nxds'; </script> <script setup lang="ts"> import { computed, type CSSProperties, onBeforeUnmount, onMounted, ref, useAttrs, } from 'vue'; import { Toaster as Sonner, type ToasterProps as SonnerToasterProps, } from 'vue-sonner'; import { IconAlertCircle, IconAlertTriangle, IconCircleCheck, IconInfoCircle, IconLoader2, } from '../../lib/icons'; import 'vue-sonner/style.css'; export type ToasterProps = Omit<SonnerToasterProps, 'id'>; defineOptions({ inheritAttrs: false }); const props = defineProps<ToasterProps>(); /** * Track the active theme by observing the `.dark` class on the document root * (where nxds toggles dark mode), or `data-theme="dark"` (where a Claude Design * page sets it), via MutationObserver, keeping sonner's own `theme` in sync * with the design system. */ function subscribeToTheme(notify: () => void) { const observer = new MutationObserver(notify); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'], }); return () => observer.disconnect(); } function getThemeSnapshot(): 'light' | 'dark' { const root = document.documentElement; return root.classList.contains('dark') || root.dataset['theme'] === 'dark' ? 'dark' : 'light'; } /** * Sonner reads these custom properties off the toaster container to colour each * toast; map every one to its nxds semantic token. The `-bg`/`-text`/`-border` * triples for success/error/warning/info apply when `richColors` is enabled. */ const toasterThemeVars = { '--normal-bg': 'var(--nx-color-container)', '--normal-text': 'var(--nx-color-foreground)', '--normal-border': 'var(--nx-color-border-default)', '--border-radius': 'var(--nx-radius-md)', '--success-bg': 'var(--nx-color-success-background)', '--success-text': 'var(--nx-color-success-foreground)', '--success-border': 'var(--nx-color-success-border)', '--info-bg': 'var(--nx-color-information-background)', '--info-text': 'var(--nx-color-information-foreground)', '--info-border': 'var(--nx-color-information-border)', '--warning-bg': 'var(--nx-color-warning-background)', '--warning-text': 'var(--nx-color-warning-foreground)', '--warning-border': 'var(--nx-color-warning-border)', '--error-bg': 'var(--nx-color-error-background)', '--error-text': 'var(--nx-color-error-foreground)', '--error-border': 'var(--nx-color-error-border)', }; // The document is read only once mounted, so server rendering starts light. const documentTheme = ref<'light' | 'dark'>('light'); let unsubscribe: (() => void) | undefined; onMounted(() => { documentTheme.value = getThemeSnapshot(); unsubscribe = subscribeToTheme(() => { documentTheme.value = getThemeSnapshot(); }); }); onBeforeUnmount(() => unsubscribe?.()); // Sonner pins its toaster to z-index:999999999 in a stylesheet; an inline // style outranks it without `!`. const style = computed( () => ({ ...toasterThemeVars, zIndex: 'var(--nx-z-index-toast)', ...props.style, }) as CSSProperties ); // Attributes go to Sonner, as React spreads its props there, not onto the // wrapper. const attrs = useAttrs(); const forwarded = computed( () => ({ ...attrs, ...props }) as SonnerToasterProps ); </script> <template> <div data-slot="toaster"> <Sonner v-bind="forwarded" :id="NXDS_TOASTER_ID" :theme="props.theme ?? documentTheme" :style="style" > <template #success-icon><IconCircleCheck class="nx:size-4" /></template> <template #info-icon><IconInfoCircle class="nx:size-4" /></template> <template #warning-icon><IconAlertTriangle class="nx:size-4" /></template> <template #error-icon><IconAlertCircle class="nx:size-4" /></template> <template #loading-icon ><IconLoader2 class="nx:size-4 nx:animate-spin" /></template> </Sonner> </div> </template>components/sonner/index.ts import { computed } from 'vue'; import { type ExternalToast, toast as sonnerToast, type ToastT, type ToastToDismiss, useVueSonner, } from 'vue-sonner'; import { NXDS_TOASTER_ID } from './Toaster.vue'; export { default as Toaster, type ToasterProps } from './Toaster.vue'; function inNxdsToaster<Data extends object>(data?: Data) { return { ...data, toasterId: NXDS_TOASTER_ID }; } function isNxdsToast(entry: ToastT | ToastToDismiss): entry is ToastT { return 'toasterId' in entry && entry.toasterId === NXDS_TOASTER_ID; } type Message = Parameters<typeof sonnerToast.success>[0]; const toast = Object.assign( (message: Parameters<typeof sonnerToast>[0], data?: ExternalToast) => sonnerToast(message, inNxdsToaster(data)), { success: (message: Message, data?: ExternalToast) => sonnerToast.success(message, inNxdsToaster(data)), info: (message: Message, data?: ExternalToast) => sonnerToast.info(message, inNxdsToaster(data)), warning: (message: Message, data?: ExternalToast) => sonnerToast.warning(message, inNxdsToaster(data)), error: (message: Message, data?: ExternalToast) => sonnerToast.error(message, inNxdsToaster(data)), message: (message: Message, data?: ExternalToast) => sonnerToast.message(message, inNxdsToaster(data)), loading: (message: Message, data?: ExternalToast) => sonnerToast.loading(message, inNxdsToaster(data)), custom: ( content: Parameters<typeof sonnerToast.custom>[0], data?: ExternalToast ) => sonnerToast.custom(content, inNxdsToaster(data)), promise<ToastData>( promise: Parameters<typeof sonnerToast.promise<ToastData>>[0], data?: Parameters<typeof sonnerToast.promise<ToastData>>[1] ) { return sonnerToast.promise(promise, data && inNxdsToaster(data)); }, // Without an id, dismisses every nxds toast and leaves the host's alone. dismiss: (id?: number | string) => { if (id !== undefined) return sonnerToast.dismiss(id); for (const entry of sonnerToast.getToasts().filter(isNxdsToast)) { sonnerToast.dismiss(entry.id); } }, getHistory: () => sonnerToast.getHistory().filter(isNxdsToast), getToasts: () => sonnerToast.getToasts().filter(isNxdsToast), } ); /** The nxds toasts currently on screen. Call it during a component's setup. */ function useNxdsToasts() { const { activeToasts } = useVueSonner(); return { toasts: computed(() => activeToasts.value.filter(isNxdsToast)) }; } export { toast, useNxdsToasts as useSonner };lib/icons.ts /** * Internal Icons * * This barrel file re-exports icons used internally by the design system. * These are hardcoded icons for component states (loading, validation, navigation). * * User-facing icons should be passed via props by consumers. * * This set draws Phosphor. `nxds init --icons` installs the same names drawn * by another library. */ import { PhCircle } from '@phosphor-icons/vue'; import { type FunctionalComponent, h } from 'vue'; // Loading states export { PhCircleNotch as IconLoader2 } from '@phosphor-icons/vue'; // Navigation export { PhArrowDown as IconArrowDown, PhArrowLeft as IconArrowLeft, PhArrowRight as IconArrowRight, PhArrowUp as IconArrowUp, PhCaretDown as IconChevronDown, PhCaretLeft as IconChevronLeft, PhCaretRight as IconChevronRight, PhCaretUp as IconChevronUp, PhDotsThree as IconDots, PhSidebarSimple as IconLayoutSidebar, PhCaretUpDown as IconSelector, } from '@phosphor-icons/vue'; // Actions export { PhCheck as IconCheck, PhCopy as IconCopy, PhMinus as IconMinus, PhPencilSimple as IconPencil, PhPlus as IconPlus, PhX as IconX, } from '@phosphor-icons/vue'; export const IconCircleFilled: FunctionalComponent = (_props, { attrs }) => h(PhCircle, { weight: 'fill', ...attrs }); // Filter field types export { PhCalendarBlank as IconCalendar, PhHash as IconHash, PhTextAa as IconLetterCase, PhListBullets as IconList, } from '@phosphor-icons/vue'; // Search / input export { PhMagnifyingGlass as IconSearch } from '@phosphor-icons/vue'; // Alerts/Status export { PhWarningCircle as IconAlertCircle, PhWarning as IconAlertTriangle, PhCheckCircle as IconCircleCheck, PhInfo as IconInfoCircle, } from '@phosphor-icons/vue'; // Layout export { PhDotsSixVertical as IconGripVertical, PhPalette as IconPalette, } from '@phosphor-icons/vue'; - 3
Keep each file at the path shown above it.
Paths are relative to
packages/vue/src/. Keep the same folders under your@/root, and the imports between files work without edits.
Usage
import { Toaster, toast } from '@/components/sonner';
Props
Toaster
Toaster adds no props of its own; it accepts the props of the element or primitive it wraps.