Sonner
Toast notifications for Tile UI.
packages/react/src/components/sonner/sonner.tsx
import React from 'react';
import { useSyncExternalStore } from 'react';
import { SONNER_DEFAULT_DURATION, buildSonnerToastApi, createSonnerStore, getSonnerPositionStyleKeys, resolveSonnerTheme, sonnerStyleKeys } from '../lib/core';
import type { SonnerPosition, SonnerStore, SonnerTheme, SonnerToast, SonnerToastApi, SonnerToasterBaseProps, SonnerType } from '../lib/core';
import styles from './sonner.module.scss';
const emptyToasts: SonnerToast[] = Object.freeze([]) as unknown as SonnerToast[];
const toasterOwners = new Map<symbol, number | undefined>();
const ownerListeners = new Set<() => void>();
let browserStore: SonnerStore | undefined;
let activeOwner: symbol | undefined;
function getBrowserStore(): SonnerStore | undefined {
if (typeof window === 'undefined') return undefined;
return (browserStore ??= createSonnerStore());
}
const storeFacade: SonnerStore = {
getToasts: () => getBrowserStore()?.getToasts() ?? emptyToasts,
subscribe: (listener) => getBrowserStore()?.subscribe(listener) ?? (() => undefined),
add: (input) => getBrowserStore()?.add(input) ?? '',
update: (id, patch) => getBrowserStore()?.update(id, patch),
dismiss: (id) => getBrowserStore()?.dismiss(id),
remove: (id) => getBrowserStore()?.remove(id),
dismissAll: () => getBrowserStore()?.dismissAll(),
setDefaultDuration: (duration) => getBrowserStore()?.setDefaultDuration(duration),
};
function applyToasterPolicy() {
const owner = Array.from(toasterOwners.entries()).at(-1);
activeOwner = owner?.[0];
getBrowserStore()?.setDefaultDuration(owner?.[1] ?? SONNER_DEFAULT_DURATION);
for (const listener of ownerListeners) listener();
}
export const toast: SonnerToastApi = buildSonnerToastApi(storeFacade);
export interface UseToastReturn {
toasts: SonnerToast[];
toast: SonnerToastApi;
dismiss: (id?: string) => void;
dismissAll: () => void;
}
function useToast(): UseToastReturn {
const toasts = useSyncExternalStore(storeFacade.subscribe, storeFacade.getToasts, () => emptyToasts);
return {
toasts,
toast,
dismiss: (id) => toast.dismiss(id),
dismissAll: () => toast.dismissAll(),
};
}
function SuccessIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
function InfoIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
);
}
function WarningIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
);
}
function ErrorIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z" />
</svg>
);
}
function LoadingIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
);
}
function CloseIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
function ToastIcon({ type }: { type: SonnerType }) {
switch (type) {
case 'success':
return <SuccessIcon />;
case 'info':
return <InfoIcon />;
case 'warning':
return <WarningIcon />;
case 'error':
return <ErrorIcon />;
case 'loading':
return <LoadingIcon />;
default:
return null;
}
}
function toastRole(type: SonnerType): 'alert' | 'status' {
return type === 'error' || type === 'warning' ? 'alert' : 'status';
}
function useResolvedTheme(theme: SonnerTheme | undefined): 'light' | 'dark' | undefined {
const [systemTheme, setSystemTheme] = React.useState<'light' | 'dark'>('light');
React.useEffect(() => {
if (theme !== 'system') {
return;
}
const media = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => setSystemTheme(media.matches ? 'dark' : 'light');
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, [theme]);
return resolveSonnerTheme(theme, systemTheme === 'dark');
}
export interface ToasterProps extends SonnerToasterBaseProps {
className?: string;
}
function Toaster({ position = 'bottom-right', duration, theme, richColors = false, closeButton = true, className = '' }: ToasterProps) {
const owner = React.useRef(Symbol('sonner-toaster-owner')).current;
const toasts = useSyncExternalStore(storeFacade.subscribe, storeFacade.getToasts, () => emptyToasts);
const currentOwner = useSyncExternalStore(
(listener) => {
ownerListeners.add(listener);
return () => ownerListeners.delete(listener);
},
() => activeOwner,
() => undefined,
);
const resolvedTheme = useResolvedTheme(theme);
React.useEffect(() => {
toasterOwners.set(owner, duration);
applyToasterPolicy();
return () => {
toasterOwners.delete(owner);
applyToasterPolicy();
};
}, [duration, owner]);
React.useEffect(() => {
if (!toasterOwners.has(owner)) return;
toasterOwners.set(owner, duration);
applyToasterPolicy();
}, [duration, owner]);
const groups = new Map<string, SonnerToast[]>();
for (const item of currentOwner === owner ? toasts : emptyToasts) {
const key = item.position ?? position;
const list = groups.get(key);
if (list) {
list.push(item);
} else {
groups.set(key, [item]);
}
}
return (
<>
{Array.from(groups.entries()).map(([groupPosition, list]) => {
const styleKeys = getSonnerPositionStyleKeys(groupPosition as SonnerPosition);
return (
<div
key={groupPosition}
data-slot="toaster"
data-theme={resolvedTheme}
data-rich-colors={richColors ? 'true' : undefined}
className={[styles[styleKeys.base], styles[styleKeys.position], resolvedTheme, className].filter(Boolean).join(' ')}>
{list.map((item) => (
<div
key={item.id}
role={toastRole(item.type)}
aria-live={toastRole(item.type) === 'alert' ? 'assertive' : 'polite'}
aria-atomic="true"
data-slot="toast"
data-type={item.type}
data-dismissing={item.dismissing}
data-rich-colors={item.richColors === undefined ? undefined : item.richColors ? 'true' : 'false'}
className={styles[sonnerStyleKeys.toast]}>
{item.type !== 'default' && (
<div className={styles[sonnerStyleKeys.icon]}>
<ToastIcon type={item.type} />
</div>
)}
<div className={styles[sonnerStyleKeys.content]}>
{item.title !== undefined && <div className={styles[sonnerStyleKeys.title]}>{item.title}</div>}
{item.description !== undefined && <div className={styles[sonnerStyleKeys.description]}>{item.description}</div>}
</div>
{closeButton && item.dismissible !== false && (
<button type="button" aria-label="关闭" className={styles[sonnerStyleKeys.close]} onClick={() => toast.dismiss(item.id)}>
<CloseIcon />
</button>
)}
</div>
))}
</div>
);
})}
</>
);
}
export { Toaster, useToast };
export default Toaster;
No selection tags yet — empty means no evidence, not lack of support.