├── 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 | toast('My first toast')}>
17 | Give me a toast
18 |
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 | toast('My first toast')}>Give me a toast
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 |
14 |
Sonner
15 |
An opinionated toast component for React.
16 |
17 |
{
20 | toast('Sonner', {
21 | description: 'An opinionated toast component for React.',
22 | });
23 | }}
24 | className={styles.button}
25 | >
26 | Render a toast
27 |
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 | {
22 | toast('Event has been created', {
23 | description: 'Monday, January 3rd at 6:00pm',
24 | });
25 | setExpand(true);
26 | }}
27 | >
28 | Expand
29 |
30 | {
34 | toast('Event has been created', {
35 | description: 'Monday, January 3rd at 6:00pm',
36 | });
37 | setExpand(false);
38 | }}
39 | >
40 | Default
41 |
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 | toast('This is a sonner toast')}>
66 | Render my toast
67 |
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 | {
26 | const toastsAmount = document.querySelectorAll('[data-sonner-toast]').length;
27 | setPosition(position);
28 | // No need to show a toast when there is already one
29 | if (toastsAmount > 0 && position !== activePosition) return;
30 |
31 | toast('Event has been created', {
32 | description: 'Monday, January 3rd at 6:00pm',
33 | });
34 | }}
35 | key={position}
36 | >
37 | {position}
38 |
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 |
31 |
32 |
33 | {copying ? (
34 |
35 |
46 |
47 |
48 |
49 | ) : (
50 |
51 |
62 |
63 |
64 |
65 | )}
66 |
67 |
68 |
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 | {
20 | type.action();
21 | setActiveType(type);
22 | }}
23 | key={type.name}
24 | >
25 | {type.name}
26 |
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 | toast.dismiss(t)}>Dismiss
50 |
51 | ));`,
52 | action: () => {
53 | toast.custom(
54 | (t) => (
55 |
56 |
Event Created
57 |
Today at 4:00pm - "Louvre Museum"
58 |
toast.dismiss(t)}>
59 |
60 |
61 |
62 |
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 | {
87 | type.action();
88 | setActiveType(type);
89 | }}
90 | key={type.name}
91 | >
92 | {type.name}
93 |
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 |
63 |
64 |
65 | {copying ? (
66 |
67 |
78 |
79 |
80 |
81 | ) : (
82 |
83 |
94 |
95 |
96 |
97 | )}
98 |
99 |
100 |
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 | setTheme('dark')}>
17 | Change theme
18 |
19 | toast('My Toast')}>
20 | Render Toast
21 |
22 | toast('My Toast')}>
23 | Render Toast Top
24 |
25 | toast.success('My Success Toast')}>
26 | Render Success Toast
27 |
28 | toast.error('My Error Toast')}>
29 | Render Error Toast
30 |
31 |
35 | toast('My Message', {
36 | action: {
37 | label: 'Action',
38 | onClick: () => console.log('Action'),
39 | },
40 | })
41 | }
42 | >
43 | Render Action Toast
44 |
45 |
49 | toast('My Message', {
50 | action: {
51 | label: 'Action',
52 | onClick: (event) => {
53 | event.preventDefault();
54 | console.log('Action');
55 | },
56 | },
57 | })
58 | }
59 | >
60 | Render Action Toast
61 |
62 |
67 | toast.promise(promise, {
68 | loading: 'Loading...',
69 | success: 'Loaded',
70 | error: 'Error',
71 | finally: () => setIsFinally(true),
72 | })
73 | }
74 | >
75 | Render Promise Toast
76 |
77 |
81 | toast.custom((t) => (
82 |
83 |
jsx
84 | toast.dismiss(t)}>
85 | Dismiss
86 |
87 |
88 | ))
89 | }
90 | >
91 | Render Custom Toast
92 |
93 | toast('My Toast', { duration: Infinity })}>
94 | Render Infinity Toast
95 |
96 |
100 | toast('My Toast', {
101 | onAutoClose: () => setShowAutoClose(true),
102 | })
103 | }
104 | >
105 | Render Toast With onAutoClose callback
106 |
107 |
111 | toast('My Toast', {
112 | onDismiss: () => setShowDismiss(true),
113 | })
114 | }
115 | >
116 | Render Toast With onAutoClose callback
117 |
118 |
122 | toast('My Toast', {
123 | dismissible: false,
124 | })
125 | }
126 | >
127 | Non-dismissible Toast
128 |
129 | {
133 | const toastId = toast('My Unupdated Toast', {
134 | duration: 10000,
135 | });
136 | toast('My Updated Toast', {
137 | id: toastId,
138 | duration: 10000,
139 | });
140 | }}
141 | >
142 | Updated Toast
143 |
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 toast.dismiss(t)}>close
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 | {}
300 | : () => {
301 | deleteToast();
302 | toast.onDismiss?.(toast);
303 | }
304 | }
305 | >
306 |
317 |
318 |
319 |
320 |
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 | {
348 | if (!dismissible) return;
349 | deleteToast();
350 | if (toast.cancel?.onClick) {
351 | toast.cancel.onClick();
352 | }
353 | }}
354 | >
355 | {toast.cancel.label}
356 |
357 | ) : null}
358 | {toast.action ? (
359 | {
362 | toast.action?.onClick(event);
363 | if (event.defaultPrevented) return;
364 | deleteToast();
365 | }}
366 | >
367 | {toast.action.label}
368 |
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 |
--------------------------------------------------------------------------------