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 (
toast('My first toast')}>Give me a toast
);
}
```
## 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' ? (
{}
: () => {
deleteToast();
toast.onDismiss?.(toast);
}
}
className={cn(classNames?.closeButton, toast?.classNames?.closeButton)}
>
{icons?.close ?? CloseIcon}
) : 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) ? (
{
// We need to check twice because typescript
if (!isAction(toast.cancel)) return;
if (!dismissible) return;
toast.cancel.onClick?.(event);
deleteToast();
}}
className={cn(classNames?.cancelButton, toast?.classNames?.cancelButton)}
>
{toast.cancel.label}
) : null}
{React.isValidElement(toast.action) ? (
toast.action
) : toast.action && isAction(toast.action) ? (
{
// We need to check twice because typescript
if (!isAction(toast.action)) return;
toast.action.onClick?.(event);
if (event.defaultPrevented) return;
deleteToast();
}}
className={cn(classNames?.actionButton, toast?.classNames?.actionButton)}
>
{toast.action.label}
) : 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 (
<>
setTheme('dark')}>
Change theme
toast('My Toast')}>
Render Toast
toast('My Toast')}>
Render Toast Top
toast.success('My Success Toast')}>
Render Success Toast
toast.error('My Error Toast')}>
Render Error Toast
toast('My Message', {
action: {
label: 'Action',
onClick: () => console.log('Action'),
},
})
}
>
Render Action Toast
toast('My Message', {
action: {
label: 'Action',
onClick: (event) => {
event.preventDefault();
console.log('Action');
},
},
})
}
>
Render Action Toast
toast.promise(promise, {
loading: 'Loading...',
success: 'Loaded',
error: 'Error',
finally: () => setIsFinally(true),
})
}
>
Render Promise Toast
toast.promise(action(), {
loading: 'Loading...',
success: 'Loaded',
error: 'Error',
finally: () => setIsFinally(true),
})
}
>
Render React Server Function Toast
toast.custom((t) => (
jsx
toast.dismiss(t)}>
Dismiss
))
}
>
Render Custom Toast
toast('My Custom Cancel Button', {
cancel: {
label: 'Cancel',
onClick: () => console.log('Cancel'),
},
})
}
>
Render Custom Cancel Button
toast.custom(
(t) => (
jsx
toast.dismiss(t)}>
Dismiss
),
{
id: undefined,
},
)
}
>
Render Custom Toast with empty id
toast('My Toast', { duration: Infinity })}>
Render Infinity Toast
toast('My Toast', {
onAutoClose: () => setShowAutoClose(true),
})
}
>
Render Toast With onAutoClose callback
toast('My Toast', {
onDismiss: () => {
setShowDismiss(true);
},
})
}
>
Dismiss toast callback
toast('My Toast', {
dismissible: false,
})
}
>
Non-dismissible Toast
{
const toastId = toast('My Unupdated Toast', {
duration: 10000,
});
toast('My Updated Toast', {
id: toastId,
duration: 10000,
});
}}
>
Updated Toast
{
const toastId = toast('My Unupdated Toast, Updated After 3 Seconds', {
duration: 10000,
});
setTimeout(() => {
toast('My Updated Toast, Close After 1 Second', {
id: toastId,
duration: 1000,
});
}, 3000);
}}
>
Updated Toast Duration
toast('Custom Description', { description: 'string description' })}
>
String Description
toast('Custom Description', { description: This is my custom ReactNode description
})}
>
ReactNode Description
toast('Toast with close button', { closeButton: true })}
>
Render close button
toast.promise(
new Promise((resolve) => {
setTimeout(() => {
resolve({ name: 'Sonner' });
}, 2000);
}),
{
loading: 'Loading...',
success: (data: any) => ({
message: `${data.name} toast has been added`,
description: 'Custom description for the Success state',
}),
error: {
message: 'An error occurred',
description: undefined,
action: {
label: 'Retry',
onClick: () => {
console.log('retrying');
},
},
},
description: 'Global description',
},
)
}
>
Extended Promise Toast
toast.promise(
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('Simulated error'));
}, 2000);
}),
{
loading: 'Loading...',
success: (data: any) => ({
message: `${data.name} toast has been added`,
description: 'Custom description for the Success state',
}),
error: {
message: 'An error occurred',
description: undefined,
action: {
label: 'Retry',
onClick: (event) => {
event.preventDefault();
console.log('retrying');
},
},
},
description: 'Global description',
},
)
}
>
Extended Promise Error Toast
{
const whatWillHappen = async () => {
throw new Error('Not implemented');
};
toast.promise(whatWillHappen, {
loading: 'Saving project...',
success: (result: any) => {
if (result?.ok) {
return 'Project saved';
} else {
return `${result?.error}`;
}
},
error: (e) => `Error Raise: ${e}`,
});
}}
>
Error Promise Toast
{
setShowAriaLabels(true);
toast('Toast with custom ARIA labels', { closeButton: true, onAutoClose: () => setShowAriaLabels(false) });
}}
>
With custom ARIA labels
toast('Secondary Toaster Toast', { toasterId: 'secondary' })}
>
Render Toast in Secondary Toaster
toast('Global Toaster Toast')}>
Render Toast in Global Toaster
toast('Toast with test ID', { testId: 'my-test-toast' })}
>
Toast with testId
toast.promise(promise, {
loading: 'Loading...',
success: 'Loaded',
error: 'Error',
testId: 'promise-test-toast',
})
}
>
Promise Toast with testId
{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 (
{copying ? (
) : (
)}
{/* @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.
{
toast('Event has been created', {
description: 'Monday, January 3rd at 6:00pm',
});
setExpand(true);
}}
>
Expand
{
toast('Event has been created', {
description: 'Monday, January 3rd at 6:00pm',
});
setExpand(false);
}}
>
Default
{` `}
);
};
================================================
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.
{
toast('Sonner', {
description: 'An opinionated toast component for React.',
});
}}
className={styles.button}
>
Render a toast
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{' '}
{copying ? (
) : (
)}
);
};
================================================
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
toast.dismiss(t)}>Dismiss
));`,
action: () => {
toast.custom(
(t) => (
Event Created
Today at 4:00pm - "Louvre Museum"
toast.dismiss(t)}>
),
{ 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) => (
{
type.action();
setActiveType(type);
}}
key={type.name}
>
{type.name}
))}
{`${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) => (
{
if (activePosition !== position) {
setPosition(position);
removeAllToasts();
}
toast('Event has been created', {
description: 'Monday, January 3rd at 6:00pm',
});
}}
key={position}
>
{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) => (
{
type.action();
setActiveType(type);
}}
key={type.name}
>
{type.name}
))}
{`${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 (
toast('My first toast')}>
Give me a toast
)
}`}
);
};
================================================
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 toast('This is a sonner toast')}>Render my toast ;
}
```
================================================
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: console.log('Action!')}>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: console.log('Cancel!')}>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 toast.dismiss(t)}>close
));
```
### 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: () => This is a button element! ,
},
);
```
### 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
toast('Global toast', { toasterId: 'global' })}>
Show in Global Toaster
toast('Canvas toast', { toasterId: 'canvas' })}>
Show in Canvas Toaster
```
### 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"]
}