├── FUNDING.yml ├── test ├── .eslintrc.json ├── .vscode │ └── settings.json ├── next.config.js ├── src │ └── app │ │ ├── layout.tsx │ │ └── page.tsx ├── .gitignore ├── package.json ├── tsconfig.json ├── README.md └── tests │ └── basic.spec.ts ├── pnpm-workspace.yaml ├── website ├── .eslintrc.json ├── public │ ├── og.png │ ├── emil.jpeg │ └── favicon.ico ├── postcss.config.js ├── .vscode │ └── settings.json ├── tailwind.config.js ├── next.config.js ├── src │ ├── pages │ │ ├── _meta.json │ │ ├── _app.tsx │ │ ├── getting-started.mdx │ │ ├── index.tsx │ │ ├── toaster.mdx │ │ └── toast.mdx │ ├── components │ │ ├── Usage │ │ │ └── index.tsx │ │ ├── Footer │ │ │ ├── footer.module.css │ │ │ └── index.tsx │ │ ├── Installation │ │ │ ├── installation.module.css │ │ │ └── index.tsx │ │ ├── Hero │ │ │ ├── index.tsx │ │ │ └── hero.module.css │ │ ├── Other │ │ │ ├── other.module.css │ │ │ └── Other.tsx │ │ ├── ExpandModes │ │ │ └── index.tsx │ │ ├── CodeBlock │ │ │ ├── code-block.module.css │ │ │ └── index.tsx │ │ ├── Position │ │ │ └── index.tsx │ │ └── Types │ │ │ └── Types.tsx │ ├── style.css │ └── globals.css ├── .gitignore ├── theme.config.jsx ├── tsconfig.json ├── package.json └── README.md ├── .prettierrc.js ├── tsconfig.json ├── turbo.json ├── tsup.config.ts ├── .gitignore ├── .github └── workflows │ └── playwright.yml ├── README.md ├── LICENSE.md ├── package.json ├── src ├── assets.tsx ├── types.ts ├── state.ts ├── styles.css └── index.tsx └── playwright.config.ts /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: emilkowalski 2 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'website' 3 | - '.' 4 | - 'test' 5 | -------------------------------------------------------------------------------- /website/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /website/public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingMarin/sonner/HEAD/website/public/og.png -------------------------------------------------------------------------------- /website/public/emil.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingMarin/sonner/HEAD/website/public/emil.jpeg -------------------------------------------------------------------------------- /website/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingMarin/sonner/HEAD/website/public/favicon.ico -------------------------------------------------------------------------------- /website/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: true, 4 | tabWidth: 2, 5 | trailingComma: 'all', 6 | printWidth: 120, 7 | }; 8 | -------------------------------------------------------------------------------- /test/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /test/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | } 7 | 8 | module.exports = nextConfig 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "moduleResolution": "node", 5 | "esModuleInterop": true, 6 | "lib": ["es2015", "dom"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /website/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**", ".next/**"] 7 | }, 8 | "dev": { 9 | "cache": false 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | minify: true, 5 | target: 'es2018', 6 | external: ['react'], 7 | sourcemap: true, 8 | dts: true, 9 | format: ['esm', 'cjs'], 10 | injectStyle: true, 11 | }); 12 | -------------------------------------------------------------------------------- /test/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: 'Create Next App', 3 | description: 'Generated by create next app', 4 | }; 5 | 6 | export default function RootLayout({ children }: { children: React.ReactNode }) { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /website/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './app/**/*.{js,ts,jsx,tsx,mdx}', 4 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './components/**/*.{js,ts,jsx,tsx,mdx}', 6 | 7 | // Or if using `src` directory: 8 | './src/**/*.{js,ts,jsx,tsx,mdx}', 9 | ], 10 | theme: { 11 | extend: {}, 12 | }, 13 | plugins: [], 14 | }; 15 | -------------------------------------------------------------------------------- /website/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | }; 7 | 8 | const withNextra = require('nextra')({ 9 | title: 'Sonner', 10 | theme: 'nextra-theme-docs', 11 | themeConfig: './theme.config.jsx', 12 | defaultShowCopyCode: true, 13 | }); 14 | 15 | module.exports = withNextra(nextConfig); 16 | -------------------------------------------------------------------------------- /website/src/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "getting-started": { 3 | "title": "Getting Started", 4 | "href": "/getting-started" 5 | }, 6 | "-- API": { 7 | "type": "separator", 8 | "title": "API" 9 | }, 10 | "toast": { 11 | "title": "toast()", 12 | "href": "/toast" 13 | }, 14 | "toaster": { 15 | "title": "Toaster", 16 | "href": "/toaster" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /website/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react'; 2 | import type { AppProps } from 'next/app'; 3 | import { Analytics } from '@vercel/analytics/react'; 4 | import '../style.css'; 5 | import '../globals.css'; 6 | 7 | export default function Nextra({ Component, pageProps }: AppProps): ReactElement { 8 | return ( 9 | <> 10 | {/* @ts-ignore */} 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /website/src/components/Usage/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeBlock } from '../CodeBlock'; 2 | 3 | export const Usage = () => { 4 | return ( 5 |
6 |

Usage

7 |

Render the toaster in the root of your app.

8 | {`import { Toaster, toast } from 'sonner' 9 | 10 | // ... 11 | 12 | function App() { 13 | return ( 14 |
15 | 16 | 19 |
20 | ) 21 | }`}
22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | dist 3 | 4 | 5 | # dependencies 6 | node_modules 7 | .pnp 8 | .pnp.js 9 | 10 | # testing 11 | coverage 12 | 13 | # next.js 14 | .next/ 15 | out/ 16 | build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # turbo 35 | .turbo 36 | /test-results/ 37 | /playwright-report/ 38 | /playwright/.cache/ 39 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | /test-results/ 38 | /playwright-report/ 39 | /playwright/.cache/ 40 | -------------------------------------------------------------------------------- /website/src/components/Footer/footer.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | padding: 32px 0; 3 | border-top: 1px solid var(--gray3); 4 | background: var(--gray1); 5 | margin-top: 164px; 6 | } 7 | 8 | .p { 9 | display: flex; 10 | align-items: center; 11 | gap: 12px; 12 | margin: 0; 13 | font-size: 14px; 14 | } 15 | 16 | .p img { 17 | border-radius: 50%; 18 | } 19 | 20 | .p a { 21 | font-weight: 600; 22 | color: inherit; 23 | text-decoration: none; 24 | } 25 | 26 | .p a:hover { 27 | text-decoration: underline; 28 | } 29 | 30 | @media (max-width: 600px) { 31 | .wrapper { 32 | margin-top: 128px; 33 | padding: 16px 0; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@types/node": "18.15.0", 13 | "@types/react": "18.0.28", 14 | "@types/react-dom": "18.0.11", 15 | "eslint": "8.35.0", 16 | "eslint-config-next": "13.2.4", 17 | "next": "13.4.19", 18 | "react": "18.2.0", 19 | "sonner": "workspace:*", 20 | "react-dom": "18.2.0", 21 | "typescript": "4.9.5" 22 | }, 23 | "devDependencies": { 24 | "@playwright/test": "^1.30.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /website/src/components/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import emil from 'public/emil.jpeg'; 3 | import styles from './footer.module.css'; 4 | 5 | export const Footer = () => { 6 | return ( 7 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /website/theme.config.jsx: -------------------------------------------------------------------------------- 1 | export default { 2 | logo: Sonner, 3 | project: { 4 | link: 'https://github.com/emilkowalski/sonner', 5 | }, 6 | docsRepositoryBase: 'https://github.com/emilkowalski/sonner/tree/main/website', 7 | useNextSeoProps() { 8 | return { 9 | titleTemplate: '%s – Sonner', 10 | }; 11 | }, 12 | feedback: { 13 | content: null, 14 | }, 15 | footer: { 16 | text: ( 17 | 18 | MIT {new Date().getFullYear()} ©{' '} 19 | 20 | Sonner 21 | 22 | . 23 | 24 | ), 25 | }, 26 | // ... other theme options 27 | }; 28 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | - run: npm install pnpm -g 17 | - run: pnpm install --no-frozen-lockfile 18 | - run: pnpm build 19 | - run: pnpm playwright install --with-deps 20 | - run: pnpm test || exit 1 21 | - uses: actions/upload-artifact@v3 22 | if: always() 23 | with: 24 | name: playwright-report 25 | path: playwright-report/ 26 | retention-days: 30 27 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../playwright.config.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["./*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /website/src/components/Installation/installation.module.css: -------------------------------------------------------------------------------- 1 | .code { 2 | padding: 0 62px 0 12px; 3 | border-radius: 6px; 4 | background: linear-gradient(to top, var(--gray2), var(--gray1) 8px); 5 | font-family: var(--font-mono); 6 | font-size: 14px; 7 | position: relative; 8 | cursor: copy; 9 | height: 40px; 10 | border: 1px solid var(--gray3); 11 | display: flex; 12 | align-items: center; 13 | } 14 | 15 | .copy { 16 | position: absolute; 17 | right: 6px; 18 | top: 50%; 19 | transform: translateY(-50%); 20 | cursor: pointer; 21 | border-radius: 50%; 22 | border: none; 23 | border: 1px solid var(--gray4); 24 | background: #fff; 25 | color: var(--gray12); 26 | border-radius: 5px; 27 | width: 26px; 28 | height: 26px; 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | } 33 | 34 | .copy div { 35 | display: flex; 36 | } 37 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@types/node": "18.11.18", 12 | "@types/react": "18.2.0", 13 | "@types/react-dom": "18.0.10", 14 | "@vercel/analytics": "^0.1.11", 15 | "clsx": "^2.0.0", 16 | "copy-to-clipboard": "^3.3.3", 17 | "eslint-config-next": "^13.2.3", 18 | "framer-motion": "^9.0.1", 19 | "next": "13.4.19", 20 | "next-mdx-remote": "^4.3.0", 21 | "nextra": "^2.12.3", 22 | "nextra-theme-docs": "^2.12.3", 23 | "prism-react-renderer": "^1.3.5", 24 | "react": "^18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-use-measure": "^2.1.1", 27 | "sonner": "workspace:*", 28 | "typescript": "4.9.5" 29 | }, 30 | "devDependencies": { 31 | "autoprefixer": "^10.4.15", 32 | "postcss": "^8.4.29", 33 | "tailwindcss": "^3.3.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | https://github.com/vallezw/sonner/assets/50796600/59b95cb7-9068-4f3e-8469-0b35d9de5cf0 2 | 3 | [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). 4 | 5 | ## Usage 6 | 7 | To start using the library, install it in your project: 8 | 9 | ```bash 10 | npm install sonner 11 | ``` 12 | 13 | Add `` to your app, it will be the place where all your toasts will be rendered. 14 | After that you can use `toast()` from anywhere in your app. 15 | 16 | ```jsx 17 | import { Toaster, toast } from 'sonner'; 18 | 19 | // ... 20 | 21 | function App() { 22 | return ( 23 |
24 | 25 | 26 |
27 | ); 28 | } 29 | ``` 30 | 31 | ## Documentation 32 | 33 | You can find out more about the API and implementation in the [Documentation](https://sonner.emilkowal.ski/getting-started). 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Emil Kowalski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /website/src/components/Hero/index.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'sonner'; 2 | 3 | import styles from './hero.module.css'; 4 | import Link from 'next/link'; 5 | 6 | export const Hero = () => { 7 | return ( 8 |
9 |
10 |
11 |
12 |
13 |
14 |

Sonner

15 |

An opinionated toast component for React.

16 |
17 | 28 | 29 | GitHub 30 | 31 |
32 | 33 | Documentation 34 | 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /website/src/components/Other/other.module.css: -------------------------------------------------------------------------------- 1 | ol[dir='ltr'] .headlessClose { 2 | --headless-close-start: unset; 3 | --headless-close-end: 6px; 4 | } 5 | 6 | ol[dir='rtl'] .headlessClose { 7 | --headless-close-start: 6px; 8 | --headless-close-end: unset; 9 | } 10 | 11 | .headless { 12 | padding: 16px; 13 | width: 356px; 14 | box-sizing: border-box; 15 | border-radius: 8px; 16 | background: var(--gray1); 17 | border: 1px solid var(--gray4); 18 | position: relative; 19 | } 20 | 21 | .headless .headlessDescription { 22 | margin: 0; 23 | color: var(--gray10); 24 | font-size: 14px; 25 | line-height: 1; 26 | } 27 | 28 | .headless .headlessTitle { 29 | font-size: 14px; 30 | margin: 0 0 8px; 31 | color: var(--gray12); 32 | font-weight: 500; 33 | line-height: 1; 34 | } 35 | 36 | .headlessClose { 37 | position: absolute; 38 | cursor: pointer; 39 | top: 6px; 40 | height: 24px; 41 | width: 24px; 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | left: var(--headless-close-start); 46 | right: var(--headless-close-end); 47 | color: var(--gray10); 48 | padding: 0; 49 | background: transparent; 50 | border: none; 51 | transition: color 200ms; 52 | } 53 | 54 | .headlessClose:hover { 55 | color: var(--gray12); 56 | } 57 | -------------------------------------------------------------------------------- /website/src/components/ExpandModes/index.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'sonner'; 2 | import { CodeBlock } from '../CodeBlock'; 3 | 4 | export const ExpandModes = ({ 5 | expand, 6 | setExpand, 7 | }: { 8 | expand: boolean; 9 | setExpand: React.Dispatch>; 10 | }) => { 11 | return ( 12 |
13 |

Expand

14 |

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

17 |
18 | 30 | 42 |
43 | {``} 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /website/src/components/CodeBlock/code-block.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | padding: 16px; 3 | margin: 0; 4 | background: var(--gray1); 5 | border-radius: 0; 6 | position: relative; 7 | line-height: 17px; 8 | white-space: pre-wrap; 9 | background: linear-gradient(to top, var(--gray2), var(--gray1) 16px); 10 | } 11 | 12 | .wrapper { 13 | overflow: hidden; 14 | margin: 0; 15 | position: relative; 16 | border-radius: 6px; 17 | margin-top: 16px; 18 | border: 1px solid var(--gray3); 19 | padding: 0 !important; 20 | } 21 | 22 | .copyButton { 23 | position: absolute; 24 | top: 12px; 25 | right: 12px; 26 | z-index: 1; 27 | width: 26px; 28 | height: 26px; 29 | border: 1px solid var(--gray4); 30 | border-radius: 6px; 31 | display: flex; 32 | align-items: center; 33 | justify-content: center; 34 | background: var(--gray0); 35 | cursor: pointer; 36 | opacity: 0; 37 | color: var(--gray12); 38 | transition: background 200ms, box-shadow 200ms, opacity 200ms; 39 | } 40 | 41 | .copyButton:focus-visible { 42 | opacity: 1; 43 | } 44 | 45 | .copyButton:hover { 46 | background: var(--gray1); 47 | } 48 | 49 | .copyButton:focus-visible { 50 | box-shadow: 0 0 0 1px var(--gray4); 51 | } 52 | 53 | .copyButton > div { 54 | display: flex; 55 | } 56 | 57 | .outerWrapper { 58 | position: relative; 59 | } 60 | 61 | .outerWrapper:hover .copyButton { 62 | opacity: 1; 63 | } 64 | -------------------------------------------------------------------------------- /website/src/pages/getting-started.mdx: -------------------------------------------------------------------------------- 1 | import { Tab, Tabs, Cards, Card, Steps } from 'nextra-theme-docs'; 2 | import { toast } from 'sonner'; 3 | 4 | # Getting Started 5 | 6 | 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). 7 | 8 | 9 | ### Install 10 | 11 | 12 | 13 | ```bash 14 | pnpm i sonner 15 | ``` 16 | 17 | 18 | 19 | ```bash 20 | npm i sonner 21 | ``` 22 | 23 | 24 | ```bash 25 | yarn add sonner 26 | ``` 27 | 28 | 29 | ```bash 30 | bun add sonner 31 | ``` 32 | 33 | 34 | 35 | ### Add Toaster to your app 36 | 37 | It can be placed anywhere, even in server components such as `layout.tsx`. 38 | 39 | ```tsx 40 | import { Toaster } from 'sonner'; 41 | 42 | export default function RootLayout({ 43 | children, 44 | }: { 45 | children: React.ReactNode; 46 | }) { 47 | return ( 48 | 49 | 50 | {children} 51 | 52 | 53 | 54 | ); 55 | } 56 | ``` 57 | 58 | ### Render a toast 59 | 60 | ```tsx 61 | import { toast } from 'sonner'; 62 | 63 | function MyToast() { 64 | return ( 65 | 68 | ); 69 | } 70 | ``` 71 | 72 | 73 | -------------------------------------------------------------------------------- /website/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Toaster } from 'sonner'; 3 | import { Installation } from '@/src/components/Installation'; 4 | import { Hero } from '@/src/components/Hero'; 5 | import { Types } from '@/src/components/Types/Types'; 6 | import { ExpandModes } from '@/src/components/ExpandModes'; 7 | import { Footer } from '@/src/components/Footer'; 8 | import { Position } from '@/src/components/Position'; 9 | import { Usage } from '@/src/components/Usage'; 10 | import { Other } from '@/src/components/Other/Other'; 11 | 12 | export default function Home() { 13 | const [expand, setExpand] = React.useState(false); 14 | const [position, setPosition] = React.useState('bottom-right'); 15 | const [richColors, setRichColors] = React.useState(false); 16 | const [closeButton, setCloseButton] = React.useState(false); 17 | 18 | return ( 19 |
20 | 21 |
22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonner", 3 | "version": "1.0.3", 4 | "description": "An opinionated toast component for React.", 5 | "exports": { 6 | "types": "./dist/index.d.ts", 7 | "import": "./dist/index.mjs", 8 | "require": "./dist/index.js" 9 | }, 10 | "types": "./dist/index.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "build": "tsup src/index.tsx", 16 | "dev": "tsup src/index.tsx --watch", 17 | "dev:website": "turbo run dev --filter=website...", 18 | "dev:test": "turbo run dev --filter=test...", 19 | "format": "prettier --write .", 20 | "test": "playwright test" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "notifications", 25 | "toast", 26 | "snackbar", 27 | "message" 28 | ], 29 | "author": "Emil Kowalski ", 30 | "license": "MIT", 31 | "homepage": "https://sonner.emilkowal.ski/", 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/emilkowalski/sonner.git" 35 | }, 36 | "bugs": { 37 | "url": "https://github.com/emilkowalski/sonner/issues" 38 | }, 39 | "devDependencies": { 40 | "@playwright/test": "^1.30.0", 41 | "@types/node": "^18.11.13", 42 | "@types/react": "^18.0.26", 43 | "prettier": "^2.8.4", 44 | "react": "^18.2.0", 45 | "react-dom": "^18.2.0", 46 | "tsup": "^6.4.0", 47 | "turbo": "1.6", 48 | "typescript": "^4.8.4" 49 | }, 50 | "peerDependencies": { 51 | "react": "^18.0.0", 52 | "react-dom": "^18.0.0" 53 | }, 54 | "packageManager": "pnpm@6.32.11" 55 | } 56 | -------------------------------------------------------------------------------- /website/src/components/Position/index.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'sonner'; 2 | import { CodeBlock } from '../CodeBlock'; 3 | import React from 'react'; 4 | 5 | const positions = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'] as const; 6 | 7 | export type Position = (typeof positions)[number]; 8 | 9 | export const Position = ({ 10 | position: activePosition, 11 | setPosition, 12 | }: { 13 | position: Position; 14 | setPosition: React.Dispatch>; 15 | }) => { 16 | return ( 17 |
18 |

Position

19 |

Swipe direction changes depending on the position.

20 |
21 | {positions.map((position) => ( 22 | 39 | ))} 40 |
41 | {``} 42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | 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). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | [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`. 20 | 21 | 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. 22 | 23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | 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. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 39 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | 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). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | [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`. 20 | 21 | 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. 22 | 23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | 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. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 39 | -------------------------------------------------------------------------------- /src/assets.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | import { ToastTypes } from './types'; 4 | 5 | export const getAsset = (type: ToastTypes): JSX.Element | null => { 6 | switch (type) { 7 | case 'success': 8 | return SuccessIcon; 9 | 10 | case 'error': 11 | return ErrorIcon; 12 | 13 | default: 14 | return null; 15 | } 16 | }; 17 | 18 | const bars = Array(12).fill(0); 19 | 20 | export const Loader = ({ visible }: { visible: boolean }) => { 21 | return ( 22 |
23 |
24 | {bars.map((_, i) => ( 25 |
26 | ))} 27 |
28 |
29 | ); 30 | }; 31 | 32 | const SuccessIcon = ( 33 | 34 | 39 | 40 | ); 41 | 42 | const InfoIcon = ( 43 | 44 | 49 | 50 | ); 51 | 52 | const ErrorIcon = ( 53 | 54 | 59 | 60 | ); 61 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './test', 14 | /* Maximum time one test can run for. */ 15 | timeout: 30 * 1000, 16 | expect: { 17 | /** 18 | * Maximum time expect() should wait for the condition to be met. 19 | * For example in `await expect(locator).toHaveText();` 20 | */ 21 | timeout: 5000, 22 | }, 23 | /* Run tests in files in parallel */ 24 | fullyParallel: true, 25 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 26 | forbidOnly: !!process.env.CI, 27 | /* Retry on CI only */ 28 | retries: process.env.CI ? 2 : 0, 29 | /* Opt out of parallel tests on CI. */ 30 | workers: process.env.CI ? 1 : undefined, 31 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 32 | reporter: 'html', 33 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 34 | use: { 35 | trace: 'on-first-retry', 36 | baseURL: 'http://localhost:3000', 37 | }, 38 | webServer: { 39 | command: 'npm run dev', 40 | url: 'http://localhost:3000', 41 | cwd: './test', 42 | reuseExistingServer: !process.env.CI, 43 | }, 44 | /* Configure projects for major browsers */ 45 | projects: [ 46 | { 47 | name: 'chromium', 48 | use: { ...devices['Desktop Chrome'] }, 49 | }, 50 | 51 | // { 52 | // name: 'firefox', 53 | // use: { ...devices['Desktop Firefox'] }, 54 | // }, 55 | 56 | { 57 | name: 'webkit', 58 | use: { ...devices['Desktop Safari'] }, 59 | }, 60 | 61 | /* Test against mobile viewports. */ 62 | // { 63 | // name: 'Mobile Chrome', 64 | // use: { ...devices['Pixel 5'] }, 65 | // }, 66 | // { 67 | // name: 'Mobile Safari', 68 | // use: { ...devices['iPhone 12'] }, 69 | // }, 70 | 71 | /* Test against branded browsers. */ 72 | // { 73 | // name: 'Microsoft Edge', 74 | // use: { channel: 'msedge' }, 75 | // }, 76 | // { 77 | // name: 'Google Chrome', 78 | // use: { channel: 'chrome' }, 79 | // }, 80 | ], 81 | }); 82 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type ToastTypes = 'normal' | 'action' | 'success' | 'error' | 'loading'; 4 | 5 | export type PromiseT = Promise | (() => Promise); 6 | 7 | export type PromiseData = ExternalToast & { 8 | loading: string | React.ReactNode; 9 | success: string | React.ReactNode | ((data: ToastData) => React.ReactNode | string); 10 | error: string | React.ReactNode | ((error: any) => React.ReactNode | string); 11 | finally?: () => void | Promise; 12 | }; 13 | 14 | export interface ToastT { 15 | id: number | string; 16 | title?: string | React.ReactNode; 17 | type?: ToastTypes; 18 | icon?: React.ReactNode; 19 | jsx?: React.ReactNode; 20 | invert?: boolean; 21 | dismissible?: boolean; 22 | description?: React.ReactNode; 23 | duration?: number; 24 | delete?: boolean; 25 | important?: boolean; 26 | action?: { 27 | label: string; 28 | onClick: (event: React.MouseEvent) => void; 29 | }; 30 | cancel?: { 31 | label: string; 32 | onClick?: () => void; 33 | }; 34 | onDismiss?: (toast: ToastT) => void; 35 | onAutoClose?: (toast: ToastT) => void; 36 | promise?: PromiseT; 37 | style?: React.CSSProperties; 38 | className?: string; 39 | descriptionClassName?: string; 40 | position?: Position; 41 | } 42 | 43 | export type Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center'; 44 | export interface HeightT { 45 | height: number; 46 | toastId: number | string; 47 | } 48 | 49 | interface ToastOptions { 50 | className?: string; 51 | descriptionClassName?: string; 52 | style?: React.CSSProperties; 53 | duration?: number; 54 | } 55 | 56 | export interface ToasterProps { 57 | invert?: boolean; 58 | theme?: 'light' | 'dark' | 'system'; 59 | position?: Position; 60 | hotkey?: string[]; 61 | richColors?: boolean; 62 | expand?: boolean; 63 | duration?: number; 64 | gap?: number; 65 | visibleToasts?: number; 66 | closeButton?: boolean; 67 | toastOptions?: ToastOptions; 68 | className?: string; 69 | style?: React.CSSProperties; 70 | offset?: string | number; 71 | dir?: 'rtl' | 'ltr' | 'auto'; 72 | } 73 | 74 | export enum SwipeStateTypes { 75 | SwipedOut = 'SwipedOut', 76 | SwipedBack = 'SwipedBack', 77 | NotSwiped = 'NotSwiped', 78 | } 79 | 80 | export type Theme = 'light' | 'dark'; 81 | 82 | export interface ToastToDismiss { 83 | id: number | string; 84 | dismiss: boolean; 85 | } 86 | 87 | export type ExternalToast = Omit & { 88 | id?: number | string; 89 | }; 90 | -------------------------------------------------------------------------------- /website/src/components/Installation/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import copy from 'copy-to-clipboard'; 5 | import { motion, AnimatePresence, MotionConfig } from 'framer-motion'; 6 | 7 | import styles from './installation.module.css'; 8 | 9 | const variants = { 10 | visible: { opacity: 1, scale: 1 }, 11 | hidden: { opacity: 0, scale: 0.5 }, 12 | }; 13 | 14 | export const Installation = () => { 15 | const [copying, setCopying] = React.useState(0); 16 | 17 | const onCopy = React.useCallback(() => { 18 | copy('npm install sonner'); 19 | setCopying((c) => c + 1); 20 | setTimeout(() => { 21 | setCopying((c) => c - 1); 22 | }, 2000); 23 | }, []); 24 | 25 | return ( 26 |
27 |

Installation

28 | 29 | npm install sonner{' '} 30 | 69 | 70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /website/src/pages/toaster.mdx: -------------------------------------------------------------------------------- 1 | # Toaster 2 | 3 | This component renders all the toasts, you can place it anywhere in your app. 4 | 5 | ## Customization 6 | 7 | You can see examples of most of the scenarios described below on the [homepage](/). 8 | 9 | ### Expand 10 | 11 | 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. 12 | 13 | ```jsx 14 | // 9 toasts will be visible instead of the default, which is 3. 15 | 16 | ``` 17 | 18 | ### Position 19 | 20 | Changes the place where all toasts will be rendered. 21 | 22 | ```jsx 23 | // Available positions: 24 | // top-left, top-center, top-right, bottom-left, bottom-center, bottom-right 25 | 26 | ``` 27 | 28 | ### Styling all toasts 29 | 30 | You can customzie all toasts at once with `toastOptions` prop. These options witll act as the default for all toasts. 31 | 32 | ```jsx 33 | 39 | ``` 40 | 41 | ### dir 42 | 43 | Changes the directionality of the toast's text. 44 | 45 | ```jsx 46 | // rtl, ltr, auto 47 | 48 | ``` 49 | 50 | ## API Reference 51 | 52 | | Property | Description | Default | 53 | | :------------ | :------------------------------------------------------------------------------------------------: | -------------: | 54 | | theme | Toast's theme, either `light`, `dark`, or `system` | `light` | 55 | | richColors | Makes error and success state more colorful | `false` | 56 | | expand | Toasts will be expanded by default | `false` | 57 | | visibleToasts | Amount of visible toasts | `3` | 58 | | position | Place where the toasts will be rendered | `bottom-right` | 59 | | closeButton | Adds a close button to all toasts, shows on hover | `false` | 60 | | offset | Offset from the edges of the screen. | `32px` | 61 | | dir | Directionality of toast's text | `ltr` | 62 | | hotkey | Keyboard shortcut that will move focus to the toaster area. | `⌥/alt + T` | 63 | | invert | Dark toasts in light mode and vice versa. | `false` | 64 | | toastOptions | These will act as default options for all toasts. See [toast()](/toast) for all available options. | `4000` | 65 | | gap | Gap between toasts when expanded | `14` | 66 | -------------------------------------------------------------------------------- /website/src/components/Types/Types.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { toast } from 'sonner'; 3 | import { CodeBlock } from '../CodeBlock'; 4 | 5 | const promiseCode = '`${data.name} toast has been added`'; 6 | 7 | export const Types = () => { 8 | const [activeType, setActiveType] = React.useState(allTypes[0]); 9 | 10 | return ( 11 |
12 |

Types

13 |

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

14 |
15 | {allTypes.map((type) => ( 16 | 27 | ))} 28 |
29 | {`${activeType.snippet}`} 30 |
31 | ); 32 | }; 33 | 34 | const allTypes = [ 35 | { 36 | name: 'Default', 37 | snippet: `toast('Event has been created')`, 38 | action: () => toast('Event has been created'), 39 | }, 40 | { 41 | name: 'Description', 42 | snippet: `toast.message('Event has been created', { 43 | description: 'Monday, January 3rd at 6:00pm', 44 | })`, 45 | action: () => 46 | toast('Event has been created', { 47 | description: 'Monday, January 3rd at 6:00pm', 48 | }), 49 | }, 50 | { 51 | name: 'Success', 52 | snippet: `toast.success('Event has been created')`, 53 | action: () => toast.success('Event has been created'), 54 | }, 55 | { 56 | name: 'Error', 57 | snippet: `toast.error('Event has not been created')`, 58 | action: () => toast.error('Event has not been created'), 59 | }, 60 | { 61 | name: 'Action', 62 | snippet: `toast('Event has been created', { 63 | action: { 64 | label: 'Undo', 65 | onClick: () => console.log('Undo') 66 | }, 67 | })`, 68 | action: () => 69 | toast.message('Event has been created', { 70 | action: { 71 | label: 'Undo', 72 | onClick: () => console.log('Undo'), 73 | }, 74 | }), 75 | }, 76 | { 77 | name: 'Promise', 78 | snippet: `const promise = () => new Promise((resolve) => setTimeout(resolve, 2000)); 79 | 80 | toast.promise(promise, { 81 | loading: 'Loading...', 82 | success: (data) => { 83 | return ${promiseCode}; 84 | }, 85 | error: 'Error', 86 | });`, 87 | action: () => 88 | toast.promise<{ name: string }>( 89 | () => 90 | new Promise((resolve) => { 91 | setTimeout(() => { 92 | resolve({ name: 'Sonner' }); 93 | }, 2000); 94 | }), 95 | { 96 | loading: 'Loading...', 97 | success: (data) => { 98 | return `${data.name} toast has been added`; 99 | }, 100 | error: 'Error', 101 | }, 102 | ), 103 | }, 104 | { 105 | name: 'Custom', 106 | snippet: `toast(
A custom toast with default styling
)`, 107 | action: () => toast(
A custom toast with default styling
), 108 | }, 109 | ]; 110 | -------------------------------------------------------------------------------- /website/src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --gray0: #fff; 7 | --gray1: hsl(0, 0%, 99%); 8 | --gray2: hsl(0, 0%, 97.3%); 9 | --gray3: hsl(0, 0%, 95.1%); 10 | --gray4: hsl(0, 0%, 93%); 11 | --gray5: hsl(0, 0%, 90.9%); 12 | --gray6: hsl(0, 0%, 88.7%); 13 | --gray7: hsl(0, 0%, 85.8%); 14 | --gray8: hsl(0, 0%, 78%); 15 | --gray9: hsl(0, 0%, 56.1%); 16 | --gray10: hsl(0, 0%, 52.3%); 17 | --gray11: hsl(0, 0%, 43.5%); 18 | --gray12: hsl(0, 0%, 9%); 19 | --hover: rgb(40, 40, 40); 20 | --border-radius: 6px; 21 | --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, 22 | Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; 23 | --font-mono: 'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; 24 | --shiki-token-comment: var(--gray11) !important; 25 | } 26 | 27 | .dark { 28 | --gray0: #000; 29 | --gray1: hsl(0, 0%, 9.5%); 30 | --gray2: hsl(0, 0%, 10.5%); 31 | --gray3: hsl(0, 0%, 15.8%); 32 | --gray4: hsl(0, 0%, 18.9%); 33 | --gray5: hsl(0, 0%, 21.7%); 34 | --gray6: hsl(0, 0%, 24.7%); 35 | --gray7: hsl(0, 0%, 29.1%); 36 | --gray8: hsl(0, 0%, 37.5%); 37 | --gray9: hsl(0, 0%, 43%); 38 | --gray10: hsl(0, 0%, 50.7%); 39 | --gray11: hsl(0, 0%, 69.5%); 40 | --gray12: hsl(0, 0%, 93.5%); 41 | } 42 | 43 | body { 44 | padding-top: 0; 45 | } 46 | 47 | .button { 48 | padding: 8px 12px; 49 | margin: 0; 50 | background: var(--gray1); 51 | border: 1px solid var(--gray3); 52 | white-space: nowrap; 53 | border-radius: 6px; 54 | font-size: 13px; 55 | font-weight: 500; 56 | font-family: var(--font-sans); 57 | cursor: pointer; 58 | color: var(--gray12); 59 | transition: border-color 200ms, background 200ms, box-shadow 200ms; 60 | margin: 1.5rem 0 0; 61 | } 62 | 63 | .button p { 64 | line-height: 1.5; 65 | } 66 | 67 | .button:hover { 68 | background: var(--gray2); 69 | border-color: var(--gray4); 70 | } 71 | 72 | .button[data-active='true'] { 73 | background: var(--gray3); 74 | border-color: var(--gray7); 75 | } 76 | 77 | .button:focus-visible { 78 | outline: none; 79 | box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08), 80 | 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), 81 | 0 0 0 2px rgba(0, 0, 0, 0.15); 82 | } 83 | 84 | @media (max-width: 600px) { 85 | .buttons { 86 | mask-image: linear-gradient(to right, transparent, black 16px, black calc(100% - 16px), transparent); 87 | } 88 | } 89 | 90 | aside li.active a { 91 | background: var(--gray3) !important; 92 | color: var(--gray12) !important; 93 | } 94 | 95 | aside li:not(.active) a:hover { 96 | background: var(--gray2) !important; 97 | } 98 | 99 | pre { 100 | background-color: var(--gray0) !important; 101 | border: 1px solid var(--gray4); 102 | margin-bottom: 2rem !important; 103 | } 104 | 105 | button[title='Copy code'] { 106 | background: var(--gray2); 107 | color: var(--gray10); 108 | } 109 | 110 | main > p { 111 | line-height: 1.5rem !important; 112 | margin-top: 1rem !important; 113 | } 114 | 115 | .nx-text-primary-600 { 116 | color: var(--gray12) !important; 117 | } 118 | 119 | div > a:hover { 120 | color: var(--gray12) !important; 121 | } 122 | 123 | p { 124 | color: var(--gray12) !important; 125 | } 126 | 127 | footer > div { 128 | padding: 32px 24px !important; 129 | } 130 | -------------------------------------------------------------------------------- /website/src/components/Hero/hero.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 12px; 5 | align-items: center; 6 | } 7 | 8 | .toastWrapper { 9 | display: flex; 10 | flex-direction: column; 11 | margin: 0 auto; 12 | height: 100px; 13 | width: 400px; 14 | position: relative; 15 | mask-image: linear-gradient(to top, transparent 0%, black 35%); 16 | opacity: 1; 17 | } 18 | 19 | .toast { 20 | width: 356px; 21 | height: 40px; 22 | background: var(--gray0); 23 | box-shadow: 0 4px 12px #0000001a; 24 | border: 1px solid var(--gray3); 25 | border-radius: 6px; 26 | position: absolute; 27 | bottom: 0; 28 | left: 50%; 29 | transform: translateX(-50%); 30 | } 31 | 32 | .toast:nth-child(1) { 33 | transform: translateY(-60%) translateX(-50%) scale(0.9); 34 | } 35 | 36 | .toast:nth-child(2) { 37 | transform: translateY(-30%) translateX(-50%) scale(0.95); 38 | } 39 | 40 | .buttons { 41 | display: flex; 42 | gap: 8px; 43 | } 44 | 45 | .button { 46 | height: 40px; 47 | border-radius: 6px; 48 | border: none; 49 | background: linear-gradient(156deg, rgba(255, 255, 255, 1) 0%, rgba(240, 240, 240, 1) 100%); 50 | padding: 0 30px; 51 | font-weight: 600; 52 | flex-shrink: 0; 53 | font-family: inherit; 54 | box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08), 55 | 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); 56 | position: relative; 57 | overflow: hidden; 58 | cursor: pointer; 59 | text-decoration: none; 60 | color: hsl(0, 0%, 9%); 61 | font-size: 13px; 62 | display: inline-flex; 63 | align-items: center; 64 | justify-content: center; 65 | transition: box-shadow 200ms, background 200ms; 66 | width: 152px; 67 | } 68 | 69 | .button[data-primary] { 70 | box-shadow: 0px 0px 0px 1px var(--gray12); 71 | background: var(--gray12); 72 | color: var(--gray1); 73 | } 74 | .button:focus-visible { 75 | outline: none; 76 | box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08), 77 | 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), 78 | 0 0 0 2px rgba(0, 0, 0, 0.15); 79 | } 80 | 81 | .button:after { 82 | content: ''; 83 | position: absolute; 84 | top: 100%; 85 | background: blue; 86 | left: 0; 87 | width: 100%; 88 | height: 35%; 89 | background: linear-gradient( 90 | to top, 91 | hsl(0, 0%, 91%) 0%, 92 | hsla(0, 0%, 91%, 0.987) 8.1%, 93 | hsla(0, 0%, 91%, 0.951) 15.5%, 94 | hsla(0, 0%, 91%, 0.896) 22.5%, 95 | hsla(0, 0%, 91%, 0.825) 29%, 96 | hsla(0, 0%, 91%, 0.741) 35.3%, 97 | hsla(0, 0%, 91%, 0.648) 41.2%, 98 | hsla(0, 0%, 91%, 0.55) 47.1%, 99 | hsla(0, 0%, 91%, 0.45) 52.9%, 100 | hsla(0, 0%, 91%, 0.352) 58.8%, 101 | hsla(0, 0%, 91%, 0.259) 64.7%, 102 | hsla(0, 0%, 91%, 0.175) 71%, 103 | hsla(0, 0%, 91%, 0.104) 77.5%, 104 | hsla(0, 0%, 91%, 0.049) 84.5%, 105 | hsla(0, 0%, 91%, 0.013) 91.9%, 106 | hsla(0, 0%, 91%, 0) 100% 107 | ); 108 | opacity: 0.6; 109 | transition: transform 200ms; 110 | } 111 | 112 | .button:hover:not([data-primary]):after { 113 | transform: translateY(-100%); 114 | } 115 | 116 | .button[data-primary]:hover { 117 | background: var(--hover); 118 | } 119 | 120 | .heading { 121 | font-size: 48px; 122 | font-weight: 700; 123 | margin: -20px 0 12px; 124 | } 125 | 126 | .wrapper p { 127 | margin-bottom: 12px; 128 | } 129 | 130 | @media (max-width: 600px) { 131 | .toastWrapper { 132 | width: 100%; 133 | } 134 | } 135 | 136 | .link { 137 | color: var(--gray11) !important; 138 | font-size: 14px; 139 | text-decoration: underline; 140 | } 141 | -------------------------------------------------------------------------------- /website/src/components/Other/Other.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMemo } from 'react'; 3 | import { toast } from 'sonner'; 4 | import { CodeBlock } from '../CodeBlock'; 5 | import styles from './other.module.css'; 6 | 7 | export const Other = ({ 8 | setRichColors, 9 | setCloseButton, 10 | }: { 11 | setRichColors: React.Dispatch>; 12 | setCloseButton: React.Dispatch>; 13 | }) => { 14 | const allTypes = useMemo( 15 | () => [ 16 | { 17 | name: 'Rich Colors Success', 18 | snippet: `toast.success('Event has been created')`, 19 | action: () => { 20 | toast.success('Event has been created'); 21 | setRichColors(true); 22 | }, 23 | }, 24 | { 25 | name: 'Rich Colors Error', 26 | snippet: `toast.error('Event has not been created')`, 27 | action: () => { 28 | toast.error('Event has not been created'); 29 | setRichColors(true); 30 | }, 31 | }, 32 | { 33 | name: 'Close Button', 34 | snippet: `toast('Event has been created', { 35 | description: 'Monday, January 3rd at 6:00pm', 36 | })`, 37 | action: () => { 38 | toast('Event has been created', { 39 | description: 'Monday, January 3rd at 6:00pm', 40 | }); 41 | setCloseButton((t) => !t); 42 | }, 43 | }, 44 | { 45 | name: 'Headless', 46 | snippet: `toast.custom((t) => ( 47 |
48 |

