import { type CSSProperties, type ReactNode, useEffect } from 'react';
import type { ExternalToast } from 'sonner';
import { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner';
import { useBreakpointAtLeast } from '../../../hooks/use-breakpoint';
import { ThemedIcon } from '../../icon/themed-icon';
import { GlassToast } from './glass-toast';
import { TOAST_STATUS_ICON } from './toast-icons';
import { TOAST_SIZE, TOAST_SIZE_DEFAULT } from './toast-scale';
import type { ToastApi, ToasterProps, ToastOptions, ToastPosition, ToastVariant } from './toast-types';
import { TOAST_FILL_TOKEN, TOAST_FOREGROUND_TOKEN } from './toast-variants';
/**
* The web twin of the `Toaster` — a thin adapter over Sonner.
*
* `rn-motion-ui/toaster` resolves to this module on web (and to `./toaster.native`
* on native), so a consumer writes the same `` + `toast()` call on every
* platform. The adapter's job is to reconcile Sonner's API with the shared
* contract in {@link ./toast-types}:
*
* - `position: 'top' | 'bottom'` maps to Sonner's centred corners — the default is
* `top` on web (bottom on native)
* - `duration: 0` (sticky) maps to Sonner's `Infinity`
* - `variant` fills the pill with the matching Button colour (see
* {@link ./toast-variants}) — the same palette the native twin uses
* - `action.onPress` maps to Sonner's `action.onClick`
* - `onClose` follows Sonner's dismissal callbacks
* - `glass` uses the same Surface blur, rim, and compact shadow as native;
* `glassTone="neutral"` uses a neutral surface and high-contrast status glyph
*
* Sonner's surface/foreground colours are re-pointed at the repo's theme tokens
* (see {@link THEME_VARS}) and its border is dropped, so the web toast follows the
* active theme and any consumer `@theme` overrides instead of Sonner's hardcoded
* palette.
*
* The `testID` prop is ignored here — Sonner owns the DOM on web.
*/
const SONNER_POSITION: Record = {
top: 'top-center',
bottom: 'bottom-center',
};
/**
* Corner radius (px) a pill toast uses — large enough that the browser clamps it
* to half the toast's height, i.e. a capsule (matching the native `Surface`).
*/
const PILL_RADIUS = 9999;
/**
* Sonner's default CSS adds margins to the toast icon — `[data-icon]` gets a
* `-3px`/`4px` start/end margin and its inner `svg` a further `-1px`/`0px` —
* which nudges the icon off the toast's padding edge and stacks 4px of extra
* space on top of the toast `gap`. Drop those margins so `gap` is the single
* source of icon↔text spacing, and re-point the `[data-icon]` box from Sonner's
* fixed 16px to hug its glyph instead, so the per-toast icon size (see
* {@link TOAST_SIZE}) drives the layout box. Each rule matches Sonner's selector
* specificity, so the injected rule — rendered later in the body — wins at equal
* specificity.
*/
const ICON_OVERRIDE_CSS = `[data-sonner-toast][data-styled='true'] [data-icon]{width:auto;height:auto;margin-left:0;margin-right:0}
[data-sonner-toast][data-styled='true'] [data-icon] svg{margin-left:0;margin-right:0}
[data-sonner-toast][data-styled='true'] [data-title]{font-weight:600;line-height:inherit}
[data-sonner-toast][data-styled='true'] [data-description]{line-height:inherit}
[data-sonner-toast][data-styled='true'] [data-content]{gap:0}
[data-sonner-toast].rn-glass-toast{transition:transform 220ms ease-out,opacity 180ms ease-out,height 220ms ease-out}
[data-sonner-toast].rn-glass-toast[data-mounted='false']{transform:translateY(calc(var(--lift) * -12px))}
[data-sonner-toast].rn-glass-toast[data-removed='true'][data-front='true'][data-swipe-out='false']{transform:translateY(calc(var(--lift) * -12px))}
@media(prefers-reduced-motion:reduce){[data-sonner-toast].rn-glass-toast{transition:none!important;transform:none!important}}`;
/**
* Sonner's theme vars re-pointed at the repo's semantic tokens, so the toast's
* surface/foreground colours adapt to the theme and its border is dropped
* (Sonner draws a 1px border with `--normal-border`, so it is keyed to
* `transparent`). Custom CSS properties aren't part of `CSSProperties`, hence
* the cast.
*/
// biome-ignore lint/plugin: ts/no-as-cast — CSS custom properties (`--normal-bg`, …) aren't part of the closed `CSSProperties` index
const THEME_VARS = {
'--normal-bg': 'var(--color-surface-3)',
'--normal-border': 'transparent',
'--normal-text': 'var(--color-foreground)',
} as CSSProperties;
/**
* Shrink the web toast to its content instead of Sonner's fixed 356px box, and
* re-centre it (Sonner centres by filling a fixed-width column, so a narrower
* toast would otherwise hug the left edge). `maxWidth` keeps a long message
* wrapping at a readable measure.
*/
const TOAST_STYLE: CSSProperties = {
width: 'fit-content',
maxWidth: 'min(356px, 100%)',
left: 0,
right: 0,
marginLeft: 'auto',
marginRight: 'auto',
};
/**
* The solid (non-glass) pill's inline style — the variant's fill and ink,
* resolved to the same `--color-*` tokens the native twin reads. Sonner's title
* inherits the toast `color`, and `richColors` makes the description inherit it
* too.
*/
function solidStyle(variant: ToastVariant): CSSProperties {
return {
backgroundColor: `var(--color-${TOAST_FILL_TOKEN[variant]})`,
color: `var(--color-${TOAST_FOREGROUND_TOKEN[variant]})`,
};
}
/** The default status glyph for a semantic variant, themed to the pill's ink —
* the same legible foreground on both the solid and frosted fills, since a glass
* pill now keeps its variant's hue in the fill itself. Neutral and the Button
* fills render no glyph. */
function statusIcon(variant: ToastVariant, size: number): ReactNode {
const Icon = TOAST_STATUS_ICON[variant];
if (!Icon) return null;
return ;
}
/**
* The ``, `` and `` defaults,
* mirrored into module variables so `toast()` (which has no access to the
* mounted ``'s props) can resolve them — the same hand-off the native
* twin does through `setToastDefaults`.
*/
let defaultGlass = false;
let defaultGlassTone: ToastOptions['glassTone'] = 'variant';
let sequence = 0;
const closeCallbacks = new Map void>();
function dismiss(id?: string) {
const ids = id ? [id] : [...closeCallbacks.keys()];
for (const key of ids) closeCallbacks.get(key)?.();
sonnerToast.dismiss(id);
}
let defaultPill = false;
let defaultSize = TOAST_SIZE_DEFAULT;
/** Translate a shared {@link ToastOptions} into Sonner's `ExternalToast`. */
function toSonnerOptions(options?: ToastOptions): ExternalToast {
const { variant = 'neutral', position, duration, description, action, onClose, pill, size } = options ?? {};
const isPill = pill ?? defaultPill;
const geometry = TOAST_SIZE[size ?? defaultSize];
return {
position: position === undefined ? undefined : SONNER_POSITION[position],
duration: duration === 0 ? Number.POSITIVE_INFINITY : duration,
description,
action: action === undefined ? undefined : { label: action.label, onClick: () => action.onPress() },
onDismiss: onClose,
// Sonner's variant methods (success/error/…) only colour an icon, so every
// toast routes through the plain `sonnerToast` and the variant is drawn by
// the fill/ink inline style instead — matching the native pill.
richColors: true,
icon: statusIcon(variant, geometry.icon),
style: {
...solidStyle(variant),
...(isPill ? { borderRadius: PILL_RADIUS } : {}),
fontSize: geometry.message.px,
lineHeight: `${geometry.lineHeight}px`,
paddingBlock: geometry.padY,
paddingInlineStart: geometry.padX,
paddingInlineEnd: isPill ? geometry.pillPadEnd : geometry.padX,
gap: geometry.gap,
},
};
}
/** Route a toast through Sonner with the shared `variant` fill/ink applied. */
function show(message: string, options?: ToastOptions): string {
const resolved = {
...options,
glass: options?.glass ?? defaultGlass,
glassTone: options?.glassTone ?? defaultGlassTone,
size: options?.size ?? defaultSize,
pill: options?.pill ?? defaultPill,
};
sequence += 1;
const id = `rn-toast-${sequence}`;
const onClose = () => {
if (!closeCallbacks.delete(id)) return;
resolved.onClose?.();
};
closeCallbacks.set(id, onClose);
const handleDismiss = () => dismiss(id);
if (resolved.glass) {
sonnerToast.custom(
// biome-ignore lint/performance/noJsxPropsBind: handleDismiss is allocated once per toast, outside React render
() => ,
{
id,
position: resolved.position === undefined ? undefined : SONNER_POSITION[resolved.position],
duration: resolved.duration === 0 ? Number.POSITIVE_INFINITY : resolved.duration,
onDismiss: onClose,
onAutoClose: onClose,
style: TOAST_STYLE,
className: 'rn-glass-toast',
},
);
return id;
}
sonnerToast(message, { ...toSonnerOptions(options), id, onDismiss: onClose, onAutoClose: onClose });
return id;
}
export function Toaster({
position = 'top',
duration,
glass = false,
glassTone = 'variant',
pill = false,
size = TOAST_SIZE_DEFAULT,
smallScreenPosition,
largeScreenPosition,
wideBreakpoint = 'sm',
offset,
}: ToasterProps) {
const isWide = useBreakpointAtLeast(wideBreakpoint);
const effectivePosition = isWide ? (largeScreenPosition ?? position) : (smallScreenPosition ?? position);
// biome-ignore lint/plugin: sync the Toaster's glass/pill/size defaults into the module variables toast() reads — an external system whose change only matters post-commit
useEffect(() => {
defaultGlass = glass;
defaultGlassTone = glassTone;
defaultPill = pill;
defaultSize = size;
}, [glass, glassTone, pill, size]);
return (
<>
>
);
}
// biome-ignore lint/style/useComponentExportOnlyModules: `toast` is the imperative half of the toaster's public API and must ship from the same subpath as ``
export const toast: ToastApi = Object.assign((message: string, options?: ToastOptions) => show(message, options), {
success: (message: string, options?: ToastOptions) => show(message, { ...options, variant: 'success' }),
error: (message: string, options?: ToastOptions) => show(message, { ...options, variant: 'danger' }),
warning: (message: string, options?: ToastOptions) => show(message, { ...options, variant: 'warning' }),
info: (message: string, options?: ToastOptions) => show(message, { ...options, variant: 'info' }),
dismiss,
});