Repository: emilkowalski/sonner Branch: main Commit: 45d894085af8 Files: 64 Total size: 156.4 KB Directory structure: gitextract_cl8qn5z4/ ├── .github/ │ └── workflows/ │ └── playwright.yml ├── .gitignore ├── .prettierrc.js ├── FUNDING.yml ├── LICENSE.md ├── README.md ├── package.json ├── playwright.config.ts ├── pnpm-workspace.yaml ├── src/ │ ├── assets.tsx │ ├── hooks.tsx │ ├── index.tsx │ ├── state.ts │ ├── styles.css │ └── types.ts ├── test/ │ ├── .eslintrc.json │ ├── .gitignore │ ├── .npmrc │ ├── .vscode/ │ │ └── settings.json │ ├── README.md │ ├── next.config.js │ ├── package.json │ ├── src/ │ │ └── app/ │ │ ├── action.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── tests/ │ │ └── basic.spec.ts │ └── tsconfig.json ├── tsconfig.json ├── turbo.json └── website/ ├── .eslintrc.json ├── .gitignore ├── .vscode/ │ └── settings.json ├── README.md ├── next.config.js ├── package.json ├── postcss.config.js ├── src/ │ ├── components/ │ │ ├── CodeBlock/ │ │ │ ├── code-block.module.css │ │ │ └── index.tsx │ │ ├── ExpandModes/ │ │ │ └── index.tsx │ │ ├── Footer/ │ │ │ ├── footer.module.css │ │ │ └── index.tsx │ │ ├── Head/ │ │ │ └── index.tsx │ │ ├── Hero/ │ │ │ ├── hero.module.css │ │ │ └── index.tsx │ │ ├── How/ │ │ │ └── How.tsx │ │ ├── Installation/ │ │ │ ├── index.tsx │ │ │ └── installation.module.css │ │ ├── Other/ │ │ │ ├── Other.tsx │ │ │ └── other.module.css │ │ ├── Position/ │ │ │ └── index.tsx │ │ ├── Types/ │ │ │ └── Types.tsx │ │ └── Usage/ │ │ └── index.tsx │ ├── globals.css │ ├── pages/ │ │ ├── _app.tsx │ │ ├── _meta.json │ │ ├── getting-started.mdx │ │ ├── index.tsx │ │ ├── styling.mdx │ │ ├── toast.mdx │ │ └── toaster.mdx │ └── style.css ├── tailwind.config.js ├── theme.config.jsx └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/playwright.yml ================================================ name: Playwright Tests on: push: branches: [main, master] pull_request: branches: [main, master] jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - run: npm install pnpm@9.15.9 -g - run: pnpm install --no-frozen-lockfile - run: pnpm build - run: npx playwright install --with-deps - run: pnpm test || exit 1 - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 30 ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. dist # dependencies node_modules .pnp .pnp.js # testing coverage # next.js .next/ out/ build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env.local .env.development.local .env.test.local .env.production.local # turbo .turbo /test-results/ /playwright-report/ /playwright/.cache/ ================================================ FILE: .prettierrc.js ================================================ module.exports = { semi: true, singleQuote: true, tabWidth: 2, trailingComma: 'all', printWidth: 120, }; ================================================ FILE: FUNDING.yml ================================================ github: emilkowalski ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2023 Emil Kowalski Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ https://github.com/vallezw/sonner/assets/50796600/59b95cb7-9068-4f3e-8469-0b35d9de5cf0 [Sonner](https://sonner.emilkowal.ski/) is an opinionated toast component for React. You can read more about why and how it was built [here](https://emilkowal.ski/ui/building-a-toast-component). ## Usage To start using the library, install it in your project: ```bash npm install sonner ``` Add `` to your app, it will be the place where all your toasts will be rendered. After that you can use `toast()` from anywhere in your app. ```jsx import { Toaster, toast } from 'sonner'; // ... function App() { return (
); } ``` ## Documentation Find the full API reference in the [documentation](https://sonner.emilkowal.ski/getting-started). ================================================ FILE: package.json ================================================ { "name": "sonner", "version": "2.0.7", "description": "An opinionated toast component for React.", "exports": { ".": { "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, "require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "default": "./dist/index.js" }, "./dist/styles.css": "./dist/styles.css" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "dev": "bunchee --watch", "build": "bunchee && cp src/styles.css dist/styles.css", "type-check": "tsc --noEmit", "dev:website": "turbo run dev --filter=website...", "dev:test": "turbo run dev --filter=test...", "format": "prettier --write .", "test": "playwright test" }, "keywords": [ "react", "notifications", "toast", "snackbar", "message" ], "author": "Emil Kowalski ", "license": "MIT", "homepage": "https://sonner.emilkowal.ski/", "repository": { "type": "git", "url": "git+https://github.com/emilkowalski/sonner.git" }, "bugs": { "url": "https://github.com/emilkowalski/sonner/issues" }, "devDependencies": { "@playwright/test": "^1.49.1", "@types/node": "^18.11.13", "@types/react": "^18.0.26", "bunchee": "6.3.3", "prettier": "^2.8.4", "react": "^18.2.0", "react-dom": "^18.2.0", "turbo": "1.6", "typescript": "^4.8.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "packageManager": "pnpm@9.15.9" } ================================================ FILE: playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ // require('dotenv').config(); /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './test', /* Maximum time one test can run for. */ timeout: 30 * 1000, expect: { /** * Maximum time expect() should wait for the condition to be met. * For example in `await expect(locator).toHaveText();` */ timeout: 5000, }, /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { trace: 'on-first-retry', baseURL: 'http://localhost:3000', }, webServer: { command: 'npm run dev', url: 'http://localhost:3000', cwd: './test', reuseExistingServer: !process.env.CI, }, /* Configure projects for major browsers */ projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, // { // name: 'firefox', // use: { ...devices['Desktop Firefox'] }, // }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, /* Test against mobile viewports. */ // { // name: 'Mobile Chrome', // use: { ...devices['Pixel 5'] }, // }, // { // name: 'Mobile Safari', // use: { ...devices['iPhone 12'] }, // }, /* Test against branded browsers. */ // { // name: 'Microsoft Edge', // use: { channel: 'msedge' }, // }, // { // name: 'Google Chrome', // use: { channel: 'chrome' }, // }, ], }); ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - 'website' - '.' - 'test' ================================================ FILE: src/assets.tsx ================================================ 'use client'; import React from 'react'; import type { ToastTypes } from './types'; export const getAsset = (type: ToastTypes): JSX.Element | null => { switch (type) { case 'success': return SuccessIcon; case 'info': return InfoIcon; case 'warning': return WarningIcon; case 'error': return ErrorIcon; default: return null; } }; const bars = Array(12).fill(0); export const Loader = ({ visible, className }: { visible: boolean; className?: string }) => { return (
{bars.map((_, i) => (
))}
); }; const SuccessIcon = ( ); const WarningIcon = ( ); const InfoIcon = ( ); const ErrorIcon = ( ); export const CloseIcon = ( ); ================================================ FILE: src/hooks.tsx ================================================ import React from 'react'; export const useIsDocumentHidden = () => { const [isDocumentHidden, setIsDocumentHidden] = React.useState(document.hidden); React.useEffect(() => { const callback = () => { setIsDocumentHidden(document.hidden); }; document.addEventListener('visibilitychange', callback); return () => document.removeEventListener('visibilitychange', callback); }, []); return isDocumentHidden; }; ================================================ FILE: src/index.tsx ================================================ 'use client'; import React from 'react'; import ReactDOM from 'react-dom'; import { CloseIcon, getAsset, Loader } from './assets'; import { useIsDocumentHidden } from './hooks'; import { toast, ToastState } from './state'; import './styles.css'; import { isAction, SwipeDirection, type ExternalToast, type HeightT, type ToasterProps, type ToastProps, type ToastT, type ToastToDismiss, } from './types'; // Visible toasts amount const VISIBLE_TOASTS_AMOUNT = 3; // Viewport padding const VIEWPORT_OFFSET = '24px'; // Mobile viewport padding const MOBILE_VIEWPORT_OFFSET = '16px'; // Default lifetime of a toasts (in ms) const TOAST_LIFETIME = 4000; // Default toast width const TOAST_WIDTH = 356; // Default gap between toasts const GAP = 14; // Threshold to dismiss a toast const SWIPE_THRESHOLD = 45; // Equal to exit animation duration const TIME_BEFORE_UNMOUNT = 200; function cn(...classes: (string | undefined)[]) { return classes.filter(Boolean).join(' '); } function getDefaultSwipeDirections(position: string): Array { const [y, x] = position.split('-'); const directions: Array = []; if (y) { directions.push(y as SwipeDirection); } if (x) { directions.push(x as SwipeDirection); } return directions; } const Toast = (props: ToastProps) => { const { invert: ToasterInvert, toast, unstyled, interacting, setHeights, visibleToasts, heights, index, toasts, expanded, removeToast, defaultRichColors, closeButton: closeButtonFromToaster, style, cancelButtonStyle, actionButtonStyle, className = '', descriptionClassName = '', duration: durationFromToaster, position, gap, expandByDefault, classNames, icons, closeButtonAriaLabel = 'Close toast', } = props; const [swipeDirection, setSwipeDirection] = React.useState<'x' | 'y' | null>(null); const [swipeOutDirection, setSwipeOutDirection] = React.useState<'left' | 'right' | 'up' | 'down' | null>(null); const [mounted, setMounted] = React.useState(false); const [removed, setRemoved] = React.useState(false); const [swiping, setSwiping] = React.useState(false); const [swipeOut, setSwipeOut] = React.useState(false); const [isSwiped, setIsSwiped] = React.useState(false); const [offsetBeforeRemove, setOffsetBeforeRemove] = React.useState(0); const [initialHeight, setInitialHeight] = React.useState(0); const remainingTime = React.useRef(toast.duration || durationFromToaster || TOAST_LIFETIME); const dragStartTime = React.useRef(null); const toastRef = React.useRef(null); const isFront = index === 0; const isVisible = index + 1 <= visibleToasts; const toastType = toast.type; const dismissible = toast.dismissible !== false; const toastClassname = toast.className || ''; const toastDescriptionClassname = toast.descriptionClassName || ''; // Height index is used to calculate the offset as it gets updated before the toast array, which means we can calculate the new layout faster. const heightIndex = React.useMemo( () => heights.findIndex((height) => height.toastId === toast.id) || 0, [heights, toast.id], ); const closeButton = React.useMemo( () => toast.closeButton ?? closeButtonFromToaster, [toast.closeButton, closeButtonFromToaster], ); const duration = React.useMemo( () => toast.duration || durationFromToaster || TOAST_LIFETIME, [toast.duration, durationFromToaster], ); const closeTimerStartTimeRef = React.useRef(0); const offset = React.useRef(0); const lastCloseTimerStartTimeRef = React.useRef(0); const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null); const [y, x] = position.split('-'); const toastsHeightBefore = React.useMemo(() => { return heights.reduce((prev, curr, reducerIndex) => { // Calculate offset up until current toast if (reducerIndex >= heightIndex) { return prev; } return prev + curr.height; }, 0); }, [heights, heightIndex]); const isDocumentHidden = useIsDocumentHidden(); const invert = toast.invert || ToasterInvert; const disabled = toastType === 'loading'; offset.current = React.useMemo(() => heightIndex * gap + toastsHeightBefore, [heightIndex, toastsHeightBefore]); React.useEffect(() => { remainingTime.current = duration; }, [duration]); React.useEffect(() => { // Trigger enter animation without using CSS animation setMounted(true); }, []); React.useEffect(() => { const toastNode = toastRef.current; if (toastNode) { const height = toastNode.getBoundingClientRect().height; // Add toast height to heights array after the toast is mounted setInitialHeight(height); setHeights((h) => [{ toastId: toast.id, height, position: toast.position }, ...h]); return () => setHeights((h) => h.filter((height) => height.toastId !== toast.id)); } }, [setHeights, toast.id]); React.useLayoutEffect(() => { // Keep height up to date with the content in case it updates if (!mounted) return; const toastNode = toastRef.current; const originalHeight = toastNode.style.height; toastNode.style.height = 'auto'; const newHeight = toastNode.getBoundingClientRect().height; toastNode.style.height = originalHeight; setInitialHeight(newHeight); setHeights((heights) => { const alreadyExists = heights.find((height) => height.toastId === toast.id); if (!alreadyExists) { return [{ toastId: toast.id, height: newHeight, position: toast.position }, ...heights]; } else { return heights.map((height) => (height.toastId === toast.id ? { ...height, height: newHeight } : height)); } }); }, [mounted, toast.title, toast.description, setHeights, toast.id, toast.jsx, toast.action, toast.cancel]); const deleteToast = React.useCallback(() => { // Save the offset for the exit swipe animation setRemoved(true); setOffsetBeforeRemove(offset.current); setHeights((h) => h.filter((height) => height.toastId !== toast.id)); setTimeout(() => { removeToast(toast); }, TIME_BEFORE_UNMOUNT); }, [toast, removeToast, setHeights, offset]); React.useEffect(() => { if ((toast.promise && toastType === 'loading') || toast.duration === Infinity || toast.type === 'loading') return; let timeoutId: NodeJS.Timeout; // Pause the timer on each hover const pauseTimer = () => { if (lastCloseTimerStartTimeRef.current < closeTimerStartTimeRef.current) { // Get the elapsed time since the timer started const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.current; remainingTime.current = remainingTime.current - elapsedTime; } lastCloseTimerStartTimeRef.current = new Date().getTime(); }; const startTimer = () => { // setTimeout(, Infinity) behaves as if the delay is 0. // As a result, the toast would be closed immediately, giving the appearance that it was never rendered. // See: https://github.com/denysdovhan/wtfjs?tab=readme-ov-file#an-infinite-timeout if (remainingTime.current === Infinity) return; closeTimerStartTimeRef.current = new Date().getTime(); // Let the toast know it has started timeoutId = setTimeout(() => { toast.onAutoClose?.(toast); deleteToast(); }, remainingTime.current); }; if (expanded || interacting || isDocumentHidden) { pauseTimer(); } else { startTimer(); } return () => clearTimeout(timeoutId); }, [expanded, interacting, toast, toastType, isDocumentHidden, deleteToast]); React.useEffect(() => { if (toast.delete) { deleteToast(); toast.onDismiss?.(toast); } }, [deleteToast, toast.delete]); function getLoadingIcon() { if (icons?.loading) { return (
{icons.loading}
); } return ; } const icon = toast.icon || icons?.[toastType] || getAsset(toastType); return (
  • { setSwiping(false); setSwipeDirection(null); pointerStartRef.current = null; }} onPointerDown={(event) => { if (event.button === 2) return; // Return early on right click if (disabled || !dismissible) return; dragStartTime.current = new Date(); setOffsetBeforeRemove(offset.current); // Ensure we maintain correct pointer capture even when going outside of the toast (e.g. when swiping) (event.target as HTMLElement).setPointerCapture(event.pointerId); if ((event.target as HTMLElement).tagName === 'BUTTON') return; setSwiping(true); pointerStartRef.current = { x: event.clientX, y: event.clientY }; }} onPointerUp={() => { if (swipeOut || !dismissible) return; pointerStartRef.current = null; const swipeAmountX = Number( toastRef.current?.style.getPropertyValue('--swipe-amount-x').replace('px', '') || 0, ); const swipeAmountY = Number( toastRef.current?.style.getPropertyValue('--swipe-amount-y').replace('px', '') || 0, ); const timeTaken = new Date().getTime() - dragStartTime.current?.getTime(); const swipeAmount = swipeDirection === 'x' ? swipeAmountX : swipeAmountY; const velocity = Math.abs(swipeAmount) / timeTaken; if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { setOffsetBeforeRemove(offset.current); toast.onDismiss?.(toast); if (swipeDirection === 'x') { setSwipeOutDirection(swipeAmountX > 0 ? 'right' : 'left'); } else { setSwipeOutDirection(swipeAmountY > 0 ? 'down' : 'up'); } deleteToast(); setSwipeOut(true); return; } else { toastRef.current?.style.setProperty('--swipe-amount-x', `0px`); toastRef.current?.style.setProperty('--swipe-amount-y', `0px`); } setIsSwiped(false); setSwiping(false); setSwipeDirection(null); }} onPointerMove={(event) => { if (!pointerStartRef.current || !dismissible) return; const isHighlighted = window.getSelection()?.toString().length > 0; if (isHighlighted) return; const yDelta = event.clientY - pointerStartRef.current.y; const xDelta = event.clientX - pointerStartRef.current.x; const swipeDirections = props.swipeDirections ?? getDefaultSwipeDirections(position); // Determine swipe direction if not already locked if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) { setSwipeDirection(Math.abs(xDelta) > Math.abs(yDelta) ? 'x' : 'y'); } let swipeAmount = { x: 0, y: 0 }; const getDampening = (delta: number) => { const factor = Math.abs(delta) / 20; return 1 / (1.5 + factor); }; // Only apply swipe in the locked direction if (swipeDirection === 'y') { // Handle vertical swipes if (swipeDirections.includes('top') || swipeDirections.includes('bottom')) { if ((swipeDirections.includes('top') && yDelta < 0) || (swipeDirections.includes('bottom') && yDelta > 0)) { swipeAmount.y = yDelta; } else { // Smoothly transition to dampened movement const dampenedDelta = yDelta * getDampening(yDelta); // Ensure we don't jump when transitioning to dampened movement swipeAmount.y = Math.abs(dampenedDelta) < Math.abs(yDelta) ? dampenedDelta : yDelta; } } } else if (swipeDirection === 'x') { // Handle horizontal swipes if (swipeDirections.includes('left') || swipeDirections.includes('right')) { if ((swipeDirections.includes('left') && xDelta < 0) || (swipeDirections.includes('right') && xDelta > 0)) { swipeAmount.x = xDelta; } else { // Smoothly transition to dampened movement const dampenedDelta = xDelta * getDampening(xDelta); // Ensure we don't jump when transitioning to dampened movement swipeAmount.x = Math.abs(dampenedDelta) < Math.abs(xDelta) ? dampenedDelta : xDelta; } } } if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) { setIsSwiped(true); } // Apply transform using both x and y values toastRef.current?.style.setProperty('--swipe-amount-x', `${swipeAmount.x}px`); toastRef.current?.style.setProperty('--swipe-amount-y', `${swipeAmount.y}px`); }} > {closeButton && !toast.jsx && toastType !== 'loading' ? ( ) : null} {/* TODO: This can be cleaner */} {(toastType || toast.icon || toast.promise) && toast.icon !== null && (icons?.[toastType] !== null || toast.icon) ? (
    {toast.promise || (toast.type === 'loading' && !toast.icon) ? toast.icon || getLoadingIcon() : null} {toast.type !== 'loading' ? icon : null}
    ) : null}
    {toast.jsx ? toast.jsx : typeof toast.title === 'function' ? toast.title() : toast.title}
    {toast.description ? (
    {typeof toast.description === 'function' ? toast.description() : toast.description}
    ) : null}
    {React.isValidElement(toast.cancel) ? ( toast.cancel ) : toast.cancel && isAction(toast.cancel) ? ( ) : null} {React.isValidElement(toast.action) ? ( toast.action ) : toast.action && isAction(toast.action) ? ( ) : null}
  • ); }; function getDocumentDirection(): ToasterProps['dir'] { if (typeof window === 'undefined') return 'ltr'; if (typeof document === 'undefined') return 'ltr'; // For Fresh purpose const dirAttribute = document.documentElement.getAttribute('dir'); if (dirAttribute === 'auto' || !dirAttribute) { return window.getComputedStyle(document.documentElement).direction as ToasterProps['dir']; } return dirAttribute as ToasterProps['dir']; } function assignOffset(defaultOffset: ToasterProps['offset'], mobileOffset: ToasterProps['mobileOffset']) { const styles = {} as React.CSSProperties; [defaultOffset, mobileOffset].forEach((offset, index) => { const isMobile = index === 1; const prefix = isMobile ? '--mobile-offset' : '--offset'; const defaultValue = isMobile ? MOBILE_VIEWPORT_OFFSET : VIEWPORT_OFFSET; function assignAll(offset: string | number) { ['top', 'right', 'bottom', 'left'].forEach((key) => { styles[`${prefix}-${key}`] = typeof offset === 'number' ? `${offset}px` : offset; }); } if (typeof offset === 'number' || typeof offset === 'string') { assignAll(offset); } else if (typeof offset === 'object') { ['top', 'right', 'bottom', 'left'].forEach((key) => { if (offset[key] === undefined) { styles[`${prefix}-${key}`] = defaultValue; } else { styles[`${prefix}-${key}`] = typeof offset[key] === 'number' ? `${offset[key]}px` : offset[key]; } }); } else { assignAll(defaultValue); } }); return styles; } function useSonner() { const [activeToasts, setActiveToasts] = React.useState([]); React.useEffect(() => { return ToastState.subscribe((toast) => { if ((toast as ToastToDismiss).dismiss) { setTimeout(() => { ReactDOM.flushSync(() => { setActiveToasts((toasts) => toasts.filter((t) => t.id !== toast.id)); }); }); return; } // Prevent batching, temp solution. setTimeout(() => { ReactDOM.flushSync(() => { setActiveToasts((toasts) => { const indexOfExistingToast = toasts.findIndex((t) => t.id === toast.id); // Update the toast if it already exists if (indexOfExistingToast !== -1) { return [ ...toasts.slice(0, indexOfExistingToast), { ...toasts[indexOfExistingToast], ...toast }, ...toasts.slice(indexOfExistingToast + 1), ]; } return [toast, ...toasts]; }); }); }); }); }, []); return { toasts: activeToasts, }; } const Toaster = React.forwardRef(function Toaster(props, ref) { const { id, invert, position = 'bottom-right', hotkey = ['altKey', 'KeyT'], expand, closeButton, className, offset, mobileOffset, theme = 'light', richColors, duration, style, visibleToasts = VISIBLE_TOASTS_AMOUNT, toastOptions, dir = getDocumentDirection(), gap = GAP, icons, customAriaLabel, containerAriaLabel = 'Notifications', } = props; const [toasts, setToasts] = React.useState([]); const filteredToasts = React.useMemo(() => { if (id) { return toasts.filter((toast) => toast.toasterId === id); } return toasts.filter((toast) => !toast.toasterId); }, [toasts, id]); const possiblePositions = React.useMemo(() => { return Array.from( new Set([position].concat(filteredToasts.filter((toast) => toast.position).map((toast) => toast.position))), ); }, [filteredToasts, position]); const [heights, setHeights] = React.useState([]); const [expanded, setExpanded] = React.useState(false); const [interacting, setInteracting] = React.useState(false); const [actualTheme, setActualTheme] = React.useState( theme !== 'system' ? theme : typeof window !== 'undefined' ? window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' : 'light', ); const listRef = React.useRef(null); const hotkeyLabel = hotkey.join('+').replace(/Key/g, '').replace(/Digit/g, ''); const lastFocusedElementRef = React.useRef(null); const isFocusWithinRef = React.useRef(false); const removeToast = React.useCallback((toastToRemove: ToastT) => { setToasts((toasts) => { if (!toasts.find((toast) => toast.id === toastToRemove.id)?.delete) { ToastState.dismiss(toastToRemove.id); } return toasts.filter(({ id }) => id !== toastToRemove.id); }); }, []); React.useEffect(() => { return ToastState.subscribe((toast) => { if ((toast as ToastToDismiss).dismiss) { // Prevent batching of other state updates requestAnimationFrame(() => { setToasts((toasts) => toasts.map((t) => (t.id === toast.id ? { ...t, delete: true } : t))); }); return; } // Prevent batching, temp solution. setTimeout(() => { ReactDOM.flushSync(() => { setToasts((toasts) => { const indexOfExistingToast = toasts.findIndex((t) => t.id === toast.id); // Update the toast if it already exists if (indexOfExistingToast !== -1) { return [ ...toasts.slice(0, indexOfExistingToast), { ...toasts[indexOfExistingToast], ...toast }, ...toasts.slice(indexOfExistingToast + 1), ]; } return [toast, ...toasts]; }); }); }); }); }, [toasts]); React.useEffect(() => { if (theme !== 'system') { setActualTheme(theme); return; } if (theme === 'system') { // check if current preference is dark if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { // it's currently dark setActualTheme('dark'); } else { // it's not dark setActualTheme('light'); } } if (typeof window === 'undefined') return; const darkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); try { // Chrome & Firefox darkMediaQuery.addEventListener('change', ({ matches }) => { if (matches) { setActualTheme('dark'); } else { setActualTheme('light'); } }); } catch (error) { // Safari < 14 darkMediaQuery.addListener(({ matches }) => { try { if (matches) { setActualTheme('dark'); } else { setActualTheme('light'); } } catch (e) { console.error(e); } }); } }, [theme]); React.useEffect(() => { // Ensure expanded is always false when no toasts are present / only one left if (toasts.length <= 1) { setExpanded(false); } }, [toasts]); React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const isHotkeyPressed = hotkey.length > 0 && hotkey.every((key) => (event as any)[key] || event.code === key); if (isHotkeyPressed) { setExpanded(true); listRef.current?.focus(); } if ( event.code === 'Escape' && (document.activeElement === listRef.current || listRef.current?.contains(document.activeElement)) ) { setExpanded(false); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [hotkey]); React.useEffect(() => { if (listRef.current) { return () => { if (lastFocusedElementRef.current) { lastFocusedElementRef.current.focus({ preventScroll: true }); lastFocusedElementRef.current = null; isFocusWithinRef.current = false; } }; } }, [listRef.current]); return ( // Remove item from normal navigation flow, only available via hotkey
    {possiblePositions.map((position, index) => { const [y, x] = position.split('-'); if (!filteredToasts.length) return null; return (
      { if (isFocusWithinRef.current && !event.currentTarget.contains(event.relatedTarget)) { isFocusWithinRef.current = false; if (lastFocusedElementRef.current) { lastFocusedElementRef.current.focus({ preventScroll: true }); lastFocusedElementRef.current = null; } } }} onFocus={(event) => { const isNotDismissible = event.target instanceof HTMLElement && event.target.dataset.dismissible === 'false'; if (isNotDismissible) return; if (!isFocusWithinRef.current) { isFocusWithinRef.current = true; lastFocusedElementRef.current = event.relatedTarget as HTMLElement; } }} onMouseEnter={() => setExpanded(true)} onMouseMove={() => setExpanded(true)} onMouseLeave={() => { // Avoid setting expanded to false when interacting with a toast, e.g. swiping if (!interacting) { setExpanded(false); } }} onDragEnd={() => setExpanded(false)} onPointerDown={(event) => { const isNotDismissible = event.target instanceof HTMLElement && event.target.dataset.dismissible === 'false'; if (isNotDismissible) return; setInteracting(true); }} onPointerUp={() => setInteracting(false)} > {filteredToasts .filter((toast) => (!toast.position && index === 0) || toast.position === position) .map((toast, index) => ( t.position == toast.position)} heights={heights.filter((h) => h.position == toast.position)} setHeights={setHeights} expandByDefault={expand} gap={gap} expanded={expanded} swipeDirections={props.swipeDirections} /> ))}
    ); })}
    ); }); export { toast, Toaster, type ExternalToast, type ToastT, type ToasterProps, useSonner }; export { type ToastClassnames, type ToastToDismiss, type Action } from './types'; ================================================ FILE: src/state.ts ================================================ import type { ExternalToast, PromiseData, PromiseIExtendedResult, PromiseT, ToastT, ToastToDismiss, ToastTypes, } from './types'; import React from 'react'; let toastsCounter = 1; type titleT = (() => React.ReactNode) | React.ReactNode; class Observer { subscribers: Array<(toast: ExternalToast | ToastToDismiss) => void>; toasts: Array; dismissedToasts: Set; constructor() { this.subscribers = []; this.toasts = []; this.dismissedToasts = new Set(); } // We use arrow functions to maintain the correct `this` reference subscribe = (subscriber: (toast: ToastT | ToastToDismiss) => void) => { this.subscribers.push(subscriber); return () => { const index = this.subscribers.indexOf(subscriber); this.subscribers.splice(index, 1); }; }; publish = (data: ToastT) => { this.subscribers.forEach((subscriber) => subscriber(data)); }; addToast = (data: ToastT) => { this.publish(data); this.toasts = [...this.toasts, data]; }; create = ( data: ExternalToast & { message?: titleT; type?: ToastTypes; promise?: PromiseT; jsx?: React.ReactElement; }, ) => { const { message, ...rest } = data; const id = typeof data?.id === 'number' || data.id?.length > 0 ? data.id : toastsCounter++; const alreadyExists = this.toasts.find((toast) => { return toast.id === id; }); const dismissible = data.dismissible === undefined ? true : data.dismissible; if (this.dismissedToasts.has(id)) { this.dismissedToasts.delete(id); } if (alreadyExists) { this.toasts = this.toasts.map((toast) => { if (toast.id === id) { this.publish({ ...toast, ...data, id, title: message }); return { ...toast, ...data, id, dismissible, title: message, }; } return toast; }); } else { this.addToast({ title: message, ...rest, dismissible, id }); } return id; }; dismiss = (id?: number | string) => { if (id) { this.dismissedToasts.add(id); requestAnimationFrame(() => this.subscribers.forEach((subscriber) => subscriber({ id, dismiss: true }))); } else { this.toasts.forEach((toast) => { this.subscribers.forEach((subscriber) => subscriber({ id: toast.id, dismiss: true })); }); } return id; }; message = (message: titleT | React.ReactNode, data?: ExternalToast) => { return this.create({ ...data, message }); }; error = (message: titleT | React.ReactNode, data?: ExternalToast) => { return this.create({ ...data, message, type: 'error' }); }; success = (message: titleT | React.ReactNode, data?: ExternalToast) => { return this.create({ ...data, type: 'success', message }); }; info = (message: titleT | React.ReactNode, data?: ExternalToast) => { return this.create({ ...data, type: 'info', message }); }; warning = (message: titleT | React.ReactNode, data?: ExternalToast) => { return this.create({ ...data, type: 'warning', message }); }; loading = (message: titleT | React.ReactNode, data?: ExternalToast) => { return this.create({ ...data, type: 'loading', message }); }; promise = (promise: PromiseT, data?: PromiseData) => { if (!data) { // Nothing to show return; } let id: string | number | undefined = undefined; if (data.loading !== undefined) { id = this.create({ ...data, promise, type: 'loading', message: data.loading, description: typeof data.description !== 'function' ? data.description : undefined, }); } const p = Promise.resolve(promise instanceof Function ? promise() : promise); let shouldDismiss = id !== undefined; let result: ['resolve', ToastData] | ['reject', unknown]; const originalPromise = p .then(async (response) => { result = ['resolve', response]; const isReactElementResponse = React.isValidElement(response); if (isReactElementResponse) { shouldDismiss = false; this.create({ id, type: 'default', message: response }); } else if (isHttpResponse(response) && !response.ok) { shouldDismiss = false; const promiseData = typeof data.error === 'function' ? await data.error(`HTTP error! status: ${response.status}`) : data.error; const description = typeof data.description === 'function' ? await data.description(`HTTP error! status: ${response.status}`) : data.description; const isExtendedResult = typeof promiseData === 'object' && !React.isValidElement(promiseData); const toastSettings: PromiseIExtendedResult = isExtendedResult ? (promiseData as PromiseIExtendedResult) : { message: promiseData }; this.create({ id, type: 'error', description, ...toastSettings }); } else if (response instanceof Error) { shouldDismiss = false; const promiseData = typeof data.error === 'function' ? await data.error(response) : data.error; const description = typeof data.description === 'function' ? await data.description(response) : data.description; const isExtendedResult = typeof promiseData === 'object' && !React.isValidElement(promiseData); const toastSettings: PromiseIExtendedResult = isExtendedResult ? (promiseData as PromiseIExtendedResult) : { message: promiseData }; this.create({ id, type: 'error', description, ...toastSettings }); } else if (data.success !== undefined) { shouldDismiss = false; const promiseData = typeof data.success === 'function' ? await data.success(response) : data.success; const description = typeof data.description === 'function' ? await data.description(response) : data.description; const isExtendedResult = typeof promiseData === 'object' && !React.isValidElement(promiseData); const toastSettings: PromiseIExtendedResult = isExtendedResult ? (promiseData as PromiseIExtendedResult) : { message: promiseData }; this.create({ id, type: 'success', description, ...toastSettings }); } }) .catch(async (error) => { result = ['reject', error]; if (data.error !== undefined) { shouldDismiss = false; const promiseData = typeof data.error === 'function' ? await data.error(error) : data.error; const description = typeof data.description === 'function' ? await data.description(error) : data.description; const isExtendedResult = typeof promiseData === 'object' && !React.isValidElement(promiseData); const toastSettings: PromiseIExtendedResult = isExtendedResult ? (promiseData as PromiseIExtendedResult) : { message: promiseData }; this.create({ id, type: 'error', description, ...toastSettings }); } }) .finally(() => { if (shouldDismiss) { // Toast is still in load state (and will be indefinitely — dismiss it) this.dismiss(id); id = undefined; } data.finally?.(); }); const unwrap = () => new Promise((resolve, reject) => originalPromise.then(() => (result[0] === 'reject' ? reject(result[1]) : resolve(result[1]))).catch(reject), ); if (typeof id !== 'string' && typeof id !== 'number') { // cannot Object.assign on undefined return { unwrap }; } else { return Object.assign(id, { unwrap }); } }; custom = (jsx: (id: number | string) => React.ReactElement, data?: ExternalToast) => { const id = data?.id || toastsCounter++; this.create({ jsx: jsx(id), ...data, id }); return id; }; getActiveToasts = () => { return this.toasts.filter((toast) => !this.dismissedToasts.has(toast.id)); }; } export const ToastState = new Observer(); // bind this to the toast function const toastFunction = (message: titleT, data?: ExternalToast) => { const id = data?.id || toastsCounter++; ToastState.addToast({ title: message, ...data, id, }); return id; }; const isHttpResponse = (data: any): data is Response => { return ( data && typeof data === 'object' && 'ok' in data && typeof data.ok === 'boolean' && 'status' in data && typeof data.status === 'number' ); }; const basicToast = toastFunction; const getHistory = () => ToastState.toasts; const getToasts = () => ToastState.getActiveToasts(); // We use `Object.assign` to maintain the correct types as we would lose them otherwise export const toast = Object.assign( basicToast, { success: ToastState.success, info: ToastState.info, warning: ToastState.warning, error: ToastState.error, custom: ToastState.custom, message: ToastState.message, promise: ToastState.promise, dismiss: ToastState.dismiss, loading: ToastState.loading, }, { getHistory, getToasts }, ); ================================================ FILE: src/styles.css ================================================ html[dir='ltr'], [data-sonner-toaster][dir='ltr'] { --toast-icon-margin-start: -3px; --toast-icon-margin-end: 4px; --toast-svg-margin-start: -1px; --toast-svg-margin-end: 0px; --toast-button-margin-start: auto; --toast-button-margin-end: 0; --toast-close-button-start: 0; --toast-close-button-end: unset; --toast-close-button-transform: translate(-35%, -35%); } html[dir='rtl'], [data-sonner-toaster][dir='rtl'] { --toast-icon-margin-start: 4px; --toast-icon-margin-end: -3px; --toast-svg-margin-start: 0px; --toast-svg-margin-end: -1px; --toast-button-margin-start: 0; --toast-button-margin-end: auto; --toast-close-button-start: unset; --toast-close-button-end: 0; --toast-close-button-transform: translate(35%, -35%); } [data-sonner-toaster] { position: fixed; width: var(--width); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; --gray1: hsl(0, 0%, 99%); --gray2: hsl(0, 0%, 97.3%); --gray3: hsl(0, 0%, 95.1%); --gray4: hsl(0, 0%, 93%); --gray5: hsl(0, 0%, 90.9%); --gray6: hsl(0, 0%, 88.7%); --gray7: hsl(0, 0%, 85.8%); --gray8: hsl(0, 0%, 78%); --gray9: hsl(0, 0%, 56.1%); --gray10: hsl(0, 0%, 52.3%); --gray11: hsl(0, 0%, 43.5%); --gray12: hsl(0, 0%, 9%); --border-radius: 8px; box-sizing: border-box; padding: 0; margin: 0; list-style: none; outline: none; z-index: 999999999; transition: transform 400ms ease; } @media (hover: none) and (pointer: coarse) { [data-sonner-toaster][data-lifted='true'] { transform: none; } } [data-sonner-toaster][data-x-position='right'] { right: var(--offset-right); } [data-sonner-toaster][data-x-position='left'] { left: var(--offset-left); } [data-sonner-toaster][data-x-position='center'] { left: 50%; transform: translateX(-50%); } [data-sonner-toaster][data-y-position='top'] { top: var(--offset-top); } [data-sonner-toaster][data-y-position='bottom'] { bottom: var(--offset-bottom); } [data-sonner-toast] { --y: translateY(100%); --lift-amount: calc(var(--lift) * var(--gap)); z-index: var(--z-index); position: absolute; opacity: 0; transform: var(--y); touch-action: none; transition: transform 400ms, opacity 400ms, height 400ms, box-shadow 200ms; box-sizing: border-box; outline: none; overflow-wrap: anywhere; } [data-sonner-toast][data-styled='true'] { padding: 16px; background: var(--normal-bg); border: 1px solid var(--normal-border); color: var(--normal-text); border-radius: var(--border-radius); box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); width: var(--width); font-size: 13px; display: flex; align-items: center; gap: 6px; } [data-sonner-toast]:focus-visible { box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2); } [data-sonner-toast][data-y-position='top'] { top: 0; --y: translateY(-100%); --lift: 1; --lift-amount: calc(1 * var(--gap)); } [data-sonner-toast][data-y-position='bottom'] { bottom: 0; --y: translateY(100%); --lift: -1; --lift-amount: calc(var(--lift) * var(--gap)); } [data-sonner-toast][data-styled='true'] [data-description] { font-weight: 400; line-height: 1.4; color: #3f3f3f; } [data-rich-colors='true'][data-sonner-toast][data-styled='true'] [data-description] { color: inherit; } [data-sonner-toaster][data-sonner-theme='dark'] [data-description] { color: hsl(0, 0%, 91%); } [data-sonner-toast][data-styled='true'] [data-title] { font-weight: 500; line-height: 1.5; color: inherit; } [data-sonner-toast][data-styled='true'] [data-icon] { display: flex; height: 16px; width: 16px; position: relative; justify-content: flex-start; align-items: center; flex-shrink: 0; margin-left: var(--toast-icon-margin-start); margin-right: var(--toast-icon-margin-end); } [data-sonner-toast][data-promise='true'] [data-icon] > svg { opacity: 0; transform: scale(0.8); transform-origin: center; animation: sonner-fade-in 300ms ease forwards; } [data-sonner-toast][data-styled='true'] [data-icon] > * { flex-shrink: 0; } [data-sonner-toast][data-styled='true'] [data-icon] svg { margin-left: var(--toast-svg-margin-start); margin-right: var(--toast-svg-margin-end); } [data-sonner-toast][data-styled='true'] [data-content] { display: flex; flex-direction: column; gap: 2px; } [data-sonner-toast][data-styled='true'] [data-button] { border-radius: 4px; padding-left: 8px; padding-right: 8px; height: 24px; font-size: 12px; color: var(--normal-bg); background: var(--normal-text); margin-left: var(--toast-button-margin-start); margin-right: var(--toast-button-margin-end); border: none; font-weight: 500; cursor: pointer; outline: none; display: flex; align-items: center; flex-shrink: 0; transition: opacity 400ms, box-shadow 200ms; } [data-sonner-toast][data-styled='true'] [data-button]:focus-visible { box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4); } [data-sonner-toast][data-styled='true'] [data-button]:first-of-type { margin-left: var(--toast-button-margin-start); margin-right: var(--toast-button-margin-end); } [data-sonner-toast][data-styled='true'] [data-cancel] { color: var(--normal-text); background: rgba(0, 0, 0, 0.08); } [data-sonner-toaster][data-sonner-theme='dark'] [data-sonner-toast][data-styled='true'] [data-cancel] { background: rgba(255, 255, 255, 0.3); } [data-sonner-toast][data-styled='true'] [data-close-button] { position: absolute; left: var(--toast-close-button-start); right: var(--toast-close-button-end); top: 0; height: 20px; width: 20px; display: flex; justify-content: center; align-items: center; padding: 0; color: var(--normal-text); background: var(--normal-bg); border: 1px solid var(--normal-border); transform: var(--toast-close-button-transform); border-radius: 50%; cursor: pointer; z-index: 1; transition: opacity 100ms, background 200ms, border-color 200ms; } [data-sonner-toast][data-styled='true'] [data-close-button]:focus-visible { box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2); } [data-sonner-toast][data-styled='true'] [data-disabled='true'] { cursor: not-allowed; } [data-sonner-toast][data-styled='true']:hover [data-close-button]:hover { background: var(--gray2); border-color: var(--gray5); } [data-sonner-toast][data-swiping='true']::before { content: ''; position: absolute; left: -100%; right: -100%; height: 100%; z-index: -1; } [data-sonner-toast][data-y-position='top'][data-swiping='true']::before { bottom: 50%; transform: scaleY(3) translateY(50%); } [data-sonner-toast][data-y-position='bottom'][data-swiping='true']::before { top: 50%; transform: scaleY(3) translateY(-50%); } [data-sonner-toast][data-swiping='false'][data-removed='true']::before { content: ''; position: absolute; inset: 0; transform: scaleY(2); } [data-sonner-toast][data-expanded='true']::after { content: ''; position: absolute; left: 0; height: calc(var(--gap) + 1px); bottom: 100%; width: 100%; } [data-sonner-toast][data-mounted='true'] { --y: translateY(0); opacity: 1; } [data-sonner-toast][data-expanded='false'][data-front='false'] { --scale: var(--toasts-before) * 0.05 + 1; --y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale))); height: var(--front-toast-height); } [data-sonner-toast] > * { transition: opacity 400ms; } [data-sonner-toast][data-x-position='right'] { right: 0; } [data-sonner-toast][data-x-position='left'] { left: 0; } [data-sonner-toast][data-expanded='false'][data-front='false'][data-styled='true'] > * { opacity: 0; } [data-sonner-toast][data-visible='false'] { opacity: 0; pointer-events: none; } [data-sonner-toast][data-mounted='true'][data-expanded='true'] { --y: translateY(calc(var(--lift) * var(--offset))); height: var(--initial-height); } [data-sonner-toast][data-removed='true'][data-front='true'][data-swipe-out='false'] { --y: translateY(calc(var(--lift) * -100%)); opacity: 0; } [data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='true'] { --y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%)); opacity: 0; } [data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false'] { --y: translateY(40%); opacity: 0; transition: transform 500ms, opacity 200ms; } [data-sonner-toast][data-removed='true'][data-front='false']::before { height: calc(var(--initial-height) + 20%); } [data-sonner-toast][data-swiping='true'] { transform: var(--y) translateY(var(--swipe-amount-y, 0px)) translateX(var(--swipe-amount-x, 0px)); transition: none; } [data-sonner-toast][data-swiped='true'] { -webkit-user-select: none; /* Safari 3+ */ user-select: none; } [data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'], [data-sonner-toast][data-swipe-out='true'][data-y-position='top'] { animation-duration: 200ms; animation-timing-function: ease-out; animation-fill-mode: forwards; } [data-sonner-toast][data-swipe-out='true'][data-swipe-direction='left'] { animation-name: swipe-out-left; } [data-sonner-toast][data-swipe-out='true'][data-swipe-direction='right'] { animation-name: swipe-out-right; } [data-sonner-toast][data-swipe-out='true'][data-swipe-direction='up'] { animation-name: swipe-out-up; } [data-sonner-toast][data-swipe-out='true'][data-swipe-direction='down'] { animation-name: swipe-out-down; } @keyframes swipe-out-left { from { transform: var(--y) translateX(var(--swipe-amount-x)); opacity: 1; } to { transform: var(--y) translateX(calc(var(--swipe-amount-x) - 100%)); opacity: 0; } } @keyframes swipe-out-right { from { transform: var(--y) translateX(var(--swipe-amount-x)); opacity: 1; } to { transform: var(--y) translateX(calc(var(--swipe-amount-x) + 100%)); opacity: 0; } } @keyframes swipe-out-up { from { transform: var(--y) translateY(var(--swipe-amount-y)); opacity: 1; } to { transform: var(--y) translateY(calc(var(--swipe-amount-y) - 100%)); opacity: 0; } } @keyframes swipe-out-down { from { transform: var(--y) translateY(var(--swipe-amount-y)); opacity: 1; } to { transform: var(--y) translateY(calc(var(--swipe-amount-y) + 100%)); opacity: 0; } } @media (max-width: 600px) { [data-sonner-toaster] { position: fixed; right: var(--mobile-offset-right); left: var(--mobile-offset-left); width: 100%; } [data-sonner-toaster][dir='rtl'] { left: calc(var(--mobile-offset-left) * -1); } [data-sonner-toaster] [data-sonner-toast] { left: 0; right: 0; width: calc(100% - var(--mobile-offset-left) * 2); } [data-sonner-toaster][data-x-position='left'] { left: var(--mobile-offset-left); } [data-sonner-toaster][data-y-position='bottom'] { bottom: var(--mobile-offset-bottom); } [data-sonner-toaster][data-y-position='top'] { top: var(--mobile-offset-top); } [data-sonner-toaster][data-x-position='center'] { left: var(--mobile-offset-left); right: var(--mobile-offset-right); transform: none; } } [data-sonner-toaster][data-sonner-theme='light'] { --normal-bg: #fff; --normal-border: var(--gray4); --normal-text: var(--gray12); --success-bg: hsl(143, 85%, 96%); --success-border: hsl(145, 92%, 87%); --success-text: hsl(140, 100%, 27%); --info-bg: hsl(208, 100%, 97%); --info-border: hsl(221, 91%, 93%); --info-text: hsl(210, 92%, 45%); --warning-bg: hsl(49, 100%, 97%); --warning-border: hsl(49, 91%, 84%); --warning-text: hsl(31, 92%, 45%); --error-bg: hsl(359, 100%, 97%); --error-border: hsl(359, 100%, 94%); --error-text: hsl(360, 100%, 45%); } [data-sonner-toaster][data-sonner-theme='light'] [data-sonner-toast][data-invert='true'] { --normal-bg: #000; --normal-border: hsl(0, 0%, 20%); --normal-text: var(--gray1); } [data-sonner-toaster][data-sonner-theme='dark'] [data-sonner-toast][data-invert='true'] { --normal-bg: #fff; --normal-border: var(--gray3); --normal-text: var(--gray12); } [data-sonner-toaster][data-sonner-theme='dark'] { --normal-bg: #000; --normal-bg-hover: hsl(0, 0%, 12%); --normal-border: hsl(0, 0%, 20%); --normal-border-hover: hsl(0, 0%, 25%); --normal-text: var(--gray1); --success-bg: hsl(150, 100%, 6%); --success-border: hsl(147, 100%, 12%); --success-text: hsl(150, 86%, 65%); --info-bg: hsl(215, 100%, 6%); --info-border: hsl(223, 43%, 17%); --info-text: hsl(216, 87%, 65%); --warning-bg: hsl(64, 100%, 6%); --warning-border: hsl(60, 100%, 9%); --warning-text: hsl(46, 87%, 65%); --error-bg: hsl(358, 76%, 10%); --error-border: hsl(357, 89%, 16%); --error-text: hsl(358, 100%, 81%); } [data-sonner-toaster][data-sonner-theme='dark'] [data-sonner-toast] [data-close-button] { background: var(--normal-bg); border-color: var(--normal-border); color: var(--normal-text); } [data-sonner-toaster][data-sonner-theme='dark'] [data-sonner-toast] [data-close-button]:hover { background: var(--normal-bg-hover); border-color: var(--normal-border-hover); } [data-rich-colors='true'][data-sonner-toast][data-type='success'] { background: var(--success-bg); border-color: var(--success-border); color: var(--success-text); } [data-rich-colors='true'][data-sonner-toast][data-type='success'] [data-close-button] { background: var(--success-bg); border-color: var(--success-border); color: var(--success-text); } [data-rich-colors='true'][data-sonner-toast][data-type='info'] { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); } [data-rich-colors='true'][data-sonner-toast][data-type='info'] [data-close-button] { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); } [data-rich-colors='true'][data-sonner-toast][data-type='warning'] { background: var(--warning-bg); border-color: var(--warning-border); color: var(--warning-text); } [data-rich-colors='true'][data-sonner-toast][data-type='warning'] [data-close-button] { background: var(--warning-bg); border-color: var(--warning-border); color: var(--warning-text); } [data-rich-colors='true'][data-sonner-toast][data-type='error'] { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); } [data-rich-colors='true'][data-sonner-toast][data-type='error'] [data-close-button] { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); } .sonner-loading-wrapper { --size: 16px; height: var(--size); width: var(--size); position: absolute; inset: 0; z-index: 10; } .sonner-loading-wrapper[data-visible='false'] { transform-origin: center; animation: sonner-fade-out 0.2s ease forwards; } .sonner-spinner { position: relative; top: 50%; left: 50%; height: var(--size); width: var(--size); } .sonner-loading-bar { animation: sonner-spin 1.2s linear infinite; background: var(--gray11); border-radius: 6px; height: 8%; left: -10%; position: absolute; top: -3.9%; width: 24%; } .sonner-loading-bar:nth-child(1) { animation-delay: -1.2s; transform: rotate(0.0001deg) translate(146%); } .sonner-loading-bar:nth-child(2) { animation-delay: -1.1s; transform: rotate(30deg) translate(146%); } .sonner-loading-bar:nth-child(3) { animation-delay: -1s; transform: rotate(60deg) translate(146%); } .sonner-loading-bar:nth-child(4) { animation-delay: -0.9s; transform: rotate(90deg) translate(146%); } .sonner-loading-bar:nth-child(5) { animation-delay: -0.8s; transform: rotate(120deg) translate(146%); } .sonner-loading-bar:nth-child(6) { animation-delay: -0.7s; transform: rotate(150deg) translate(146%); } .sonner-loading-bar:nth-child(7) { animation-delay: -0.6s; transform: rotate(180deg) translate(146%); } .sonner-loading-bar:nth-child(8) { animation-delay: -0.5s; transform: rotate(210deg) translate(146%); } .sonner-loading-bar:nth-child(9) { animation-delay: -0.4s; transform: rotate(240deg) translate(146%); } .sonner-loading-bar:nth-child(10) { animation-delay: -0.3s; transform: rotate(270deg) translate(146%); } .sonner-loading-bar:nth-child(11) { animation-delay: -0.2s; transform: rotate(300deg) translate(146%); } .sonner-loading-bar:nth-child(12) { animation-delay: -0.1s; transform: rotate(330deg) translate(146%); } @keyframes sonner-fade-in { 0% { opacity: 0; transform: scale(0.8); } 100% { opacity: 1; transform: scale(1); } } @keyframes sonner-fade-out { 0% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(0.8); } } @keyframes sonner-spin { 0% { opacity: 1; } 100% { opacity: 0.15; } } @media (prefers-reduced-motion) { [data-sonner-toast], [data-sonner-toast] > *, .sonner-loading-bar { transition: none !important; animation: none !important; } } .sonner-loader { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); transform-origin: center; transition: opacity 200ms, transform 200ms; } .sonner-loader[data-visible='false'] { opacity: 0; transform: scale(0.8) translate(-50%, -50%); } ================================================ FILE: src/types.ts ================================================ import React from 'react'; export type ToastTypes = 'normal' | 'action' | 'success' | 'info' | 'warning' | 'error' | 'loading' | 'default'; export type PromiseT = Promise | (() => Promise); export interface PromiseIExtendedResult extends ExternalToast { message: React.ReactNode; } export type PromiseTExtendedResult = | PromiseIExtendedResult | ((data: Data) => PromiseIExtendedResult | Promise); export type PromiseTResult = | string | React.ReactNode | ((data: Data) => React.ReactNode | string | Promise); export type PromiseExternalToast = Omit; export type PromiseData = PromiseExternalToast & { loading?: string | React.ReactNode; success?: PromiseTResult | PromiseTExtendedResult; error?: PromiseTResult | PromiseTExtendedResult; description?: PromiseTResult; finally?: () => void | Promise; }; export interface ToastClassnames { toast?: string; title?: string; description?: string; loader?: string; closeButton?: string; cancelButton?: string; actionButton?: string; success?: string; error?: string; info?: string; warning?: string; loading?: string; default?: string; content?: string; icon?: string; } export interface ToastIcons { success?: React.ReactNode; info?: React.ReactNode; warning?: React.ReactNode; error?: React.ReactNode; loading?: React.ReactNode; close?: React.ReactNode; } export interface Action { label: React.ReactNode; onClick: (event: React.MouseEvent) => void; actionButtonStyle?: React.CSSProperties; } export interface ToastT { id: number | string; toasterId?: string; title?: (() => React.ReactNode) | React.ReactNode; type?: ToastTypes; icon?: React.ReactNode; jsx?: React.ReactNode; richColors?: boolean; invert?: boolean; closeButton?: boolean; dismissible?: boolean; description?: (() => React.ReactNode) | React.ReactNode; duration?: number; delete?: boolean; action?: Action | React.ReactNode; cancel?: Action | React.ReactNode; onDismiss?: (toast: ToastT) => void; onAutoClose?: (toast: ToastT) => void; promise?: PromiseT; cancelButtonStyle?: React.CSSProperties; actionButtonStyle?: React.CSSProperties; style?: React.CSSProperties; unstyled?: boolean; className?: string; classNames?: ToastClassnames; descriptionClassName?: string; position?: Position; testId?: string; } export function isAction(action: Action | React.ReactNode): action is Action { return (action as Action).label !== undefined; } export type Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center'; export interface HeightT { height: number; toastId: number | string; position: Position; } interface ToastOptions { className?: string; closeButton?: boolean; descriptionClassName?: string; style?: React.CSSProperties; cancelButtonStyle?: React.CSSProperties; actionButtonStyle?: React.CSSProperties; duration?: number; unstyled?: boolean; classNames?: ToastClassnames; closeButtonAriaLabel?: string; toasterId?: string; } type Offset = | { top?: string | number; right?: string | number; bottom?: string | number; left?: string | number; } | string | number; export interface ToasterProps { id?: string; invert?: boolean; theme?: 'light' | 'dark' | 'system'; position?: Position; hotkey?: string[]; richColors?: boolean; expand?: boolean; duration?: number; gap?: number; visibleToasts?: number; closeButton?: boolean; toastOptions?: ToastOptions; className?: string; style?: React.CSSProperties; offset?: Offset; mobileOffset?: Offset; dir?: 'rtl' | 'ltr' | 'auto'; swipeDirections?: SwipeDirection[]; icons?: ToastIcons; customAriaLabel?: string; containerAriaLabel?: string; } export type SwipeDirection = 'top' | 'right' | 'bottom' | 'left'; export interface ToastProps { toast: ToastT; toasts: ToastT[]; index: number; swipeDirections?: SwipeDirection[]; expanded: boolean; invert: boolean; heights: HeightT[]; setHeights: React.Dispatch>; removeToast: (toast: ToastT) => void; gap?: number; position: Position; visibleToasts: number; expandByDefault: boolean; closeButton: boolean; interacting: boolean; style?: React.CSSProperties; cancelButtonStyle?: React.CSSProperties; actionButtonStyle?: React.CSSProperties; duration?: number; className?: string; unstyled?: boolean; descriptionClassName?: string; loadingIcon?: React.ReactNode; classNames?: ToastClassnames; icons?: ToastIcons; closeButtonAriaLabel?: string; defaultRichColors?: boolean; } export enum SwipeStateTypes { SwipedOut = 'SwipedOut', SwipedBack = 'SwipedBack', NotSwiped = 'NotSwiped', } export type Theme = 'light' | 'dark'; export interface ToastToDismiss { id: number | string; dismiss: boolean; } export type ExternalToast = Omit & { id?: number | string; toasterId?: string; }; ================================================ FILE: test/.eslintrc.json ================================================ { "extends": "next/core-web-vitals" } ================================================ FILE: test/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts /test-results/ /playwright-report/ /playwright/.cache/ ================================================ FILE: test/.npmrc ================================================ package-manager-strict=false ================================================ FILE: test/.vscode/settings.json ================================================ { "typescript.tsdk": "../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true } ================================================ FILE: test/README.md ================================================ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev # or pnpm dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. ================================================ FILE: test/next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = {}; module.exports = nextConfig; ================================================ FILE: test/package.json ================================================ { "name": "test", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { "@types/node": "18.15.0", "@types/react": "18.0.28", "@types/react-dom": "18.0.11", "ai": "^3.4.9", "eslint": "8.35.0", "eslint-config-next": "13.2.4", "next": "14.2.15", "react": "18.3.1", "react-dom": "18.3.1", "sonner": "workspace:*", "typescript": "4.9.5" } } ================================================ FILE: test/src/app/action.tsx ================================================ 'use server'; import { createStreamableUI } from 'ai/rsc'; export async function action() { 'use server'; let progress = 0; const ui = createStreamableUI('loading 0%'); const interval = setInterval(() => { progress += 10; ui.update('loading ' + progress + '%'); if (progress >= 100) { clearInterval(interval); ui.update('load complete'); } }, 100); return ui.value; } ================================================ FILE: test/src/app/layout.tsx ================================================ export const metadata = { title: 'Create Next App', description: 'Generated by create next app', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ================================================ FILE: test/src/app/page.tsx ================================================ 'use client'; import React from 'react'; import { Toaster, toast } from 'sonner'; import { action } from '@/app/action'; const promise = () => new Promise((resolve) => setTimeout(resolve, 2000)); export default function Home({ searchParams }: any) { const [showAutoClose, setShowAutoClose] = React.useState(false); const [showDismiss, setShowDismiss] = React.useState(false); const [theme, setTheme] = React.useState(searchParams.theme || 'light'); const [isFinally, setIsFinally] = React.useState(false); const [showAriaLabels, setShowAriaLabels] = React.useState(false); return ( <>
    )) } > Render Custom Toast ), { id: undefined, }, ) } > Render Custom Toast with empty id {showAutoClose ?
    : null} {showDismiss ?
    : null} ) : undefined, }} /> ); } Home.theme = 'light'; ================================================ FILE: test/tests/basic.spec.ts ================================================ import { expect, test } from '@playwright/test'; import { toast } from 'sonner'; test.beforeEach(async ({ page }) => { await page.goto('/'); }); test.describe('Basic functionality', () => { test('toast is rendered and disappears after the default timeout', async ({ page }) => { await page.getByTestId('default-button').click(); await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); }); test('various toast types are rendered correctly', async ({ page }) => { await page.getByTestId('success').click(); await expect(page.getByText('My Success Toast', { exact: true })).toHaveCount(1); await page.getByTestId('error').click(); await expect(page.getByText('My Error Toast', { exact: true })).toHaveCount(1); await page.getByTestId('action').click(); await expect(page.locator('[data-button]')).toHaveCount(1); }); test('show correct toast content based on promise state', async ({ page }) => { await page.getByTestId('promise').click(); await expect(page.getByText('Loading...')).toHaveCount(1); await expect(page.getByText('Loaded')).toHaveCount(1); }); test('handle toast promise rejections', async ({ page }) => { const rejectedPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Promise rejected')), 100)); try { toast.promise(rejectedPromise, {}); } catch { throw new Error('Promise should not have rejected without unwrap'); } await expect(toast.promise(rejectedPromise, {}).unwrap()).rejects.toThrow('Promise rejected'); }); test('promise toast with extended configuration', async ({ page }) => { await page.getByTestId('extended-promise').click(); // Check loading state await expect(page.getByText('Loading...')).toHaveCount(1); // Check success state with custom message and description await expect(page.getByText('Sonner toast has been added')).toHaveCount(1); await expect(page.getByText('Custom description for the Success state')).toHaveCount(1); // Verify global description is not shown (overridden by success description) await expect(page.getByText('Global description')).toHaveCount(0); }); test('promise toast with extended error configuration', async ({ page }) => { await page.getByTestId('extended-promise-error').click(); // Check loading state await expect(page.getByText('Loading...')).toHaveCount(1); // Check error state await expect(page.getByText('An error occurred')).toHaveCount(1); // Verify action button is present const actionButton = page.getByText('Retry'); await expect(actionButton).toHaveCount(1); // Click retry button and verify it doesn't close the toast (due to preventDefault) await actionButton.click(); await expect(page.getByText('An error occurred')).toHaveCount(1); }); test('promise toast with Error object rejection', async ({ page }) => { await page.getByTestId('error-promise').click(); // Check error state shows the error message correctly await expect(page.getByText('Error Raise: Error: Not implemented')).toHaveCount(1); }); test('render custom jsx in toast', async ({ page }) => { await page.getByTestId('custom').click(); await expect(page.getByText('jsx')).toHaveCount(1); }); test('toast is removed after swiping down', async ({ page }) => { await page.getByTestId('default-button').click(); await page.hover('[data-sonner-toast]'); await page.mouse.down(); await page.mouse.move(0, 800); await page.mouse.up(); await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); }); test('dismissible toast is not removed when dragged', async ({ page }) => { await page.getByTestId('non-dismissible-toast').click(); const toast = page.locator('[data-sonner-toast]'); const dragBoundingBox = await toast.boundingBox(); if (!dragBoundingBox) return; await page.mouse.move(dragBoundingBox.x + dragBoundingBox.width / 2, dragBoundingBox.y); await page.mouse.down(); await page.mouse.move(0, dragBoundingBox.y + 300); await page.mouse.up(); await expect(page.getByTestId('non-dismissible-toast')).toHaveCount(1); }); test('toast is removed after swiping up', async ({ page }) => { await page.goto('/?position=top-left'); await page.getByTestId('default-button').click(); await page.hover('[data-sonner-toast]'); await page.mouse.down(); await page.mouse.move(0, -800); await page.mouse.up(); await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); }); test('toast is not removed when hovered', async ({ page }) => { await page.getByTestId('default-button').click(); // Wait for toast to be visible first await expect(page.locator('[data-sonner-toast]')).toBeVisible(); // Hover the toast await page.hover('[data-sonner-toast]'); // Wait a bit to ensure hover is registered await page.waitForTimeout(100); // Create a longer timeout to verify toast persists await page.waitForTimeout(5000); // Verify toast is still visible await expect(page.locator('[data-sonner-toast]')).toBeVisible(); await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); }); test('toast is not removed if duration is set to infinity', async ({ page }) => { await page.getByTestId('infinity-toast').click(); await expect(page.locator('[data-sonner-toast]')).toBeVisible(); const toast = page.locator('[data-sonner-toast]'); await toast.hover({ force: true }); await page.waitForTimeout(100); await page.waitForTimeout(5000); await expect(toast).toBeVisible(); await expect(toast).toHaveCount(1); }); test('toast is not removed when event prevented in action', async ({ page }) => { await page.getByTestId('action-prevent').click(); await page.locator('[data-button]').click(); await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); }); test("toast's auto close callback gets executed correctly", async ({ page }) => { await page.getByTestId('auto-close-toast-callback').click(); await expect(page.getByTestId('auto-close-el')).toHaveCount(1); }); test("toast's dismiss callback gets executed correctly", async ({ page }) => { await page.getByTestId('dismiss-toast-callback').click(); const toast = page.locator('[data-sonner-toast]'); await toast.waitFor({ state: 'visible' }); const box = await toast.boundingBox(); if (!box) return; const startX = box.x + box.width / 2; const startY = box.y + box.height / 2; await page.mouse.move(startX, startY); await page.mouse.down(); // Small initial movement to trigger drag await page.mouse.move(startX, startY + 20, { steps: 5 }); // Main swipe movement await page.mouse.move(startX, startY + 300, { steps: 10 }); await page.mouse.up(); await expect(page.getByTestId('dismiss-el')).toHaveCount(1); }); test("toaster's theme should be light", async ({ page }) => { await page.getByTestId('infinity-toast').click(); await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('data-sonner-theme', 'light'); }); test("toaster's theme should be dark", async ({ page }) => { await page.goto('/?theme=dark'); await page.getByTestId('infinity-toast').click(); await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('data-sonner-theme', 'dark'); }); test("toaster's theme should be changed", async ({ page }) => { await page.getByTestId('infinity-toast').click(); await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('data-sonner-theme', 'light'); await page.getByTestId('theme-button').click(); await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('data-sonner-theme', 'dark'); }); test('return focus to the previous focused element', async ({ page }) => { await page.getByTestId('custom').focus(); await page.keyboard.press('Enter'); await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); await page.getByTestId('dismiss-button').focus(); await page.keyboard.press('Enter'); await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); await expect(page.getByTestId('custom')).toBeFocused(); }); test("toaster's dir prop is reflected correctly", async ({ page }) => { await page.goto('/?dir=rtl'); await page.getByTestId('default-button').click(); await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('dir', 'rtl'); }); test("toaster respects the HTML's dir attribute", async ({ page }) => { await page.evaluate(() => { document.documentElement.setAttribute('dir', 'rtl'); }); await page.getByTestId('default-button').click(); await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('dir', 'rtl'); }); test("toaster respects its own dir attribute over HTML's", async ({ page }) => { await page.goto('/?dir=ltr'); await page.evaluate(() => { document.documentElement.setAttribute('dir', 'rtl'); }); await page.getByTestId('default-button').click(); await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('dir', 'ltr'); }); test('show correct toast content when updating', async ({ page }) => { await page.getByTestId('update-toast').click(); await expect(page.getByText('My Unupdated Toast')).toHaveCount(0); await expect(page.getByText('My Updated Toast')).toHaveCount(1); }); test('should update toast content and duration after 3 seconds', async ({ page }) => { await page.getByTestId('update-toast-duration').click(); const initialToast = page.getByText('My Unupdated Toast, Updated After 3 Seconds'); await expect(initialToast).toBeVisible(); await page.waitForTimeout(3000); const updatedToast = page.getByText('My Updated Toast, Close After 1 Second'); await expect(updatedToast).toBeVisible(); await expect(initialToast).not.toBeVisible(); await page.waitForTimeout(1200); await expect(updatedToast).not.toBeVisible(); }); test('cancel button is rendered with custom styles', async ({ page }) => { await page.getByTestId('custom-cancel-button-toast').click(); const button = await page.locator('[data-cancel]'); await expect(button).toHaveCSS('background-color', 'rgb(254, 226, 226)'); }); test('cancel button dismisses the custom toast with empty id', async ({ page }) => { await page.getByTestId('custom-with-empty-id').click(); await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); await page.locator('[data-dismiss]').click(); await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); }); test('action button is rendered with custom styles', async ({ page }) => { await page.getByTestId('action').click(); const button = await page.locator('[data-button]'); await expect(button).toHaveCSS('background-color', 'rgb(219, 239, 255)'); }); test('string description is rendered', async ({ page }) => { await page.getByTestId('string-description').click(); await expect(page.getByText('string description')).toHaveCount(1); }); test('ReactNode description is rendered', async ({ page }) => { await page.getByTestId('react-node-description').click(); await expect(page.getByText('This is my custom ReactNode description')).toHaveCount(1); }); test('aria labels are custom', async ({ page }) => { await page.getByRole('button', { name: 'With custom ARIA labels' }).click(); await expect(page.getByText('Toast with custom ARIA labels')).toHaveCount(1); await expect(page.getByLabel('Notices')).toHaveCount(1); await expect(page.getByLabel('Yeet the notice', { exact: true })).toHaveCount(1); }); test('toast with toasterId only appears in the correct Toaster', async ({ page }) => { await page.getByTestId('toast-secondary').click(); const secondaryToaster = page.locator('[data-sonner-toaster][data-x-position="left"][data-y-position="top"]'); await expect(secondaryToaster.getByText('Secondary Toaster Toast')).toHaveCount(1); const globalToaster = page.locator('[data-sonner-toaster][data-x-position="right"][data-y-position="bottom"]'); await expect(globalToaster.getByText('Secondary Toaster Toast')).toHaveCount(0); }); test('toast without toasterId only appears in the global Toaster', async ({ page }) => { await page.getByTestId('toast-global').click(); const globalToaster = page.locator('[data-sonner-toaster][data-x-position="right"][data-y-position="bottom"]'); await expect(globalToaster.getByText('Global Toaster Toast')).toHaveCount(1); const secondaryToaster = page.locator('[data-sonner-toaster][data-x-position="left"][data-y-position="top"]'); await expect(secondaryToaster.getByText('Global Toaster Toast')).toHaveCount(0); }); test('toast with testId renders data-testid attribute correctly', async ({ page }) => { await page.getByTestId('testid-toast-button').click(); await expect(page.getByTestId('my-test-toast')).toBeVisible(); await expect(page.getByTestId('my-test-toast')).toHaveText('Toast with test ID'); }); test('toast without testId does not have data-testid attribute', async ({ page }) => { await page.getByTestId('default-button').click(); const toast = page.locator('[data-sonner-toast]'); await expect(toast).toBeVisible(); await expect(toast).not.toHaveAttribute('data-testid'); }); test('promise toast with testId maintains testId through state changes', async ({ page }) => { await page.getByTestId('testid-promise-toast-button').click(); await expect(page.getByTestId('promise-test-toast')).toBeVisible(); await expect(page.getByTestId('promise-test-toast')).toHaveText('Loading...'); await expect(page.getByTestId('promise-test-toast')).toHaveText('Loaded'); }); }); ================================================ FILE: test/tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../playwright.config.ts"], "exclude": ["node_modules"] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "target": "ES2018", "moduleResolution": "node", "esModuleInterop": true, "lib": ["es2015", "dom"] }, "include": ["src"] } ================================================ FILE: turbo.json ================================================ { "$schema": "https://turbo.build/schema.json", "extends": ["//"], "pipeline": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] }, "dev": { "cache": false } } } ================================================ FILE: website/.eslintrc.json ================================================ { "extends": "next/core-web-vitals" } ================================================ FILE: website/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: website/.vscode/settings.json ================================================ { "typescript.tsdk": "../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true } ================================================ FILE: website/README.md ================================================ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev # or pnpm dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. ================================================ FILE: website/next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { experimental: { appDir: true, }, }; const withNextra = require('nextra')({ title: 'Sonner', theme: 'nextra-theme-docs', themeConfig: './theme.config.jsx', defaultShowCopyCode: true, }); module.exports = withNextra(nextConfig); ================================================ FILE: website/package.json ================================================ { "name": "website", "version": "0.1.0", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { "@types/node": "18.11.18", "@types/react": "18.2.0", "@types/react-dom": "18.0.10", "@vercel/analytics": "^1.1.0", "clsx": "^2.0.0", "copy-to-clipboard": "^3.3.3", "eslint-config-next": "^13.2.3", "framer-motion": "^9.0.1", "next": "13.4.19", "next-mdx-remote": "^4.3.0", "nextra": "^2.12.3", "nextra-theme-docs": "^2.12.3", "prism-react-renderer": "^1.3.5", "react": "^18.2.0", "react-dom": "18.2.0", "react-use-measure": "^2.1.1", "sonner": "workspace:*", "typescript": "4.9.5" }, "devDependencies": { "autoprefixer": "^10.4.15", "postcss": "^8.4.29", "tailwindcss": "^3.3.3" } } ================================================ FILE: website/postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: website/src/components/CodeBlock/code-block.module.css ================================================ .root { padding: 16px; margin: 0; background: var(--gray1); border-radius: 0; position: relative; line-height: 17px; white-space: pre-wrap; background: linear-gradient(to top, var(--gray2), var(--gray1) 16px); } .wrapper { overflow: hidden; margin: 0; position: relative; border-radius: 6px; margin-top: 16px; border: 1px solid var(--gray3); padding: 0 !important; } .copyButton { position: absolute; top: 12px; right: 12px; z-index: 1; width: 26px; height: 26px; border: 1px solid var(--gray4); border-radius: 6px; display: flex; align-items: center; justify-content: center; background: var(--gray0); cursor: pointer; opacity: 0; color: var(--gray12); transition: background 200ms, box-shadow 200ms, opacity 200ms; } .copyButton:focus-visible { opacity: 1; } .copyButton:hover { background: var(--gray1); } .copyButton:focus-visible { box-shadow: 0 0 0 1px var(--gray4); } .copyButton > div { display: flex; } .outerWrapper { position: relative; } .outerWrapper:hover .copyButton { opacity: 1; } ================================================ FILE: website/src/components/CodeBlock/index.tsx ================================================ import React from 'react'; import Highlight, { defaultProps } from 'prism-react-renderer'; import useMeasure from 'react-use-measure'; import copy from 'copy-to-clipboard'; import { AnimatePresence, motion, MotionConfig } from 'framer-motion'; import styles from './code-block.module.css'; const variants = { visible: { opacity: 1, scale: 1 }, hidden: { opacity: 0, scale: 0.5 }, }; const theme = { plain: { color: 'var(--gray12)', fontSize: 12, fontFamily: 'var(--font-mono)', }, styles: [ { types: ['comment'], style: { color: 'var(--gray9)', }, }, { types: ['atrule', 'keyword', 'attr-name', 'selector', 'string'], style: { color: 'var(--gray11)', }, }, { types: ['punctuation', 'operator'], style: { color: 'var(--gray9)', }, }, { types: ['class-name', 'function', 'tag'], style: { color: 'var(--gray12)', }, }, ], }; export const CodeBlock = ({ children, initialHeight = 0 }: { children: string; initialHeight?: number }) => { const [ref, bounds] = useMeasure(); const [copying, setCopying] = React.useState(0); const onCopy = React.useCallback(() => { copy(children); setCopying((c) => c + 1); setTimeout(() => { setCopying((c) => c - 1); }, 2000); }, [children]); return (
    {/* @ts-ignore */} {({ className, tokens, getLineProps, getTokenProps }) => (
    {tokens.map((line, i) => { const { key: lineKey, ...rest } = getLineProps({ line, key: i }); return (
    {line.map((token, key) => { const { key: tokenKey, ...rest } = getTokenProps({ token, key }); return ; })}
    ); })}
    )}
    ); }; ================================================ FILE: website/src/components/ExpandModes/index.tsx ================================================ import { toast } from 'sonner'; import { CodeBlock } from '../CodeBlock'; export const ExpandModes = ({ expand, setExpand, }: { expand: boolean; setExpand: React.Dispatch>; }) => { return (

    Expand

    You can change the amount of toasts visible through the visibleToasts prop.

    {``}
    ); }; ================================================ FILE: website/src/components/Footer/footer.module.css ================================================ .wrapper { padding: 32px 0; border-top: 1px solid var(--gray3); background: var(--gray1); margin-top: 164px; } .p { display: flex; align-items: center; gap: 12px; margin: 0; font-size: 14px; } .p img { border-radius: 50%; } .p a { font-weight: 600; color: inherit; text-decoration: none; } .p a:hover { text-decoration: underline; } @media (max-width: 600px) { .wrapper { margin-top: 128px; padding: 16px 0; } } ================================================ FILE: website/src/components/Footer/index.tsx ================================================ import Image from 'next/image'; import emil from 'public/emil.jpeg'; import styles from './footer.module.css'; export const Footer = () => { return ( ); }; ================================================ FILE: website/src/components/Head/index.tsx ================================================ import NextHead from 'next/head'; const ogImage = 'https://sonner.emilkowal.ski/og.png'; const Head = () => ( {/* Title */} Sonner {/* Description */} {/* Image */} {/* URL */} {/* General */} {/* Favicons */} ); export default Head; ================================================ FILE: website/src/components/Hero/hero.module.css ================================================ .wrapper { display: flex; flex-direction: column; gap: 12px; align-items: center; } .toastWrapper { display: flex; flex-direction: column; margin: 0 auto; height: 100px; width: 400px; position: relative; mask-image: linear-gradient(to top, transparent 0%, black 35%); opacity: 1; } .toast { width: 356px; height: 40px; background: var(--gray0); box-shadow: 0 4px 12px #0000001a; border: 1px solid var(--gray3); border-radius: 6px; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); } .toast:nth-child(1) { transform: translateY(-60%) translateX(-50%) scale(0.9); } .toast:nth-child(2) { transform: translateY(-30%) translateX(-50%) scale(0.95); } .buttons { display: flex; gap: 8px; } .button { height: 40px; border-radius: 6px; border: none; background: linear-gradient(156deg, rgba(255, 255, 255, 1) 0%, rgba(240, 240, 240, 1) 100%); padding: 0 30px; font-weight: 600; flex-shrink: 0; font-family: inherit; box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08), 0px 2px 2px 0px rgba(0, 0, 0, 0.04), 0px 3px 3px 0px rgba(0, 0, 0, 0.02), 0px 4px 4px 0px rgba(0, 0, 0, 0.01); position: relative; overflow: hidden; cursor: pointer; text-decoration: none; color: hsl(0, 0%, 9%); font-size: 13px; display: inline-flex; align-items: center; justify-content: center; transition: box-shadow 200ms, background 200ms; width: 152px; } .button[data-primary] { box-shadow: 0px 0px 0px 1px var(--gray12); background: var(--gray12); color: var(--gray1); } .button:focus-visible { outline: none; box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08), 0px 2px 2px 0px rgba(0, 0, 0, 0.04), 0px 3px 3px 0px rgba(0, 0, 0, 0.02), 0px 4px 4px 0px rgba(0, 0, 0, 0.01), 0 0 0 2px rgba(0, 0, 0, 0.15); } .button:after { content: ''; position: absolute; top: 100%; background: blue; left: 0; width: 100%; height: 35%; background: linear-gradient( to top, hsl(0, 0%, 91%) 0%, hsla(0, 0%, 91%, 0.987) 8.1%, hsla(0, 0%, 91%, 0.951) 15.5%, hsla(0, 0%, 91%, 0.896) 22.5%, hsla(0, 0%, 91%, 0.825) 29%, hsla(0, 0%, 91%, 0.741) 35.3%, hsla(0, 0%, 91%, 0.648) 41.2%, hsla(0, 0%, 91%, 0.55) 47.1%, hsla(0, 0%, 91%, 0.45) 52.9%, hsla(0, 0%, 91%, 0.352) 58.8%, hsla(0, 0%, 91%, 0.259) 64.7%, hsla(0, 0%, 91%, 0.175) 71%, hsla(0, 0%, 91%, 0.104) 77.5%, hsla(0, 0%, 91%, 0.049) 84.5%, hsla(0, 0%, 91%, 0.013) 91.9%, hsla(0, 0%, 91%, 0) 100% ); opacity: 0.6; transition: transform 200ms; } .button:hover:not([data-primary]):after { transform: translateY(-100%); } .button[data-primary]:hover { background: var(--hover); } .heading { font-size: 48px; font-weight: 700; margin: -20px 0 12px; } .wrapper p { margin-bottom: 12px; } @media (max-width: 600px) { .toastWrapper { width: 100%; } } .link { color: var(--gray11) !important; font-size: 14px; text-decoration: underline; } ================================================ FILE: website/src/components/Hero/index.tsx ================================================ import { toast } from 'sonner'; import styles from './hero.module.css'; import Link from 'next/link'; export const Hero = () => { return (

    Sonner

    An opinionated toast component for React.

    GitHub
    Documentation
    ); }; ================================================ FILE: website/src/components/How/How.tsx ================================================ import React from 'react'; export const How = () => { return ( <>

    Want to learn how to make components like this one?

    I created an animations course in which I share everything I know about motion on the web. You can check it out{' '} here .

    ); }; ================================================ FILE: website/src/components/Installation/index.tsx ================================================ 'use client'; import React from 'react'; import copy from 'copy-to-clipboard'; import { motion, AnimatePresence, MotionConfig } from 'framer-motion'; import styles from './installation.module.css'; const variants = { visible: { opacity: 1, scale: 1 }, hidden: { opacity: 0, scale: 0.5 }, }; export const Installation = () => { const [copying, setCopying] = React.useState(0); const onCopy = React.useCallback(() => { copy('npm install sonner'); setCopying((c) => c + 1); setTimeout(() => { setCopying((c) => c - 1); }, 2000); }, []); return (

    Installation

    npm install sonner{' '}
    ); }; ================================================ FILE: website/src/components/Installation/installation.module.css ================================================ .code { padding: 0 62px 0 12px; border-radius: 6px; background: linear-gradient(to top, var(--gray2), var(--gray1) 8px); font-family: var(--font-mono); font-size: 14px; position: relative; cursor: copy; height: 40px; border: 1px solid var(--gray3); display: flex; align-items: center; color: var(--gray12); } .copy { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); cursor: pointer; border-radius: 50%; border: none; border: 1px solid var(--gray4); background: #fff; color: var(--gray12); border-radius: 5px; width: 26px; height: 26px; display: flex; justify-content: center; align-items: center; } .copy div { display: flex; } ================================================ FILE: website/src/components/Other/Other.tsx ================================================ import React from 'react'; import { useMemo } from 'react'; import { toast } from 'sonner'; import { CodeBlock } from '../CodeBlock'; import styles from './other.module.css'; export const Other = ({ setRichColors, setCloseButton, }: { setRichColors: React.Dispatch>; setCloseButton: React.Dispatch>; }) => { const allTypes = useMemo( () => [ { name: 'Rich Colors Success', snippet: `toast.success('Event has been created')`, action: () => { toast.success('Event has been created'); setRichColors(true); }, }, { name: 'Rich Colors Error', snippet: `toast.error('Event has not been created')`, action: () => { toast.error('Event has not been created'); setRichColors(true); }, }, { name: 'Rich Colors Info', snippet: `toast.info('Be at the area 10 minutes before the event time')`, action: () => { toast.info('Be at the area 10 minutes before the event time'); setRichColors(true); }, }, { name: 'Rich Colors Warning', snippet: `toast.warning('Event start time cannot be earlier than 8am')`, action: () => { toast.warning('Event start time cannot be earlier than 8am'); setRichColors(true); }, }, { name: 'Close Button', snippet: `toast('Event has been created', { description: 'Monday, January 3rd at 6:00pm', })`, action: () => { toast('Event has been created', { description: 'Monday, January 3rd at 6:00pm', }); setCloseButton((t) => !t); }, }, { name: 'Headless', snippet: `toast.custom((t) => (

    Custom toast

    ));`, action: () => { toast.custom( (t) => (

    Event Created

    Today at 4:00pm - "Louvre Museum"

    ), { duration: 999999 }, ); setCloseButton((t) => !t); }, }, ], [setRichColors], ); const [activeType, setActiveType] = React.useState(allTypes[0]); const richColorsActive = activeType?.name?.includes('Rich'); const closeButtonActive = activeType?.name?.includes('Close'); return (

    Other

    {allTypes.map((type) => ( ))}
    {`${activeType.snippet || ''} // ... `}
    ); }; ================================================ FILE: website/src/components/Other/other.module.css ================================================ ol[dir='ltr'] .headlessClose { --headless-close-start: unset; --headless-close-end: 6px; } ol[dir='rtl'] .headlessClose { --headless-close-start: 6px; --headless-close-end: unset; } .headless { padding: 16px; width: 356px; box-sizing: border-box; border-radius: 8px; background: var(--gray1); border: 1px solid var(--gray4); position: relative; } .headless .headlessDescription { margin: 0; color: var(--gray10); font-size: 14px; line-height: 1; } .headless .headlessTitle { font-size: 14px; margin: 0 0 8px; color: var(--gray12); font-weight: 500; line-height: 1; } .headlessClose { position: absolute; cursor: pointer; top: 6px; height: 24px; width: 24px; display: flex; justify-content: center; align-items: center; left: var(--headless-close-start); right: var(--headless-close-end); color: var(--gray10); padding: 0; background: transparent; border: none; transition: color 200ms; } .headlessClose:hover { color: var(--gray12); } ================================================ FILE: website/src/components/Position/index.tsx ================================================ import { toast, useSonner } from 'sonner'; import { CodeBlock } from '../CodeBlock'; import React from 'react'; const positions = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'] as const; export type Position = (typeof positions)[number]; export const Position = ({ position: activePosition, setPosition, }: { position: Position; setPosition: React.Dispatch>; }) => { const { toasts } = useSonner(); function removeAllToasts() { toasts.forEach((t) => toast.dismiss(t.id)); } return (

    Position

    Swipe direction changes depending on the position.

    {positions.map((position) => ( ))}
    {``}
    ); }; ================================================ FILE: website/src/components/Types/Types.tsx ================================================ import React from 'react'; import { toast } from 'sonner'; import { CodeBlock } from '../CodeBlock'; const promiseCode = '`${data.name} toast has been added`'; export const Types = () => { const [activeType, setActiveType] = React.useState(allTypes[0]); return (

    Types

    You can customize the type of toast you want to render, and pass an options object as the second argument.

    {allTypes.map((type) => ( ))}
    {`${activeType.snippet}`}
    ); }; const allTypes = [ { name: 'Default', snippet: `toast('Event has been created')`, action: () => toast('Event has been created'), }, { name: 'Description', snippet: `toast.message('Event has been created', { description: 'Monday, January 3rd at 6:00pm', })`, action: () => toast('Event has been created', { description: 'Monday, January 3rd at 6:00pm', }), }, { name: 'Success', snippet: `toast.success('Event has been created')`, action: () => toast.success('Event has been created'), }, { name: 'Info', snippet: `toast.info('Be at the area 10 minutes before the event time')`, action: () => toast.info('Be at the area 10 minutes before the event time'), }, { name: 'Warning', snippet: `toast.warning('Event start time cannot be earlier than 8am')`, action: () => toast.warning('Event start time cannot be earlier than 8am'), }, { name: 'Error', snippet: `toast.error('Event has not been created')`, action: () => toast.error('Event has not been created'), }, { name: 'Action', snippet: `toast('Event has been created', { action: { label: 'Undo', onClick: () => console.log('Undo') }, })`, action: () => toast.message('Event has been created', { action: { label: 'Undo', onClick: () => console.log('Undo'), }, }), }, { name: 'Promise', snippet: `const promise = () => new Promise((resolve) => setTimeout(() => resolve({ name: 'Sonner' }), 2000)); toast.promise(promise, { loading: 'Loading...', success: (data) => { return ${promiseCode}; }, error: 'Error', });`, action: () => toast.promise<{ name: string }>( () => new Promise((resolve) => { setTimeout(() => { resolve({ name: 'Sonner' }); }, 2000); }), { loading: 'Loading...', success: (data) => { return `${data.name} toast has been added`; }, error: 'Error', }, ), }, { name: 'Custom', snippet: `toast(
    A custom toast with default styling
    )`, action: () => toast(
    A custom toast with default styling
    , { duration: 1000000 }), }, ]; ================================================ FILE: website/src/components/Usage/index.tsx ================================================ import { CodeBlock } from '../CodeBlock'; export const Usage = () => { return (

    Usage

    Render the toaster in the root of your app.

    {`import { Toaster, toast } from 'sonner' // ... function App() { return (
    ) }`}
    ); }; ================================================ FILE: website/src/globals.css ================================================ :root, .light { --gray0: #fff; --gray1: hsl(0, 0%, 99%); --gray2: hsl(0, 0%, 97.3%); --gray3: hsl(0, 0%, 95.1%); --gray4: hsl(0, 0%, 93%); --gray5: hsl(0, 0%, 90.9%); --gray6: hsl(0, 0%, 88.7%); --gray7: hsl(0, 0%, 85.8%); --gray8: hsl(0, 0%, 78%); --gray9: hsl(0, 0%, 56.1%); --gray10: hsl(0, 0%, 52.3%); --gray11: hsl(0, 0%, 43.5%); --gray12: hsl(0, 0%, 9%); --hover: rgb(40, 40, 40); --border-radius: 6px; --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; --font-mono: 'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; } .dark { --gray0: #000; --gray1: hsl(0, 0%, 9.5%); --gray2: hsl(0, 0%, 10.5%); --gray3: hsl(0, 0%, 15.8%); --gray4: hsl(0, 0%, 18.9%); --gray5: hsl(0, 0%, 21.7%); --gray6: hsl(0, 0%, 24.7%); --gray7: hsl(0, 0%, 29.1%); --gray8: hsl(0, 0%, 37.5%); --gray9: hsl(0, 0%, 43%); --gray10: hsl(0, 0%, 50.7%); --gray11: hsl(0, 0%, 69.5%); --gray12: hsl(0, 0%, 93.5%); } ::selection { background: var(--gray7); } .container { max-width: 642px; margin: 0 auto; padding-left: max(var(--side-padding), env(safe-area-inset-left)); padding-right: max(var(--side-padding), env(safe-area-inset-right)); } .wrapper { --side-padding: 16px; background: var(--gray0); margin: 0; padding: 0; padding-top: 100px; font-family: var(--font-sans); -webkit-font-smoothing: antialiased; } /* Disable double-tap zoom */ * { touch-action: manipulation; } h1, p { color: var(--gray12); } h2 { font-size: 16px; color: var(--gray12); font-weight: 500; } h2 + p { margin-top: -4px; } p { font-size: 16px; } a { color: inherit; text-decoration-color: var(--gray10); text-underline-position: from-font; } code { font-size: 13px; line-height: 28px; padding: 2px 3.6px; border: 1px solid var(--gray3); background: var(--gray4); font-family: var(--font-mono); border-radius: 6px; } .content { display: flex; flex-direction: column; gap: 48px; margin-top: 96px; } .buttons { display: flex; flex-wrap: wrap; gap: 8px; overflow: auto; margin: 0 calc(-1 * var(--side-padding)); padding: 4px var(--side-padding); position: relative; } .button { padding: 8px 12px; margin: 0; background: var(--gray1); border: 1px solid var(--gray3); white-space: nowrap; border-radius: 6px; font-size: 13px; font-weight: 500; font-family: var(--font-sans); cursor: pointer; color: var(--gray12); transition: border-color 200ms, background 200ms, box-shadow 200ms; } .button:hover { background: var(--gray2); border-color: var(--gray4); } .button[data-active='true'] { background: var(--gray3); border-color: var(--gray7); } .button:focus-visible { outline: none; box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08), 0px 2px 2px 0px rgba(0, 0, 0, 0.04), 0px 3px 3px 0px rgba(0, 0, 0, 0.02), 0px 4px 4px 0px rgba(0, 0, 0, 0.01), 0 0 0 2px rgba(0, 0, 0, 0.15); } @media (max-width: 600px) { .buttons { mask-image: linear-gradient(to right, transparent, black 16px, black calc(100% - 16px), transparent); } } .wrapper h1, .wrapper p { color: var(--gray12); line-height: 25px; } m .wrapper h2 { font-size: 16px; color: var(--gray12); font-weight: 500; } .wrapper h2 + p { margin-top: -4px; } .wrapper h2 { margin: 12px 0; } .wrapper p { font-size: 16px; margin-bottom: 16px; } .wrapper a { text-decoration-color: var(--gray10); text-underline-position: from-font; } .wrapper .content { display: flex; flex-direction: column; gap: 48px; margin-top: 96px; } .wrapper footer { padding: 0; } .wrapper footer .container { padding: 32px 16px !important; } .wrapper footer p { margin: 0; font-size: 14px; } footer { background: var(--gray1) !important; } hr { background: var(--gray3) !important; } .nx-border-primary-500 { border-color: var(--gray12) !important; } .nx-bg-primary-500\/10 { background: var(--gray3) !important; } ================================================ FILE: website/src/pages/_app.tsx ================================================ import type { ReactElement } from 'react'; import type { AppProps } from 'next/app'; import { Analytics } from '@vercel/analytics/react'; import '../style.css'; import '../globals.css'; export default function Nextra({ Component, pageProps }: AppProps): ReactElement { return ( <> {/* @ts-ignore */} ); } ================================================ FILE: website/src/pages/_meta.json ================================================ { "getting-started": { "title": "Getting Started", "href": "/getting-started" }, "-- API": { "type": "separator", "title": "API" }, "toast": { "title": "toast()", "href": "/toast" }, "toaster": { "title": "Toaster", "href": "/toaster" }, "-- More": { "type": "separator", "title": "Guides" }, "styling": { "title": "Styling", "href": "/styling" } } ================================================ FILE: website/src/pages/getting-started.mdx ================================================ import { Tab, Tabs, Cards, Card, Steps } from 'nextra-theme-docs'; import { toast } from 'sonner'; # Getting Started Sonner is an opinionated toast component for React. You can read more about why and how it was built [here](https://emilkowal.ski/ui/building-a-toast-component). ### Install ```bash pnpm i sonner ``` ```bash npm i sonner ``` ```bash yarn add sonner ``` ```bash bun add sonner ``` ### Add Toaster to your app It can be placed anywhere, even in server components such as `layout.tsx`. ```tsx import { Toaster } from 'sonner'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ### Render a toast ```tsx import { toast } from 'sonner'; function MyToast() { return ; } ``` ================================================ FILE: website/src/pages/index.tsx ================================================ import React, { StrictMode } from 'react'; import { Toaster } from 'sonner'; import { Installation } from '@/src/components/Installation'; import { Hero } from '@/src/components/Hero'; import { Types } from '@/src/components/Types/Types'; import { ExpandModes } from '@/src/components/ExpandModes'; import { Position } from '@/src/components/Position'; import { Usage } from '@/src/components/Usage'; import { Other } from '@/src/components/Other/Other'; import Head from '../components/Head'; import { How } from '../components/How/How'; import { Footer } from '../components/Footer'; export default function Home() { const [expand, setExpand] = React.useState(false); const [position, setPosition] = React.useState('bottom-right'); const [richColors, setRichColors] = React.useState(false); const [closeButton, setCloseButton] = React.useState(false); return (
    ); } ================================================ FILE: website/src/pages/styling.mdx ================================================ # Styling Styling can be done globally via `toastOptions`, this way every toast will have the same styling. ```jsx ``` You can also use the same props when calling `toast` to style a specific toast. ```jsx toast('Hello World', { style: { background: 'red', }, className: 'class', }); ``` ## Tailwind CSS The preferred way to style the toasts with tailwind is by using the `unstyled` prop. That will give you an unstyled toast which you can then style with tailwind. ```jsx ``` You can do the same when calling `toast()`. ```jsx toast('Hello World', { unstyled: true, classNames: { toast: 'bg-blue-400', title: 'text-red-400 text-2xl', description: 'text-red-400', actionButton: 'bg-zinc-400', cancelButton: 'bg-orange-400', closeButton: 'bg-lime-400', }, }); ``` Styling per toast type is also possible. ```jsx ``` ## Changing Icons You can change the default icons using the `icons` prop: ```jsx , info: , warning: , error: , loading: , }} /> ``` You can also set an icon for each toast: ```jsx toast('Hello World', { icon: , }); ``` ================================================ FILE: website/src/pages/toast.mdx ================================================ import { toast } from 'sonner'; # Toast() Use it to render a toast. You can call it from anywhere, even outside of React. ## Rendering the toast You can call it with just a string. ```jsx import { toast } from 'sonner'; toast('Hello World!'); ``` Or provide an object as the second argument with more options. They will overwrite the options passed to [``](/toaster) if you have provided any. ```jsx import { toast } from 'sonner'; toast('My toast', { className: 'my-classname', description: 'My description', duration: 5000, icon: , }); ``` ### Render toast on page load To render a toast on initial page load it is required that the function `toast()` is called inside of a `setTimeout` or `requestAnimationFrame`. ```jsx setTimeout(() => { toast('My toast on a page load'); }); ``` ## Creating toasts ### Success Renders a checkmark icon in front of the message. ```jsx toast.success('My success toast'); ``` ### Error Renders an error icon in front of the message. ```jsx toast.error('My error toast'); ``` ### Action Renders a primary button, clicking it will close the toast and run the callback passed via `onClick`. You can prevent the toast from closing by calling `event.preventDefault()` in the `onClick` callback. ```jsx toast('My action toast', { action: { label: 'Action', onClick: () => console.log('Action!'), }, }); ``` You can also render jsx as your action. ```jsx toast('My action toast', { action: , }); ``` ### Cancel Renders a secondary button, clicking it will close the toast and run the callback passed via `onClick`. ```jsx toast('My cancel toast', { cancel: { label: 'Cancel', onClick: () => console.log('Cancel!'), }, }); ``` You can also render jsx in the cancel option. ```jsx toast('My cancel toast', { cancel: , }); ``` ### Promise Starts in a loading state and will update automatically after the promise resolves or fails. You can pass a function to the success/error messages to incorporate the result/error of the promise. ```jsx toast.promise(myPromise, { loading: 'Loading...', success: (data) => { return `${data.name} toast has been added`; }, error: 'Error', }); ``` ### Loading Renders a toast with a loading spinner. Useful when you want to handle various states yourself instead of using a promise toast. ```jsx toast.loading('Loading data'); ``` ### Custom You can pass jsx as the first argument instead of a string to render a custom toast while maintaining default styling. ```jsx toast(
    A custom toast with default styling
    , { duration: 5000 }); ``` ### Headless Use it to render an unstyled toast with custom jsx while maintaining the functionality. This function receives the `Toast` as an argument, giving you access to all properties. ```jsx toast.custom((t) => (
    This is a custom component
    )); ``` ### Dynamic Position You can change the position of the toast dynamically by passing a `position` prop to the toast function. It will not affect the positioning of other toasts. ```jsx // Available positions: // top-left, top-center, top-right, bottom-left, bottom-center, bottom-right toast('Hello World', { position: 'top-center', }); ``` ## Other ### Updating toasts You can update a toast by using the `toast` function and passing it the id of the toast you want to update, the rest stays the same. ```jsx const toastId = toast('Sonner'); toast.success('Toast has been updated', { id: toastId, }); ``` ### On Close Callback You can pass `onDismiss` and `onAutoClose` callbacks to each toast. `onDismiss` gets fired when either the close button gets clicked or the toast is swiped. `onAutoClose` fires when the toast disappears automatically after it's timeout (`duration` prop). ```jsx toast('Event has been created', { onDismiss: (t) => console.log(`Toast with id ${t.id} has been dismissed`), onAutoClose: (t) => console.log(`Toast with id ${t.id} has been closed automatically`), }); ``` ### Persisting toasts If you want a toast to stay on screen forever, you can set the `duration` to [`Infinity`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Infinity). ```js toast('This toast will stay on screen forever', { duration: Infinity, }); ``` ### Dismissing toasts programmatically To remove a toast programmatically use `toast.dismiss(id)`. The `toast()` function return the id of the toast. ```jsx const toastId = toast('Event has been created'); toast.dismiss(toastId); ``` You can also dismiss all toasts at once by calling `toast.dismiss()` without an id. ```jsx toast.dismiss(); ``` ### Rendering custom elements You can render custom elements inside the toast like `` or custom components by passing a function instead of a string. This work for both the title and description. ```jsx toast( () => ( <> View{' '} Animation on the Web ), { description: () => , }, ); ``` ### Targeting a specific Toaster You can target a specific Toaster by passing a `toasterId` option: ```jsx // This toast will only appear in the Toaster with id="canvas" toast('This will show in the canvas Toaster', { toasterId: 'canvas' }); ``` ## API Reference | Property | Description | Default | | :---------------- | :----------------------------------------------------------------------------------------------------: | -------------: | | description | Toast's description, renders underneath the title. | `-` | | closeButton | Adds a close button. | `false` | | invert | Dark toast in light mode and vice versa. | `false` | | duration | Time in milliseconds that should elapse before automatically closing the toast. | `4000` | | position | Position of the toast. | `bottom-right` | | dismissible | If `false`, it'll prevent the user from dismissing the toast. | `true` | | icon | Icon displayed in front of toast's text, aligned vertically. | `-` | | action | Renders a primary button, clicking it will close the toast. | `-` | | cancel | Renders a secondary button, clicking it will close the toast. | `-` | | id | Custom id for the toast. | `-` | | onDismiss | The function gets called when either the close button is clicked, or the toast is swiped. | `-` | | onAutoClose | Function that gets called when the toast disappears automatically after it's timeout (duration` prop). | `-` | | unstyled | Removes the default styling, which allows for easier customization. | `false` | | actionButtonStyle | Styles for the action button | `{}` | | cancelButtonStyle | Styles for the cancel button | `{}` | ================================================ FILE: website/src/pages/toaster.mdx ================================================ # Toaster This component renders all the toasts, you can place it anywhere in your app. ## Customization You can see examples of most of the scenarios described below on the [homepage](/). ### Multiple Toasters You can render multiple Toaster components with different ids and target toasts to each one: ```jsx ``` ### Expand When you hover on one of the toasts, they will expand. You can make that the default behavior by setting the `expand` prop to `true`, and customize it even further with the `visibleToasts` prop. ```jsx // 9 toasts will be visible instead of the default, which is 3. ``` ### Position Changes the place where all toasts will be rendered. ```jsx // Available positions: // top-left, top-center, top-right, bottom-left, bottom-center, bottom-right ``` ### Styling all toasts You can customize all toasts at once with `toastOptions` prop. These options will act as the default for all toasts. ```jsx ``` ### dir Changes the directionality of the toast's text. ```jsx // rtl, ltr, auto ``` ### Custom ARIA label You can customize the default ARIA label for the notification container and the toast close button. ```jsx // example in Finnish ``` ## API Reference | Property | Description | Default | | :-------------------- | :-----------------------------------------------------------------------------------------------------------------------------: | -------------: | | theme | Toast's theme, either `light`, `dark`, or `system` | `light` | | richColors | Makes error and success state more colorful | `false` | | expand | Toasts will be expanded by default | `false` | | visibleToasts | Amount of visible toasts | `3` | | position | Place where the toasts will be rendered | `bottom-right` | | closeButton | Adds a close button to all toasts | `false` | | offset | Offset from the edges of the screen. | `32px` | | mobileOffset | Offset from the left/right edges of the screen on screens with width smaller than 600px. | `16px` | | dir | Directionality of toast's text | `ltr` | | hotkey | Keyboard shortcut that will move focus to the toaster area. | `⌥/alt + T` | | invert | Dark toasts in light mode and vice versa. | `false` | | toastOptions | These will act as default options for all toasts. See [toast()](/toast) for all available options. | `4000` | | gap | Gap between toasts when expanded | `14` | | loadingIcon | Changes the default loading icon | `-` | | pauseWhenPageIsHidden | Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked. | `false` | | icons | Changes the default icons | `-` | ================================================ FILE: website/src/style.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; :root { --gray0: #fff; --gray1: hsl(0, 0%, 99%); --gray2: hsl(0, 0%, 97.3%); --gray3: hsl(0, 0%, 95.1%); --gray4: hsl(0, 0%, 93%); --gray5: hsl(0, 0%, 90.9%); --gray6: hsl(0, 0%, 88.7%); --gray7: hsl(0, 0%, 85.8%); --gray8: hsl(0, 0%, 78%); --gray9: hsl(0, 0%, 56.1%); --gray10: hsl(0, 0%, 52.3%); --gray11: hsl(0, 0%, 43.5%); --gray12: hsl(0, 0%, 9%); --hover: rgb(40, 40, 40); --border-radius: 6px; --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; --font-mono: 'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; --shiki-token-comment: var(--gray11) !important; } .dark { --gray0: #000; --gray1: hsl(0, 0%, 9.5%); --gray2: hsl(0, 0%, 10.5%); --gray3: hsl(0, 0%, 15.8%); --gray4: hsl(0, 0%, 18.9%); --gray5: hsl(0, 0%, 21.7%); --gray6: hsl(0, 0%, 24.7%); --gray7: hsl(0, 0%, 29.1%); --gray8: hsl(0, 0%, 37.5%); --gray9: hsl(0, 0%, 43%); --gray10: hsl(0, 0%, 50.7%); --gray11: hsl(0, 0%, 69.5%); --gray12: hsl(0, 0%, 93.5%); } body { padding-top: 0; } .button { padding: 8px 12px; margin: 0; background: var(--gray1); border: 1px solid var(--gray3); white-space: nowrap; border-radius: 6px; font-size: 13px; font-weight: 500; font-family: var(--font-sans); cursor: pointer; color: var(--gray12); transition: border-color 200ms, background 200ms, box-shadow 200ms; margin: 1.5rem 0 0; } .button p { line-height: 1.5; } .button:hover { background: var(--gray2); border-color: var(--gray4); } .button[data-active='true'] { background: var(--gray3); border-color: var(--gray7); } .button:focus-visible { outline: none; box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08), 0px 2px 2px 0px rgba(0, 0, 0, 0.04), 0px 3px 3px 0px rgba(0, 0, 0, 0.02), 0px 4px 4px 0px rgba(0, 0, 0, 0.01), 0 0 0 2px rgba(0, 0, 0, 0.15); } @media (max-width: 600px) { .buttons { mask-image: linear-gradient(to right, transparent, black 16px, black calc(100% - 16px), transparent); } } aside li.active a { background: var(--gray3) !important; color: var(--gray12) !important; } aside li:not(.active) a:hover { background: var(--gray2) !important; } pre { background-color: var(--gray0) !important; border: 1px solid var(--gray4); margin-bottom: 2rem !important; } button[title='Copy code'] { background: var(--gray2); color: var(--gray10); } main > p { line-height: 1.5rem !important; margin-top: 1rem !important; } .nx-text-primary-600 { color: var(--gray12) !important; } div > a:hover { color: var(--gray12) !important; } p { color: var(--gray12) !important; } footer > div { padding: 32px 24px !important; } ================================================ FILE: website/tailwind.config.js ================================================ module.exports = { content: [ './app/**/*.{js,ts,jsx,tsx,mdx}', './pages/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', // Or if using `src` directory: './src/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: {}, }, plugins: [], }; ================================================ FILE: website/theme.config.jsx ================================================ export default { logo: Sonner, project: { link: 'https://github.com/emilkowalski/sonner', }, docsRepositoryBase: 'https://github.com/emilkowalski/sonner/tree/main/website', useNextSeoProps() { return { titleTemplate: '%s – Sonner', }; }, feedback: { content: null, }, footer: { text: ( MIT {new Date().getFullYear()} ©{' '} Sonner . ), }, // ... other theme options }; ================================================ FILE: website/tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "baseUrl": ".", "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] }