Custom toast

49 | 50 |
51 | ));`, 52 | action: () => { 53 | toast.custom( 54 | (t) => ( 55 |
56 |

Event Created

57 |

Today at 4:00pm - "Louvre Museum"

58 | 63 |
64 | ), 65 | { duration: 999999 }, 66 | ); 67 | setCloseButton((t) => !t); 68 | }, 69 | }, 70 | ], 71 | [setRichColors], 72 | ); 73 | 74 | const [activeType, setActiveType] = React.useState(allTypes[0]); 75 | 76 | const richColorsActive = activeType?.name?.includes('Rich'); 77 | const closeButtonActive = activeType?.name?.includes('Close'); 78 | 79 | return ( 80 |
81 |

Other

82 |
83 | {allTypes.map((type) => ( 84 | 94 | ))} 95 |
96 | 97 | {`${activeType.snippet || ''} 98 | 99 | // ... 100 | 101 | `} 102 | 103 |
104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /website/src/components/CodeBlock/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Highlight, { defaultProps } from 'prism-react-renderer'; 3 | import useMeasure from 'react-use-measure'; 4 | import copy from 'copy-to-clipboard'; 5 | import { AnimatePresence, motion, MotionConfig } from 'framer-motion'; 6 | 7 | import styles from './code-block.module.css'; 8 | 9 | const variants = { 10 | visible: { opacity: 1, scale: 1 }, 11 | hidden: { opacity: 0, scale: 0.5 }, 12 | }; 13 | 14 | const theme = { 15 | plain: { 16 | color: 'var(--gray12)', 17 | fontSize: 12, 18 | fontFamily: 'var(--font-mono)', 19 | }, 20 | styles: [ 21 | { 22 | types: ['comment'], 23 | style: { 24 | color: 'var(--gray9)', 25 | }, 26 | }, 27 | { 28 | types: ['atrule', 'keyword', 'attr-name', 'selector'], 29 | style: { 30 | color: 'var(--gray10)', 31 | }, 32 | }, 33 | { 34 | types: ['punctuation', 'operator'], 35 | style: { 36 | color: 'var(--gray9)', 37 | }, 38 | }, 39 | { 40 | types: ['class-name', 'function', 'tag'], 41 | style: { 42 | color: 'var(--gray12)', 43 | }, 44 | }, 45 | ], 46 | }; 47 | 48 | export const CodeBlock = ({ children, initialHeight = 0 }: { children: string; initialHeight?: number }) => { 49 | const [ref, bounds] = useMeasure(); 50 | const [copying, setCopying] = React.useState(0); 51 | 52 | const onCopy = React.useCallback(() => { 53 | copy(children); 54 | setCopying((c) => c + 1); 55 | setTimeout(() => { 56 | setCopying((c) => c - 1); 57 | }, 2000); 58 | }, [children]); 59 | 60 | return ( 61 |
62 | 101 | {/* @ts-ignore */} 102 | 103 | {({ className, tokens, getLineProps, getTokenProps }) => ( 104 | 109 |
110 |
111 | {tokens.map((line, i) => { 112 | const { key: lineKey, ...rest } = getLineProps({ line, key: i }); 113 | return ( 114 |
115 | {line.map((token, key) => { 116 | const { key: tokenKey, ...rest } = getTokenProps({ token, key }); 117 | return ; 118 | })} 119 |
120 | ); 121 | })} 122 |
123 | 124 | )} 125 | 126 |
127 | ); 128 | }; 129 | -------------------------------------------------------------------------------- /test/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { Toaster, toast } from 'sonner'; 5 | 6 | const promise = () => new Promise((resolve) => setTimeout(resolve, 2000)); 7 | 8 | export default function Home({ searchParams }: any) { 9 | const [showAutoClose, setShowAutoClose] = React.useState(false); 10 | const [showDismiss, setShowDismiss] = React.useState(false); 11 | const [theme, setTheme] = React.useState(searchParams.theme || 'light'); 12 | const [isFinally, setIsFinally] = React.useState(false); 13 | 14 | return ( 15 | <> 16 | 19 | 22 | 25 | 28 | 31 | 45 | 62 | 77 | 87 |
88 | )) 89 | } 90 | > 91 | Render Custom Toast 92 | 93 | 96 | 107 | 118 | 129 | 144 | {showAutoClose ?
: null} 145 | {showDismiss ?
: null} 146 | 147 | 148 | ); 149 | } 150 | 151 | Home.theme = 'light'; 152 | -------------------------------------------------------------------------------- /website/src/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --gray0: #fff; 3 | --gray1: hsl(0, 0%, 99%); 4 | --gray2: hsl(0, 0%, 97.3%); 5 | --gray3: hsl(0, 0%, 95.1%); 6 | --gray4: hsl(0, 0%, 93%); 7 | --gray5: hsl(0, 0%, 90.9%); 8 | --gray6: hsl(0, 0%, 88.7%); 9 | --gray7: hsl(0, 0%, 85.8%); 10 | --gray8: hsl(0, 0%, 78%); 11 | --gray9: hsl(0, 0%, 56.1%); 12 | --gray10: hsl(0, 0%, 52.3%); 13 | --gray11: hsl(0, 0%, 43.5%); 14 | --gray12: hsl(0, 0%, 9%); 15 | --hover: rgb(40, 40, 40); 16 | --border-radius: 6px; 17 | --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, 18 | Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; 19 | --font-mono: 'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; 20 | } 21 | 22 | .dark { 23 | --gray0: #000; 24 | --gray1: hsl(0, 0%, 9.5%); 25 | --gray2: hsl(0, 0%, 10.5%); 26 | --gray3: hsl(0, 0%, 15.8%); 27 | --gray4: hsl(0, 0%, 18.9%); 28 | --gray5: hsl(0, 0%, 21.7%); 29 | --gray6: hsl(0, 0%, 24.7%); 30 | --gray7: hsl(0, 0%, 29.1%); 31 | --gray8: hsl(0, 0%, 37.5%); 32 | --gray9: hsl(0, 0%, 43%); 33 | --gray10: hsl(0, 0%, 50.7%); 34 | --gray11: hsl(0, 0%, 69.5%); 35 | --gray12: hsl(0, 0%, 93.5%); 36 | } 37 | 38 | ::selection { 39 | background: var(--gray7); 40 | } 41 | 42 | .container { 43 | max-width: 642px; 44 | margin: 0 auto; 45 | padding-left: max(var(--side-padding), env(safe-area-inset-left)); 46 | padding-right: max(var(--side-padding), env(safe-area-inset-right)); 47 | } 48 | 49 | .wrapper { 50 | --side-padding: 16px; 51 | margin: 0; 52 | padding: 0; 53 | padding-top: 100px; 54 | font-family: var(--font-sans); 55 | -webkit-font-smoothing: antialiased; 56 | } 57 | 58 | /* Disable double-tap zoom */ 59 | * { 60 | touch-action: manipulation; 61 | } 62 | 63 | h1, 64 | p { 65 | color: var(--gray12); 66 | } 67 | 68 | h2 { 69 | font-size: 16px; 70 | color: var(--gray12); 71 | font-weight: 500; 72 | } 73 | 74 | h2 + p { 75 | margin-top: -4px; 76 | } 77 | 78 | p { 79 | font-size: 16px; 80 | } 81 | 82 | a { 83 | color: inherit; 84 | text-decoration-color: var(--gray10); 85 | text-underline-position: from-font; 86 | } 87 | 88 | code { 89 | font-size: 13px; 90 | line-height: 28px; 91 | padding: 2px 3.6px; 92 | border: 1px solid var(--gray3); 93 | background: var(--gray4); 94 | font-family: var(--font-mono); 95 | border-radius: 6px; 96 | } 97 | 98 | .content { 99 | display: flex; 100 | flex-direction: column; 101 | gap: 48px; 102 | margin-top: 96px; 103 | } 104 | 105 | .buttons { 106 | display: flex; 107 | gap: 8px; 108 | overflow: auto; 109 | margin: 0 calc(-1 * var(--side-padding)); 110 | padding: 4px var(--side-padding); 111 | position: relative; 112 | } 113 | 114 | .button { 115 | padding: 8px 12px; 116 | margin: 0; 117 | background: var(--gray1); 118 | border: 1px solid var(--gray3); 119 | white-space: nowrap; 120 | border-radius: 6px; 121 | font-size: 13px; 122 | font-weight: 500; 123 | font-family: var(--font-sans); 124 | cursor: pointer; 125 | color: var(--gray12); 126 | transition: border-color 200ms, background 200ms, box-shadow 200ms; 127 | } 128 | 129 | .button:hover { 130 | background: var(--gray2); 131 | border-color: var(--gray4); 132 | } 133 | 134 | .button[data-active='true'] { 135 | background: var(--gray3); 136 | border-color: var(--gray7); 137 | } 138 | 139 | .button:focus-visible { 140 | outline: none; 141 | box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08), 142 | 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), 143 | 0 0 0 2px rgba(0, 0, 0, 0.15); 144 | } 145 | 146 | @media (max-width: 600px) { 147 | .buttons { 148 | mask-image: linear-gradient(to right, transparent, black 16px, black calc(100% - 16px), transparent); 149 | } 150 | } 151 | 152 | .wrapper h1, 153 | .wrapper p { 154 | color: var(--gray12); 155 | line-height: 25px; 156 | } 157 | m .wrapper h2 { 158 | font-size: 16px; 159 | color: var(--gray12); 160 | font-weight: 500; 161 | } 162 | 163 | .wrapper h2 + p { 164 | margin-top: -4px; 165 | } 166 | 167 | .wrapper h2 { 168 | margin: 12px 0; 169 | } 170 | 171 | .wrapper p { 172 | font-size: 16px; 173 | margin-bottom: 16px; 174 | } 175 | 176 | .wrapper a { 177 | color: inherit; 178 | text-decoration-color: var(--gray10); 179 | text-underline-position: from-font; 180 | } 181 | 182 | .wrapper .content { 183 | display: flex; 184 | flex-direction: column; 185 | gap: 48px; 186 | margin-top: 96px; 187 | } 188 | 189 | .wrapper footer { 190 | padding: 0; 191 | } 192 | 193 | .wrapper footer .container { 194 | padding: 32px 16px !important; 195 | } 196 | 197 | .wrapper footer p { 198 | margin: 0; 199 | font-size: 14px; 200 | } 201 | 202 | footer { 203 | background: var(--gray1) !important; 204 | } 205 | 206 | hr { 207 | background: var(--gray3) !important; 208 | } 209 | 210 | .nx-border-primary-500 { 211 | border-color: var(--gray12) !important; 212 | } 213 | 214 | .nx-bg-primary-500\/10 { 215 | background: var(--gray3) !important; 216 | } 217 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | ExternalToast, 4 | ToastT, 5 | PromiseData, 6 | PromiseT, 7 | ToastToDismiss, 8 | ToastTypes, 9 | } from './types'; 10 | 11 | let toastsCounter = 1; 12 | 13 | class Observer { 14 | subscribers: Array<(toast: ExternalToast | ToastToDismiss) => void>; 15 | toasts: Array; 16 | 17 | constructor() { 18 | this.subscribers = []; 19 | this.toasts = []; 20 | } 21 | 22 | // We use arrow functions to maintain the correct `this` reference 23 | subscribe = ( 24 | subscriber: (toast: ToastT | ToastToDismiss) => void, 25 | ) => { 26 | this.subscribers.push(subscriber); 27 | 28 | return () => { 29 | const index = this.subscribers.indexOf(subscriber); 30 | this.subscribers.splice(index, 1); 31 | }; 32 | }; 33 | 34 | publish = (data: ToastT) => { 35 | this.subscribers.forEach((subscriber) => subscriber(data)); 36 | }; 37 | 38 | addToast = (data: ToastT) => { 39 | this.publish(data); 40 | this.toasts = [...this.toasts, data]; 41 | }; 42 | 43 | create = ( 44 | data: ExternalToast & { 45 | message?: string | React.ReactNode; 46 | type?: ToastTypes; 47 | promise?: PromiseT; 48 | }, 49 | ) => { 50 | const { message, ...rest } = data; 51 | const id = 52 | typeof data?.id === 'number' || data.id?.length > 0 53 | ? data.id 54 | : toastsCounter++; 55 | const alreadyExists = this.toasts.find((toast) => { 56 | return toast.id === id; 57 | }); 58 | const dismissible = 59 | data.dismissible === undefined ? true : data.dismissible; 60 | 61 | if (alreadyExists) { 62 | this.toasts = this.toasts.map((toast) => { 63 | if (toast.id === id) { 64 | this.publish({ ...toast, ...data, id, title: message }); 65 | return { 66 | ...toast, 67 | ...data, 68 | id, 69 | dismissible, 70 | title: message, 71 | }; 72 | } 73 | 74 | return toast; 75 | }); 76 | } else { 77 | this.addToast({ title: message, ...rest, dismissible, id }); 78 | } 79 | 80 | return id; 81 | }; 82 | 83 | dismiss = (id?: number | string) => { 84 | if (!id) { 85 | this.toasts.forEach((toast) => { 86 | this.subscribers.forEach((subscriber) => 87 | subscriber({ id: toast.id, dismiss: true }), 88 | ); 89 | }); 90 | } 91 | 92 | this.subscribers.forEach((subscriber) => 93 | subscriber({ id, dismiss: true }), 94 | ); 95 | return id; 96 | }; 97 | 98 | message = ( 99 | message: string | React.ReactNode, 100 | data?: ExternalToast, 101 | ) => { 102 | return this.create({ ...data, message }); 103 | }; 104 | 105 | error = ( 106 | message: string | React.ReactNode, 107 | data?: ExternalToast, 108 | ) => { 109 | return this.create({ ...data, message, type: 'error' }); 110 | }; 111 | 112 | success = ( 113 | message: string | React.ReactNode, 114 | data?: ExternalToast, 115 | ) => { 116 | return this.create({ ...data, type: 'success', message }); 117 | }; 118 | 119 | loading = ( 120 | message: string | React.ReactNode, 121 | data?: ExternalToast, 122 | ) => { 123 | return this.create({ ...data, type: 'loading', message }); 124 | }; 125 | 126 | promise = ( 127 | promise: PromiseT, 128 | data?: PromiseData, 129 | ) => { 130 | if (!data) { 131 | // Nothing to show 132 | return; 133 | } 134 | 135 | let id: string | number | undefined = undefined; 136 | if (data.loading !== undefined) { 137 | id = this.create({ 138 | ...data, 139 | promise, 140 | type: 'loading', 141 | message: data.loading, 142 | }); 143 | } 144 | 145 | const p = promise instanceof Promise ? promise : promise(); 146 | 147 | let shouldDismiss = id !== undefined; 148 | 149 | p.then((promiseData) => { 150 | if (data.success !== undefined) { 151 | shouldDismiss = false; 152 | const message = typeof data.success === 'function' ? data.success(promiseData) : data.success; 153 | this.create({ id, type: 'success', message }); 154 | } 155 | }) 156 | .catch((error) => { 157 | if (data.error !== undefined) { 158 | shouldDismiss = false; 159 | const message = typeof data.error === 'function' ? data.error(error) : data.error; 160 | this.create({ id, type: 'error', message }); 161 | } 162 | }) 163 | .finally(() => { 164 | if (shouldDismiss) { 165 | // Toast is still in load state (and will be indefinitely — dismiss it) 166 | this.dismiss(id); 167 | id = undefined; 168 | } 169 | 170 | data.finally?.(); 171 | }); 172 | 173 | return id; 174 | }; 175 | 176 | // We can't provide the toast we just created as a prop as we didn't create it yet, so we can create a default toast object, I just don't know how to use function in argument when calling()? 177 | custom = ( 178 | jsx: (id: number | string) => React.ReactElement, 179 | data?: ExternalToast, 180 | ) => { 181 | const id = data?.id || toastsCounter++; 182 | this.publish({ jsx: jsx(id), id, ...data }); 183 | }; 184 | } 185 | 186 | export const ToastState = new Observer(); 187 | 188 | // bind this to the toast function 189 | const toastFunction = ( 190 | message: string | React.ReactNode, 191 | data?: ExternalToast, 192 | ) => { 193 | const id = data?.id || toastsCounter++; 194 | 195 | ToastState.addToast({ 196 | title: message, 197 | ...data, 198 | id, 199 | }); 200 | return id; 201 | }; 202 | 203 | const basicToast = toastFunction; 204 | 205 | // We use `Object.assign` to maintain the correct types as we would lose them otherwise 206 | export const toast = Object.assign(basicToast, { 207 | success: ToastState.success, 208 | error: ToastState.error, 209 | custom: ToastState.custom, 210 | message: ToastState.message, 211 | promise: ToastState.promise, 212 | dismiss: ToastState.dismiss, 213 | loading: ToastState.loading, 214 | }); 215 | -------------------------------------------------------------------------------- /website/src/pages/toast.mdx: -------------------------------------------------------------------------------- 1 | import { toast } from 'sonner'; 2 | 3 | # Toast() 4 | 5 | Use it to render a toast. You can call it from anywhere, even outside of React. 6 | 7 | ## Rendering the toast 8 | 9 | You can call it with just a string. 10 | 11 | ```jsx 12 | import { toast } from 'sonner'; 13 | 14 | toast('Hello World!'); 15 | ``` 16 | 17 | Or provide an object as the second argument with more options. They will overwrite the options passed to [``](/toaster) if you have provided any. 18 | 19 | ```jsx 20 | import { toast } from 'sonner'; 21 | 22 | toast('My toast', { 23 | className: 'my-classname', 24 | description: 'My description', 25 | duration: 5000, 26 | icon: , 27 | }); 28 | ``` 29 | 30 | ## Creating toasts 31 | 32 | ### Success 33 | 34 | Renders a checkmark icon in front of the message. 35 | 36 | ```jsx 37 | toast.success('My success toast'); 38 | ``` 39 | 40 | ### Error 41 | 42 | Renders an error icon in front of the message. 43 | 44 | ```jsx 45 | toast.error('My error toast'); 46 | ``` 47 | 48 | ### Action 49 | 50 | 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. 51 | 52 | ```jsx 53 | toast('My action toast', { 54 | action: { 55 | label: 'Action', 56 | onClick: () => console.log('Action!'), 57 | }, 58 | }); 59 | ``` 60 | 61 | ### Cancel 62 | 63 | Renders a secondary button, clicking it will close the toast and run the callback passed via `onClick`. 64 | 65 | ```jsx 66 | toast('My cancel toast', { 67 | action: { 68 | label: 'Cancel', 69 | onClick: () => console.log('Cancel!'), 70 | }, 71 | }); 72 | ``` 73 | 74 | ### Promise 75 | 76 | Starts in a loading state and will update automatically after the promise resolves or fails. 77 | You can pass a function to the success/error messages to incorporate the result/error of the promise. 78 | 79 | ```jsx 80 | toast.promise(myPromise, { 81 | loading: 'Loading...', 82 | success: (data) => { 83 | return `${data.name} toast has been added`; 84 | }, 85 | error: 'Error', 86 | }); 87 | ``` 88 | 89 | ### Loading 90 | 91 | Renders a toast with a loading spinner. Useful when you want to handle various states yourself instead of using a promise toast. 92 | 93 | ```jsx 94 | toast.loading('Loading data'); 95 | ``` 96 | 97 | ### Custom 98 | 99 | You can pass jsx as the first argument instead of a string to render a custom toast while maintaining default styling. 100 | 101 | ```jsx 102 | toast(
A custom toast with default styling
, { duration: 5000 }); 103 | ``` 104 | 105 | ### Headless 106 | 107 | 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. 108 | 109 | ```jsx 110 | toast.custom((t) => ( 111 |
112 | This is a custom component 113 |
114 | )); 115 | ``` 116 | 117 | ### Dynamic Position 118 | 119 | You can change the position of the toast dynamically by passing a `position` prop to the toast 120 | function. It will not affect the positioning of other toasts. 121 | 122 | ```jsx 123 | // Available positions: 124 | // top-left, top-center, top-right, bottom-left, bottom-center, bottom-right 125 | toast('Hello World', { 126 | position: 'top-center', 127 | }); 128 | ``` 129 | 130 | ## Other 131 | 132 | ### On Close Callback 133 | 134 | 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). 135 | 136 | ```jsx 137 | toast('Event has been created', { 138 | onDismiss: (t) => console.log(`Toast with id ${t.id} has been dismissed`), 139 | onAutoClose: (t) => console.log(`Toast with id ${t.id} has been closed automatically`), 140 | }); 141 | ``` 142 | 143 | ### Dismissing toasts programmatically 144 | 145 | To remove a toast programmatically use `toast.dismiss(id)`. The `toast()` function return the id of the toast. 146 | 147 | ```jsx 148 | const toastId = toast('Event has been created'); 149 | 150 | toast.dismiss(toastId); 151 | ``` 152 | 153 | ## API Reference 154 | 155 | | Property | Description | Default | 156 | | :---------- | :----------------------------------------------------------------------------------------------------: | -------------: | 157 | | description | Toast's description, renders underneath the title. | `-` | 158 | | closeButton | Adds a close button which shows on hover. | `false` | 159 | | invert | Dark toast in light mode and vice versa. | `false` | 160 | | important | Control the sensitivity of the toast for screen readers | `false` | 161 | | duration | Time in milliseconds that should elapse before automatically closing the toast. | `4000` | 162 | | position | Position of the toast. | `bottom-right` | 163 | | dismissible | If `false`, it'll prevent the user from dismissing the toast. | `true` | 164 | | icon | Icon displayed in front of toast's text, aligned vertically. | `-` | 165 | | action | Renders a primary button, clicking it will close the toast. | `-` | 166 | | cancel | Renders a secondary button, clicking it will close the toast. | `-` | 167 | | id | Custom id for the toast. | `-` | 168 | | onDismiss | The function gets called when either the close button is clicked, or the toast is swiped. | `-` | 169 | | onAutoClose | Function that gets called when the toast disappears automatically after it's timeout (duration` prop). | `-` | 170 | -------------------------------------------------------------------------------- /test/tests/basic.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/'); 5 | }); 6 | 7 | test.describe('Basic functionality', () => { 8 | test('toast is rendered and disappears after the default timeout', async ({ page }) => { 9 | await page.getByTestId('default-button').click(); 10 | await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); 11 | await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); 12 | }); 13 | 14 | test('various toast types are rendered correctly', async ({ page }) => { 15 | await page.getByTestId('success').click(); 16 | await expect(page.getByText('My Success Toast', { exact: true })).toHaveCount(1); 17 | 18 | await page.getByTestId('error').click(); 19 | await expect(page.getByText('My Error Toast', { exact: true })).toHaveCount(1); 20 | 21 | await page.getByTestId('action').click(); 22 | await expect(page.locator('[data-button]')).toHaveCount(1); 23 | }); 24 | 25 | test('show correct toast content based on promise state', async ({ page }) => { 26 | await page.getByTestId('promise').click(); 27 | await expect(page.getByText('Loading...')).toHaveCount(1); 28 | await expect(page.getByText('Loaded')).toHaveCount(1); 29 | }); 30 | 31 | test('render custom jsx in toast', async ({ page }) => { 32 | await page.getByTestId('custom').click(); 33 | await expect(page.getByText('jsx')).toHaveCount(1); 34 | }); 35 | 36 | test('toast is removed after swiping down', async ({ page }) => { 37 | await page.getByTestId('default-button').click(); 38 | await page.hover('[data-sonner-toast]'); 39 | await page.mouse.down(); 40 | await page.mouse.move(0, 800); 41 | await page.mouse.up(); 42 | await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); 43 | }); 44 | 45 | test('dismissible toast is not removed when dragged', async ({ page }) => { 46 | await page.getByTestId('non-dismissible-toast').click(); 47 | const toast = page.locator('[data-sonner-toast]'); 48 | const dragBoundingBox = await toast.boundingBox(); 49 | 50 | if (!dragBoundingBox) return; 51 | await page.mouse.move(dragBoundingBox.x + dragBoundingBox.width / 2, dragBoundingBox.y); 52 | 53 | await page.mouse.down(); 54 | await page.mouse.move(0, dragBoundingBox.y + 300); 55 | 56 | await page.mouse.up(); 57 | await expect(page.getByTestId('non-dismissible-toast')).toHaveCount(1); 58 | }); 59 | 60 | test('toast is removed after swiping up', async ({ page }) => { 61 | await page.goto('/?position=top-left'); 62 | await page.getByTestId('default-button').click(); 63 | await page.hover('[data-sonner-toast]'); 64 | await page.mouse.down(); 65 | await page.mouse.move(0, -800); 66 | await page.mouse.up(); 67 | await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); 68 | }); 69 | 70 | test('toast is not removed when hovered', async ({ page }) => { 71 | await page.getByTestId('default-button').click(); 72 | await page.hover('[data-sonner-toast]'); 73 | const timeout = new Promise((resolve) => setTimeout(resolve, 5000)); 74 | await timeout; 75 | await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); 76 | }); 77 | 78 | test('toast is not removed if duration is set to infinity', async ({ page }) => { 79 | await page.getByTestId('infinity-toast').click(); 80 | await page.hover('[data-sonner-toast]'); 81 | const timeout = new Promise((resolve) => setTimeout(resolve, 5000)); 82 | await timeout; 83 | await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); 84 | }); 85 | 86 | test('toast is not removed when event prevented in action', async ({ page }) => { 87 | await page.getByTestId('action-prevent').click(); 88 | await page.locator('[data-button]').click(); 89 | await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); 90 | }); 91 | 92 | test("toast's auto close callback gets executed correctly", async ({ page }) => { 93 | await page.getByTestId('auto-close-toast-callback').click(); 94 | await expect(page.getByTestId('auto-close-el')).toHaveCount(1); 95 | }); 96 | 97 | test("toast's dismiss callback gets executed correctly", async ({ page }) => { 98 | await page.getByTestId('dismiss-toast-callback').click(); 99 | const toast = page.locator('[data-sonner-toast]'); 100 | const dragBoundingBox = await toast.boundingBox(); 101 | 102 | if (!dragBoundingBox) return; 103 | await page.mouse.move(dragBoundingBox.x + dragBoundingBox.width / 2, dragBoundingBox.y); 104 | 105 | await page.mouse.down(); 106 | await page.mouse.move(0, dragBoundingBox.y + 300); 107 | 108 | await page.mouse.up(); 109 | await expect(page.getByTestId('dismiss-el')).toHaveCount(1); 110 | }); 111 | 112 | test("toaster's theme should be light", async ({ page }) => { 113 | await page.getByTestId('infinity-toast').click(); 114 | await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('data-theme', 'light'); 115 | }); 116 | 117 | test("toaster's theme should be dark", async ({ page }) => { 118 | await page.goto('/?theme=dark'); 119 | await page.getByTestId('infinity-toast').click(); 120 | await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('data-theme', 'dark'); 121 | }); 122 | 123 | test("toaster's theme should be changed", async ({ page }) => { 124 | await page.getByTestId('infinity-toast').click(); 125 | await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('data-theme', 'light'); 126 | await page.getByTestId('theme-button').click(); 127 | await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('data-theme', 'dark'); 128 | }); 129 | 130 | test('return focus to the previous focused element', async ({ page }) => { 131 | await page.getByTestId('custom').focus(); 132 | await page.keyboard.press('Enter'); 133 | await expect(page.locator('[data-sonner-toast]')).toHaveCount(1); 134 | await page.getByTestId('dismiss-button').focus(); 135 | await page.keyboard.press('Enter'); 136 | await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); 137 | await expect(page.getByTestId('custom')).toBeFocused(); 138 | }); 139 | 140 | test("toaster's dir prop is reflected correctly", async ({ page }) => { 141 | await page.goto('/?dir=rtl'); 142 | await page.getByTestId('default-button').click(); 143 | await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('dir', 'rtl'); 144 | }); 145 | 146 | test("toaster respects the HTML's dir attribute", async ({ page }) => { 147 | await page.evaluate(() => { 148 | document.documentElement.setAttribute('dir', 'rtl'); 149 | }); 150 | await page.getByTestId('default-button').click(); 151 | await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('dir', 'rtl'); 152 | }); 153 | 154 | test("toaster respects its own dir attribute over HTML's", async ({ page }) => { 155 | await page.goto('/?dir=ltr'); 156 | await page.evaluate(() => { 157 | document.documentElement.setAttribute('dir', 'rtl'); 158 | }); 159 | await page.getByTestId('default-button').click(); 160 | await expect(page.locator('[data-sonner-toaster]')).toHaveAttribute('dir', 'ltr'); 161 | }); 162 | 163 | test('show correct toast content when updating', async ({ page }) => { 164 | await page.getByTestId('update-toast').click(); 165 | await expect(page.getByText('My Unupdated Toast')).toHaveCount(0); 166 | await expect(page.getByText('My Updated Toast')).toHaveCount(1); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | html[dir='ltr'], 2 | [data-sonner-toaster][dir='ltr'] { 3 | --toast-icon-margin-start: -3px; 4 | --toast-icon-margin-end: 4px; 5 | --toast-svg-margin-start: -1px; 6 | --toast-svg-margin-end: 0px; 7 | --toast-button-margin-start: auto; 8 | --toast-button-margin-end: 0; 9 | --toast-close-button-start: 0; 10 | --toast-close-button-end: unset; 11 | --toast-close-button-transform: translate(-35%, -35%); 12 | } 13 | 14 | html[dir='rtl'], 15 | [data-sonner-toaster][dir='rtl'] { 16 | --toast-icon-margin-start: 4px; 17 | --toast-icon-margin-end: -3px; 18 | --toast-svg-margin-start: 0px; 19 | --toast-svg-margin-end: -1px; 20 | --toast-button-margin-start: 0; 21 | --toast-button-margin-end: auto; 22 | --toast-close-button-start: unset; 23 | --toast-close-button-end: 0; 24 | --toast-close-button-transform: translate(35%, -35%); 25 | } 26 | 27 | [data-sonner-toaster] { 28 | position: fixed; 29 | width: var(--width); 30 | font-family: ui-sans-serif, system-ui, -apple-system, 31 | BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, 32 | Arial, Noto Sans, sans-serif, Apple Color Emoji, 33 | Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; 34 | --gray1: hsl(0, 0%, 99%); 35 | --gray2: hsl(0, 0%, 97.3%); 36 | --gray3: hsl(0, 0%, 95.1%); 37 | --gray4: hsl(0, 0%, 93%); 38 | --gray5: hsl(0, 0%, 90.9%); 39 | --gray6: hsl(0, 0%, 88.7%); 40 | --gray7: hsl(0, 0%, 85.8%); 41 | --gray8: hsl(0, 0%, 78%); 42 | --gray9: hsl(0, 0%, 56.1%); 43 | --gray10: hsl(0, 0%, 52.3%); 44 | --gray11: hsl(0, 0%, 43.5%); 45 | --gray12: hsl(0, 0%, 9%); 46 | --border-radius: 8px; 47 | box-sizing: border-box; 48 | padding: 0; 49 | margin: 0; 50 | list-style: none; 51 | outline: none; 52 | z-index: 999999999; 53 | } 54 | 55 | [data-sonner-toaster][data-x-position='right'] { 56 | right: max(var(--offset), env(safe-area-inset-right)); 57 | } 58 | 59 | [data-sonner-toaster][data-x-position='left'] { 60 | left: max(var(--offset), env(safe-area-inset-left)); 61 | } 62 | 63 | [data-sonner-toaster][data-x-position='center'] { 64 | left: 50%; 65 | transform: translateX(-50%); 66 | } 67 | 68 | [data-sonner-toaster][data-y-position='top'] { 69 | top: max(var(--offset), env(safe-area-inset-top)); 70 | } 71 | 72 | [data-sonner-toaster][data-y-position='bottom'] { 73 | bottom: max(var(--offset), env(safe-area-inset-bottom)); 74 | } 75 | 76 | [data-sonner-toast] { 77 | --y: translateY(100%); 78 | --lift-amount: calc(var(--lift) * var(--gap)); 79 | z-index: var(--z-index); 80 | position: absolute; 81 | opacity: 0; 82 | transform: var(--y); 83 | /* https://stackoverflow.com/questions/48124372/pointermove-event-not-working-with-touch-why-not */ 84 | touch-action: none; 85 | will-change: transform, opacity, height; 86 | transition: transform 400ms, opacity 400ms, height 400ms, 87 | box-shadow 200ms; 88 | box-sizing: border-box; 89 | outline: none; 90 | overflow-wrap: anywhere; 91 | } 92 | 93 | [data-sonner-toast][data-styled='true'] { 94 | padding: 16px; 95 | background: var(--normal-bg); 96 | border: 1px solid var(--normal-border); 97 | color: var(--normal-text); 98 | border-radius: var(--border-radius); 99 | box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); 100 | width: var(--width); 101 | font-size: 13px; 102 | display: flex; 103 | align-items: center; 104 | gap: 6px; 105 | } 106 | 107 | [data-sonner-toast]:focus-visible { 108 | box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 109 | 0 0 0 2px rgba(0, 0, 0, 0.2); 110 | } 111 | 112 | [data-sonner-toast][data-y-position='top'] { 113 | top: 0; 114 | --y: translateY(-100%); 115 | --lift: 1; 116 | --lift-amount: calc(1 * var(--gap)); 117 | } 118 | 119 | [data-sonner-toast][data-y-position='bottom'] { 120 | bottom: 0; 121 | --y: translateY(100%); 122 | --lift: -1; 123 | --lift-amount: calc(var(--lift) * var(--gap)); 124 | } 125 | 126 | [data-sonner-toast] [data-description] { 127 | font-weight: 400; 128 | line-height: 1.4; 129 | color: inherit; 130 | } 131 | 132 | [data-sonner-toast] [data-title] { 133 | font-weight: 500; 134 | line-height: 1.5; 135 | color: inherit; 136 | } 137 | 138 | [data-sonner-toast] [data-icon] { 139 | display: flex; 140 | height: 16px; 141 | width: 16px; 142 | position: relative; 143 | justify-content: flex-start; 144 | align-items: center; 145 | flex-shrink: 0; 146 | margin-left: var(--toast-icon-margin-start); 147 | margin-right: var(--toast-icon-margin-end); 148 | } 149 | 150 | [data-sonner-toast][data-promise='true'] [data-icon] > svg { 151 | opacity: 0; 152 | transform: scale(0.8); 153 | transform-origin: center; 154 | animation: sonner-fade-in 300ms ease forwards; 155 | } 156 | 157 | [data-sonner-toast] [data-icon] > * { 158 | flex-shrink: 0; 159 | } 160 | 161 | [data-sonner-toast] [data-icon] svg { 162 | margin-left: var(--toast-svg-margin-start); 163 | margin-right: var(--toast-svg-margin-end); 164 | } 165 | 166 | [data-sonner-toast] [data-content] { 167 | display: flex; 168 | flex-direction: column; 169 | gap: 2px; 170 | } 171 | 172 | [data-sonner-toast] [data-button] { 173 | border-radius: 4px; 174 | padding-left: 8px; 175 | padding-right: 8px; 176 | height: 24px; 177 | font-size: 12px; 178 | color: var(--normal-bg); 179 | background: var(--normal-text); 180 | margin-left: var(--toast-button-margin-start); 181 | margin-right: var(--toast-button-margin-end); 182 | border: none; 183 | cursor: pointer; 184 | outline: none; 185 | transition: opacity 400ms, box-shadow 200ms; 186 | } 187 | 188 | [data-sonner-toast] [data-button]:focus-visible { 189 | box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4); 190 | } 191 | 192 | [data-sonner-toast] [data-button]:first-of-type { 193 | margin-left: var(--toast-button-margin-start); 194 | margin-right: var(--toast-button-margin-end); 195 | } 196 | 197 | [data-sonner-toast] [data-cancel] { 198 | color: var(--normal-text); 199 | background: rgba(0, 0, 0, 0.08); 200 | } 201 | 202 | [data-sonner-toast][data-theme='dark'] [data-cancel] { 203 | background: rgba(255, 255, 255, 0.3); 204 | } 205 | 206 | [data-sonner-toast] [data-close-button] { 207 | position: absolute; 208 | left: var(--toast-close-button-start); 209 | right: var(--toast-close-button-end); 210 | top: 0; 211 | height: 20px; 212 | width: 20px; 213 | display: flex; 214 | justify-content: center; 215 | align-items: center; 216 | padding: 0; 217 | background: var(--gray1); 218 | color: var(--gray12); 219 | border: 1px solid var(--gray4); 220 | transform: var(--toast-close-button-transform); 221 | border-radius: 50%; 222 | opacity: 0; 223 | cursor: pointer; 224 | z-index: 1; 225 | transition: opacity 100ms, background 200ms, 226 | border-color 200ms; 227 | } 228 | 229 | [data-sonner-toast] [data-close-button]:focus-visible { 230 | box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 231 | 0 0 0 2px rgba(0, 0, 0, 0.2); 232 | } 233 | 234 | [data-sonner-toast] [data-disabled='true'] { 235 | cursor: not-allowed; 236 | } 237 | 238 | [data-sonner-toast]:hover [data-close-button] { 239 | opacity: 1; 240 | } 241 | [data-sonner-toast]:focus [data-close-button] { 242 | opacity: 1; 243 | } 244 | [data-sonner-toast]:focus-within [data-close-button] { 245 | opacity: 1; 246 | } 247 | 248 | [data-sonner-toast]:hover [data-close-button]:hover { 249 | background: var(--gray2); 250 | border-color: var(--gray5); 251 | } 252 | 253 | /* Leave a ghost div to avoid setting hover to false when swiping out */ 254 | [data-sonner-toast][data-swiping='true']:before { 255 | content: ''; 256 | position: absolute; 257 | left: 0; 258 | right: 0; 259 | height: 100%; 260 | } 261 | 262 | [data-sonner-toast][data-y-position='top'][data-swiping='true']:before { 263 | /* y 50% needed to distribute height additional height evenly */ 264 | bottom: 50%; 265 | transform: scaleY(3) translateY(50%); 266 | } 267 | 268 | [data-sonner-toast][data-y-position='bottom'][data-swiping='true']:before { 269 | /* y -50% needed to distribute height additional height evenly */ 270 | top: 50%; 271 | transform: scaleY(3) translateY(-50%); 272 | } 273 | 274 | /* Leave a ghost div to avoid setting hover to false when transitioning out */ 275 | [data-sonner-toast][data-swiping='false'][data-removed='true']:before { 276 | content: ''; 277 | position: absolute; 278 | inset: 0; 279 | transform: scaleY(2); 280 | } 281 | 282 | /* Needed to avoid setting hover to false when inbetween toasts */ 283 | [data-sonner-toast]:after { 284 | content: ''; 285 | position: absolute; 286 | left: 0; 287 | height: calc(var(--gap) + 1px); 288 | bottom: 100%; 289 | width: 100%; 290 | } 291 | 292 | [data-sonner-toast][data-mounted='true'] { 293 | --y: translateY(0); 294 | opacity: 1; 295 | } 296 | 297 | [data-sonner-toast][data-expanded='false'][data-front='false'] { 298 | --scale: var(--toasts-before) * 0.05 + 1; 299 | --y: translateY( 300 | calc(var(--lift-amount) * var(--toasts-before)) 301 | ) 302 | scale(calc(-1 * var(--scale))); 303 | height: var(--front-toast-height); 304 | } 305 | 306 | [data-sonner-toast] > * { 307 | transition: opacity 400ms; 308 | } 309 | 310 | [data-sonner-toast][data-expanded='false'][data-front='false'][data-styled='true'] 311 | > * { 312 | opacity: 0; 313 | } 314 | 315 | [data-sonner-toast][data-visible='false'] { 316 | opacity: 0; 317 | pointer-events: none; 318 | } 319 | 320 | [data-sonner-toast][data-mounted='true'][data-expanded='true'] { 321 | --y: translateY(calc(var(--lift) * var(--offset))); 322 | height: var(--initial-height); 323 | } 324 | 325 | [data-sonner-toast][data-removed='true'][data-front='true'][data-swipe-out='false'] { 326 | --y: translateY(calc(var(--lift) * -100%)); 327 | opacity: 0; 328 | } 329 | 330 | [data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='true'] { 331 | --y: translateY( 332 | calc(var(--lift) * var(--offset) + var(--lift) * -100%) 333 | ); 334 | opacity: 0; 335 | } 336 | 337 | [data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false'] { 338 | --y: translateY(40%); 339 | opacity: 0; 340 | transition: transform 500ms, opacity 200ms; 341 | } 342 | 343 | /* Bump up the height to make sure hover state doesn't get set to false */ 344 | [data-sonner-toast][data-removed='true'][data-front='false']:before { 345 | height: calc(var(--initial-height) + 20%); 346 | } 347 | 348 | [data-sonner-toast][data-swiping='true'] { 349 | transform: var(--y) translateY(var(--swipe-amount, 0px)); 350 | transition: none; 351 | } 352 | 353 | [data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'], 354 | [data-sonner-toast][data-swipe-out='true'][data-y-position='top'] { 355 | animation: swipe-out 200ms ease-out forwards; 356 | } 357 | 358 | @keyframes swipe-out { 359 | from { 360 | transform: translateY( 361 | calc( 362 | var(--lift) * var(--offset) + var(--swipe-amount) 363 | ) 364 | ); 365 | opacity: 1; 366 | } 367 | 368 | to { 369 | transform: translateY( 370 | calc( 371 | var(--lift) * var(--offset) + var(--swipe-amount) + 372 | var(--lift) * -100% 373 | ) 374 | ); 375 | opacity: 0; 376 | } 377 | } 378 | 379 | @media (max-width: 600px) { 380 | [data-sonner-toaster] { 381 | position: fixed; 382 | --mobile-offset: 16px; 383 | right: var(--mobile-offset); 384 | left: var(--mobile-offset); 385 | width: 100%; 386 | } 387 | 388 | [data-sonner-toaster] [data-sonner-toast] { 389 | left: 0; 390 | right: 0; 391 | width: calc(100% - 32px); 392 | } 393 | 394 | [data-sonner-toaster][data-x-position='left'] { 395 | left: var(--mobile-offset); 396 | } 397 | 398 | [data-sonner-toaster][data-y-position='bottom'] { 399 | bottom: 20px; 400 | } 401 | 402 | [data-sonner-toaster][data-y-position='top'] { 403 | top: 20px; 404 | } 405 | 406 | [data-sonner-toaster][data-x-position='center'] { 407 | left: var(--mobile-offset); 408 | right: var(--mobile-offset); 409 | transform: none; 410 | } 411 | } 412 | 413 | [data-sonner-toaster][data-theme='light'] { 414 | --normal-bg: #fff; 415 | --normal-border: var(--gray4); 416 | --normal-text: var(--gray12); 417 | 418 | --success-bg: hsl(143, 85%, 96%); 419 | --success-border: hsl(145, 92%, 91%); 420 | --success-text: hsl(140, 100%, 27%); 421 | 422 | --error-bg: hsl(359, 100%, 97%); 423 | --error-border: hsl(359, 100%, 94%); 424 | --error-text: hsl(360, 100%, 45%); 425 | } 426 | 427 | [data-sonner-toaster][data-theme='light'] 428 | [data-sonner-toast][data-invert='true'] { 429 | --normal-bg: #000; 430 | --normal-border: hsl(0, 0%, 20%); 431 | --normal-text: var(--gray1); 432 | } 433 | 434 | [data-sonner-toaster][data-theme='dark'] 435 | [data-sonner-toast][data-invert='true'] { 436 | --normal-bg: #fff; 437 | --normal-border: var(--gray3); 438 | --normal-text: var(--gray12); 439 | } 440 | 441 | [data-sonner-toaster][data-theme='dark'] { 442 | --normal-bg: #000; 443 | --normal-border: hsl(0, 0%, 20%); 444 | --normal-text: var(--gray1); 445 | 446 | --success-bg: hsl(150, 100%, 6%); 447 | --success-border: hsl(147, 100%, 12%); 448 | --success-text: hsl(150, 86%, 65%); 449 | 450 | --error-bg: hsl(358, 76%, 10%); 451 | --error-border: hsl(357, 89%, 16%); 452 | --error-text: hsl(358, 100%, 81%); 453 | } 454 | 455 | [data-rich-colors='true'] 456 | [data-sonner-toast][data-type='success'] { 457 | background: var(--success-bg); 458 | border-color: var(--success-border); 459 | color: var(--success-text); 460 | } 461 | 462 | [data-rich-colors='true'] 463 | [data-sonner-toast][data-type='success'] 464 | [data-close-button] { 465 | background: var(--success-bg); 466 | border-color: var(--success-border); 467 | color: var(--success-text); 468 | } 469 | 470 | [data-rich-colors='true'] 471 | [data-sonner-toast][data-type='error'] { 472 | background: var(--error-bg); 473 | border-color: var(--error-border); 474 | color: var(--error-text); 475 | } 476 | 477 | [data-rich-colors='true'] 478 | [data-sonner-toast][data-type='error'] 479 | [data-close-button] { 480 | background: var(--error-bg); 481 | border-color: var(--error-border); 482 | color: var(--error-text); 483 | } 484 | 485 | .sonner-loading-wrapper { 486 | --size: 16px; 487 | height: var(--size); 488 | width: var(--size); 489 | position: absolute; 490 | inset: 0; 491 | z-index: 10; 492 | } 493 | 494 | .sonner-loading-wrapper[data-visible='false'] { 495 | transform-origin: center; 496 | animation: sonner-fade-out 0.2s ease forwards; 497 | } 498 | 499 | .sonner-spinner { 500 | position: relative; 501 | top: 50%; 502 | left: 50%; 503 | height: var(--size); 504 | width: var(--size); 505 | } 506 | 507 | .sonner-loading-bar { 508 | animation: sonner-spin 1.2s linear infinite; 509 | background: var(--gray11); 510 | border-radius: 6px; 511 | height: 8%; 512 | left: -10%; 513 | position: absolute; 514 | top: -3.9%; 515 | width: 24%; 516 | } 517 | 518 | .sonner-loading-bar:nth-child(1) { 519 | animation-delay: -1.2s; 520 | /* Rotate trick to avoid adding an additional pixel in some sizes */ 521 | transform: rotate(0.0001deg) translate(146%); 522 | } 523 | 524 | .sonner-loading-bar:nth-child(2) { 525 | animation-delay: -1.1s; 526 | transform: rotate(30deg) translate(146%); 527 | } 528 | 529 | .sonner-loading-bar:nth-child(3) { 530 | animation-delay: -1s; 531 | transform: rotate(60deg) translate(146%); 532 | } 533 | 534 | .sonner-loading-bar:nth-child(4) { 535 | animation-delay: -0.9s; 536 | transform: rotate(90deg) translate(146%); 537 | } 538 | 539 | .sonner-loading-bar:nth-child(5) { 540 | animation-delay: -0.8s; 541 | transform: rotate(120deg) translate(146%); 542 | } 543 | 544 | .sonner-loading-bar:nth-child(6) { 545 | animation-delay: -0.7s; 546 | transform: rotate(150deg) translate(146%); 547 | } 548 | 549 | .sonner-loading-bar:nth-child(7) { 550 | animation-delay: -0.6s; 551 | transform: rotate(180deg) translate(146%); 552 | } 553 | 554 | .sonner-loading-bar:nth-child(8) { 555 | animation-delay: -0.5s; 556 | transform: rotate(210deg) translate(146%); 557 | } 558 | 559 | .sonner-loading-bar:nth-child(9) { 560 | animation-delay: -0.4s; 561 | transform: rotate(240deg) translate(146%); 562 | } 563 | 564 | .sonner-loading-bar:nth-child(10) { 565 | animation-delay: -0.3s; 566 | transform: rotate(270deg) translate(146%); 567 | } 568 | 569 | .sonner-loading-bar:nth-child(11) { 570 | animation-delay: -0.2s; 571 | transform: rotate(300deg) translate(146%); 572 | } 573 | 574 | .sonner-loading-bar:nth-child(12) { 575 | animation-delay: -0.1s; 576 | transform: rotate(330deg) translate(146%); 577 | } 578 | 579 | @keyframes sonner-fade-in { 580 | 0% { 581 | opacity: 0; 582 | transform: scale(0.8); 583 | } 584 | 100% { 585 | opacity: 1; 586 | transform: scale(1); 587 | } 588 | } 589 | 590 | @keyframes sonner-fade-out { 591 | 0% { 592 | opacity: 1; 593 | transform: scale(1); 594 | } 595 | 100% { 596 | opacity: 0; 597 | transform: scale(0.8); 598 | } 599 | } 600 | 601 | @keyframes sonner-spin { 602 | 0% { 603 | opacity: 1; 604 | } 605 | 100% { 606 | opacity: 0.15; 607 | } 608 | } 609 | 610 | @media (prefers-reduced-motion) { 611 | [data-sonner-toast], 612 | [data-sonner-toast] > *, 613 | .sonner-loading-bar { 614 | transition: none !important; 615 | animation: none !important; 616 | } 617 | } 618 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | import './styles.css'; 7 | import { getAsset, Loader } from './assets'; 8 | import { HeightT, Position, ToastT, ToastToDismiss, ExternalToast, ToasterProps } from './types'; 9 | import { ToastState, toast } from './state'; 10 | 11 | // Visible toasts amount 12 | const VISIBLE_TOASTS_AMOUNT = 3; 13 | 14 | // Viewport padding 15 | const VIEWPORT_OFFSET = '32px'; 16 | 17 | // Default lifetime of a toasts (in ms) 18 | const TOAST_LIFETIME = 4000; 19 | 20 | // Default toast width 21 | const TOAST_WIDTH = 356; 22 | 23 | // Default gap between toasts 24 | const GAP = 14; 25 | 26 | const SWIPE_THRESHOLD = 20; 27 | 28 | const TIME_BEFORE_UNMOUNT = 200; 29 | 30 | interface ToastProps { 31 | toast: ToastT; 32 | toasts: ToastT[]; 33 | index: number; 34 | expanded: boolean; 35 | invert: boolean; 36 | heights: HeightT[]; 37 | setHeights: React.Dispatch>; 38 | removeToast: (toast: ToastT) => void; 39 | gap?: number; 40 | position: Position; 41 | visibleToasts: number; 42 | expandByDefault: boolean; 43 | closeButton: boolean; 44 | interacting: boolean; 45 | style?: React.CSSProperties; 46 | duration?: number; 47 | className?: string; 48 | descriptionClassName?: string; 49 | } 50 | 51 | const Toast = (props: ToastProps) => { 52 | const { 53 | invert: ToasterInvert, 54 | toast, 55 | interacting, 56 | setHeights, 57 | visibleToasts, 58 | heights, 59 | index, 60 | toasts, 61 | expanded, 62 | removeToast, 63 | closeButton, 64 | style, 65 | className = '', 66 | descriptionClassName = '', 67 | duration: durationFromToaster, 68 | position, 69 | gap = GAP, 70 | expandByDefault, 71 | } = props; 72 | const [mounted, setMounted] = React.useState(false); 73 | const [removed, setRemoved] = React.useState(false); 74 | const [swiping, setSwiping] = React.useState(false); 75 | const [swipeOut, setSwipeOut] = React.useState(false); 76 | const [offsetBeforeRemove, setOffsetBeforeRemove] = React.useState(0); 77 | const [initialHeight, setInitialHeight] = React.useState(0); 78 | const dragStartTime = React.useRef(null); 79 | const toastRef = React.useRef(null); 80 | const isFront = index === 0; 81 | const isVisible = index + 1 <= visibleToasts; 82 | const toastType = toast.type; 83 | const dismissible = toast.dismissible !== false; 84 | const toastClassname = toast.className || ''; 85 | const toastDescriptionClassname = toast.descriptionClassName || ''; 86 | 87 | // 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. 88 | const heightIndex = React.useMemo( 89 | () => heights.findIndex((height) => height.toastId === toast.id) || 0, 90 | [heights, toast.id], 91 | ); 92 | const duration = React.useMemo( 93 | () => toast.duration || durationFromToaster || TOAST_LIFETIME, 94 | [toast.duration, durationFromToaster], 95 | ); 96 | const closeTimerStartTimeRef = React.useRef(0); 97 | const offset = React.useRef(0); 98 | const closeTimerRemainingTimeRef = React.useRef(duration); 99 | const lastCloseTimerStartTimeRef = React.useRef(0); 100 | const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null); 101 | const [y, x] = position.split('-'); 102 | const toastsHeightBefore = React.useMemo(() => { 103 | return heights.reduce((prev, curr, reducerIndex) => { 104 | // Calculate offset up until current toast 105 | if (reducerIndex >= heightIndex) { 106 | return prev; 107 | } 108 | 109 | return prev + curr.height; 110 | }, 0); 111 | }, [heights, heightIndex]); 112 | const invert = toast.invert || ToasterInvert; 113 | const disabled = toastType === 'loading'; 114 | 115 | offset.current = React.useMemo(() => heightIndex * gap + toastsHeightBefore, [heightIndex, toastsHeightBefore]); 116 | 117 | React.useEffect(() => { 118 | // Trigger enter animation without using CSS animation 119 | setMounted(true); 120 | }, []); 121 | 122 | React.useLayoutEffect(() => { 123 | if (!mounted) return; 124 | const toastNode = toastRef.current; 125 | const originalHeight = toastNode.style.height; 126 | toastNode.style.height = 'auto'; 127 | const newHeight = toastNode.getBoundingClientRect().height; 128 | toastNode.style.height = originalHeight; 129 | 130 | setInitialHeight(newHeight); 131 | 132 | setHeights((heights) => { 133 | const alreadyExists = heights.find((height) => height.toastId === toast.id); 134 | if (!alreadyExists) { 135 | return [{ toastId: toast.id, height: newHeight }, ...heights]; 136 | } else { 137 | return heights.map((height) => (height.toastId === toast.id ? { ...height, height: newHeight } : height)); 138 | } 139 | }); 140 | }, [mounted, toast.title, toast.description, setHeights, toast.id]); 141 | 142 | const deleteToast = React.useCallback(() => { 143 | // Save the offset for the exit swipe animation 144 | setRemoved(true); 145 | setOffsetBeforeRemove(offset.current); 146 | setHeights((h) => h.filter((height) => height.toastId !== toast.id)); 147 | 148 | setTimeout(() => { 149 | removeToast(toast); 150 | }, TIME_BEFORE_UNMOUNT); 151 | }, [toast, removeToast, setHeights, offset]); 152 | 153 | React.useEffect(() => { 154 | if ((toast.promise && toastType === 'loading') || toast.duration === Infinity) return; 155 | let timeoutId: NodeJS.Timeout; 156 | 157 | // Pause the timer on each hover 158 | const pauseTimer = () => { 159 | if (lastCloseTimerStartTimeRef.current < closeTimerStartTimeRef.current) { 160 | // Get the elapsed time since the timer started 161 | const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.current; 162 | 163 | closeTimerRemainingTimeRef.current = closeTimerRemainingTimeRef.current - elapsedTime; 164 | } 165 | 166 | lastCloseTimerStartTimeRef.current = new Date().getTime(); 167 | }; 168 | 169 | const startTimer = () => { 170 | closeTimerStartTimeRef.current = new Date().getTime(); 171 | // Let the toast know it has started 172 | timeoutId = setTimeout(() => { 173 | toast.onAutoClose?.(toast); 174 | deleteToast(); 175 | }, closeTimerRemainingTimeRef.current); 176 | }; 177 | 178 | if (expanded || interacting) { 179 | pauseTimer(); 180 | } else { 181 | startTimer(); 182 | } 183 | 184 | return () => clearTimeout(timeoutId); 185 | }, [expanded, interacting, expandByDefault, toast, duration, deleteToast, toast.promise, toastType]); 186 | 187 | React.useEffect(() => { 188 | const toastNode = toastRef.current; 189 | 190 | if (toastNode) { 191 | const height = toastNode.getBoundingClientRect().height; 192 | 193 | // Add toast height tot heights array after the toast is mounted 194 | setInitialHeight(height); 195 | setHeights((h) => [{ toastId: toast.id, height }, ...h]); 196 | 197 | return () => setHeights((h) => h.filter((height) => height.toastId !== toast.id)); 198 | } 199 | }, [setHeights, toast.id]); 200 | 201 | React.useEffect(() => { 202 | if (toast.delete) { 203 | deleteToast(); 204 | } 205 | }, [deleteToast, toast.delete]); 206 | 207 | return ( 208 |
  • { 243 | if (disabled || !dismissible) return; 244 | dragStartTime.current = new Date(); 245 | setOffsetBeforeRemove(offset.current); 246 | // Ensure we maintain correct pointer capture even when going outside of the toast (e.g. when swiping) 247 | (event.target as HTMLElement).setPointerCapture(event.pointerId); 248 | if ((event.target as HTMLElement).tagName === 'BUTTON') return; 249 | setSwiping(true); 250 | pointerStartRef.current = { x: event.clientX, y: event.clientY }; 251 | }} 252 | onPointerUp={() => { 253 | if (swipeOut || !dismissible) return; 254 | 255 | pointerStartRef.current = null; 256 | const swipeAmount = Number(toastRef.current?.style.getPropertyValue('--swipe-amount').replace('px', '') || 0); 257 | const timeTaken = new Date().getTime() - dragStartTime.current?.getTime(); 258 | const velocity = Math.abs(swipeAmount) / timeTaken; 259 | 260 | // Remove only if threshold is met 261 | if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { 262 | setOffsetBeforeRemove(offset.current); 263 | toast.onDismiss?.(toast); 264 | deleteToast(); 265 | setSwipeOut(true); 266 | return; 267 | } 268 | 269 | toastRef.current?.style.setProperty('--swipe-amount', '0px'); 270 | setSwiping(false); 271 | }} 272 | onPointerMove={(event) => { 273 | if (!pointerStartRef.current || !dismissible) return; 274 | 275 | const yPosition = event.clientY - pointerStartRef.current.y; 276 | const xPosition = event.clientX - pointerStartRef.current.x; 277 | 278 | const clamp = y === 'top' ? Math.min : Math.max; 279 | const clampedY = clamp(0, yPosition); 280 | const swipeStartThreshold = event.pointerType === 'touch' ? 10 : 2; 281 | const isAllowedToSwipe = Math.abs(clampedY) > swipeStartThreshold; 282 | 283 | if (isAllowedToSwipe) { 284 | toastRef.current?.style.setProperty('--swipe-amount', `${yPosition}px`); 285 | } else if (Math.abs(xPosition) > swipeStartThreshold) { 286 | // User is swiping in wrong direction so we disable swipe gesture 287 | // for the current pointer down interaction 288 | pointerStartRef.current = null; 289 | } 290 | }} 291 | > 292 | {closeButton && !toast.jsx ? ( 293 | 321 | ) : null} 322 | {toast.jsx || React.isValidElement(toast.title) ? ( 323 | toast.jsx || toast.title 324 | ) : ( 325 | <> 326 | {toastType || toast.icon || toast.promise ? ( 327 |
    328 | {(toast.promise || toast.type === 'loading') && !toast.icon ? ( 329 | 330 | ) : null} 331 | {toast.icon || getAsset(toastType)} 332 |
    333 | ) : null} 334 | 335 |
    336 |
    {toast.title}
    337 | {toast.description ? ( 338 |
    339 | {toast.description} 340 |
    341 | ) : null} 342 |
    343 | {toast.cancel ? ( 344 | 357 | ) : null} 358 | {toast.action ? ( 359 | 369 | ) : null} 370 | 371 | )} 372 |
  • 373 | ); 374 | }; 375 | 376 | function getDocumentDirection(): ToasterProps['dir'] { 377 | if (typeof window === 'undefined') return 'ltr'; 378 | 379 | const dirAttribute = document.documentElement.getAttribute('dir'); 380 | 381 | if (dirAttribute === 'auto' || !dirAttribute) { 382 | return window.getComputedStyle(document.documentElement).direction as ToasterProps['dir']; 383 | } 384 | 385 | return dirAttribute as ToasterProps['dir']; 386 | } 387 | 388 | const Toaster = (props: ToasterProps) => { 389 | const { 390 | invert, 391 | position = 'bottom-right', 392 | hotkey = ['altKey', 'KeyT'], 393 | expand, 394 | closeButton, 395 | className, 396 | offset, 397 | theme = 'light', 398 | richColors, 399 | duration, 400 | style, 401 | visibleToasts = VISIBLE_TOASTS_AMOUNT, 402 | toastOptions, 403 | dir = getDocumentDirection(), 404 | gap, 405 | } = props; 406 | const [toasts, setToasts] = React.useState([]); 407 | const possiblePositions = React.useMemo(() => { 408 | return Array.from( 409 | new Set([position].concat(toasts.filter((toast) => toast.position).map((toast) => toast.position))), 410 | ); 411 | }, [toasts, position]); 412 | const [heights, setHeights] = React.useState([]); 413 | const [expanded, setExpanded] = React.useState(false); 414 | const [interacting, setInteracting] = React.useState(false); 415 | const [actualTheme, setActualTheme] = React.useState( 416 | theme !== 'system' 417 | ? theme 418 | : typeof window !== 'undefined' 419 | ? window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches 420 | ? 'dark' 421 | : 'light' 422 | : 'light', 423 | ); 424 | 425 | const listRef = React.useRef(null); 426 | const hotkeyLabel = hotkey.join('+').replace(/Key/g, '').replace(/Digit/g, ''); 427 | const lastFocusedElementRef = React.useRef(null); 428 | const isFocusWithinRef = React.useRef(false); 429 | 430 | const removeToast = React.useCallback( 431 | (toast: ToastT) => setToasts((toasts) => toasts.filter(({ id }) => id !== toast.id)), 432 | [], 433 | ); 434 | 435 | React.useEffect(() => { 436 | return ToastState.subscribe((toast) => { 437 | if ((toast as ToastToDismiss).dismiss) { 438 | setToasts((toasts) => toasts.map((t) => (t.id === toast.id ? { ...t, delete: true } : t))); 439 | return; 440 | } 441 | 442 | // Prevent batching, temp solution. 443 | setTimeout(() => { 444 | ReactDOM.flushSync(() => { 445 | setToasts((toasts) => { 446 | const indexOfExistingToast = toasts.findIndex((t) => t.id === toast.id); 447 | 448 | // Update the toast if it already exists 449 | if (indexOfExistingToast !== -1) { 450 | return [ 451 | ...toasts.slice(0, indexOfExistingToast), 452 | { ...toasts[indexOfExistingToast], ...toast }, 453 | ...toasts.slice(indexOfExistingToast + 1), 454 | ]; 455 | } 456 | 457 | return [toast, ...toasts]; 458 | }); 459 | }); 460 | }); 461 | }); 462 | }, []); 463 | 464 | React.useEffect(() => { 465 | if (theme !== 'system') { 466 | setActualTheme(theme); 467 | return; 468 | } 469 | 470 | if (theme === 'system') { 471 | // check if current preference is dark 472 | if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { 473 | // it's currently dark 474 | setActualTheme('dark'); 475 | } else { 476 | // it's not dark 477 | setActualTheme('light'); 478 | } 479 | } 480 | 481 | if (typeof window === 'undefined') return; 482 | 483 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ({ matches }) => { 484 | if (matches) { 485 | setActualTheme('dark'); 486 | } else { 487 | setActualTheme('light'); 488 | } 489 | }); 490 | }, [theme]); 491 | 492 | React.useEffect(() => { 493 | // Ensure expanded is always false when no toasts are present / only one left 494 | if (toasts.length <= 1) { 495 | setExpanded(false); 496 | } 497 | }, [toasts]); 498 | 499 | React.useEffect(() => { 500 | const handleKeyDown = (event: KeyboardEvent) => { 501 | const isHotkeyPressed = hotkey.every((key) => (event as any)[key] || event.code === key); 502 | 503 | if (isHotkeyPressed) { 504 | setExpanded(true); 505 | listRef.current?.focus(); 506 | } 507 | 508 | if ( 509 | event.code === 'Escape' && 510 | (document.activeElement === listRef.current || listRef.current?.contains(document.activeElement)) 511 | ) { 512 | setExpanded(false); 513 | } 514 | }; 515 | document.addEventListener('keydown', handleKeyDown); 516 | 517 | return () => document.removeEventListener('keydown', handleKeyDown); 518 | }, [hotkey]); 519 | 520 | React.useEffect(() => { 521 | if (listRef.current) { 522 | return () => { 523 | if (lastFocusedElementRef.current) { 524 | lastFocusedElementRef.current.focus({ preventScroll: true }); 525 | lastFocusedElementRef.current = null; 526 | isFocusWithinRef.current = false; 527 | } 528 | }; 529 | } 530 | }, [listRef.current]); 531 | 532 | if (!toasts.length) return null; 533 | 534 | return ( 535 | // Remove item from normal navigation flow, only available via hotkey 536 |
    537 | {possiblePositions.map((position, index) => { 538 | const [y, x] = position.split('-'); 539 | return ( 540 |
      { 561 | if (isFocusWithinRef.current && !event.currentTarget.contains(event.relatedTarget)) { 562 | isFocusWithinRef.current = false; 563 | if (lastFocusedElementRef.current) { 564 | lastFocusedElementRef.current.focus({ preventScroll: true }); 565 | lastFocusedElementRef.current = null; 566 | } 567 | } 568 | }} 569 | onFocus={(event) => { 570 | const isNotDismissible = 571 | event.target instanceof HTMLElement && event.target.dataset.dismissible === 'false'; 572 | 573 | if (isNotDismissible) return; 574 | 575 | if (!isFocusWithinRef.current) { 576 | isFocusWithinRef.current = true; 577 | lastFocusedElementRef.current = event.relatedTarget as HTMLElement; 578 | } 579 | }} 580 | onMouseEnter={() => setExpanded(true)} 581 | onMouseMove={() => setExpanded(true)} 582 | onMouseLeave={() => { 583 | // Avoid setting expanded to false when interacting with a toast, e.g. swiping 584 | if (!interacting) { 585 | setExpanded(false); 586 | } 587 | }} 588 | onPointerDown={(event) => { 589 | const isNotDismissible = 590 | event.target instanceof HTMLElement && event.target.dataset.dismissible === 'false'; 591 | 592 | if (isNotDismissible) return; 593 | setInteracting(true); 594 | }} 595 | onPointerUp={() => setInteracting(false)} 596 | > 597 | {toasts 598 | .filter((toast) => (!toast.position && index === 0) || toast.position === position) 599 | .map((toast, index) => ( 600 | 621 | ))} 622 |
    623 | ); 624 | })} 625 |
    626 | ); 627 | }; 628 | export { toast, Toaster, ToastT, ExternalToast }; 629 | --------------------------------------------------------------------------------