├── .github
└── workflows
│ ├── main.yml
│ └── size.yml
├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── assets
└── header.svg
├── jest.config.js
├── package.json
├── pnpm-lock.yaml
├── site
├── .gitignore
├── README.md
├── assets
│ ├── arrow.svg
│ ├── butter-1.svg
│ ├── butter-2.png
│ ├── butter-2.svg
│ ├── checkmark.svg
│ ├── github.svg
│ ├── logo-small.svg
│ └── logo.svg
├── components
│ ├── code.tsx
│ ├── docs-layout.tsx
│ ├── emoji-button.tsx
│ └── sections
│ │ ├── footer.tsx
│ │ ├── splitbee-counter.tsx
│ │ ├── toast-example.tsx
│ │ └── toaster-example.tsx
├── next-env.d.ts
├── next.config.mjs
├── package.json
├── pages
│ ├── _app.tsx
│ ├── docs
│ │ ├── index.mdx
│ │ ├── styling.mdx
│ │ ├── toast-bar.mdx
│ │ ├── toast.mdx
│ │ ├── toaster.mdx
│ │ ├── use-toaster-store.mdx
│ │ ├── use-toaster.mdx
│ │ └── version-2.mdx
│ └── index.tsx
├── pnpm-lock.yaml
├── postcss.config.js
├── public
│ ├── favicon.png
│ └── social-image.png
├── styles
│ ├── main.css
│ ├── prism-theme.css
│ └── tailwind-utils.css
├── tailwind.config.js
├── tsconfig.json
└── types
│ ├── mdx.d.ts
│ └── svg.d.ts
├── src
├── components
│ ├── checkmark.tsx
│ ├── error.tsx
│ ├── loader.tsx
│ ├── toast-bar.tsx
│ ├── toast-icon.tsx
│ └── toaster.tsx
├── core
│ ├── store.ts
│ ├── toast.ts
│ ├── types.ts
│ ├── use-toaster.ts
│ └── utils.ts
├── headless
│ └── index.ts
└── index.ts
├── test
├── setup.ts
└── toast.test.tsx
├── tsconfig.json
└── tsup.config.ts
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push]
3 | jobs:
4 | build:
5 | name: Build & test
6 | runs-on: ubuntu-latest
7 | steps:
8 | - uses: actions/checkout@v2
9 | - uses: pnpm/action-setup@v4
10 | - name: Use Node.js ${{ matrix.node-version }}
11 | uses: actions/setup-node@v2
12 | with:
13 | node-version: ${{ matrix.node-version }}
14 | cache: 'pnpm'
15 | - name: Install dependencies
16 | run: pnpm install
17 | - name: Build package
18 | run: pnpm build
19 | - name: Test
20 | run: pnpm run test --ci --coverage
21 |
--------------------------------------------------------------------------------
/.github/workflows/size.yml:
--------------------------------------------------------------------------------
1 | name: size
2 | on: [pull_request]
3 | jobs:
4 | size:
5 | runs-on: ubuntu-latest
6 | env:
7 | CI_JOB_NUMBER: 1
8 | steps:
9 | - uses: actions/checkout@v2
10 | - uses: pnpm/action-setup@v4
11 | - uses: andresz1/size-limit-action@v1
12 | with:
13 | github_token: ${{ secrets.GITHUB_TOKEN }}
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .DS_Store
3 | node_modules
4 | .cache
5 | coverage
6 | dist
7 | /headless
8 | .vscode
9 | .vercel
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Timo Lins
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 | Smoking hot Notifications for React.
11 | Lightweight, customizable and beautiful by default.
12 |
13 |
20 |
21 |
22 |
25 |
26 |
27 |
28 | ## Features
29 |
30 | - 🔥 **Hot by default**
31 | - 🔩 **Easily Customizable**
32 | - ⏳ **Promise API** - _Automatic loader from a promise_
33 | - 🕊 **Lightweight** - _less than 5kb including styles_
34 | - ✅ **Accessible**
35 | - 🤯 **Headless Hooks** - _Create your own with [`useToaster()`](https://react-hot-toast.com/docs/use-toaster)_
36 |
37 | ## Installation
38 |
39 | #### With pnpm
40 |
41 | ```sh
42 | pnpm add react-hot-toast
43 | ```
44 |
45 | #### With NPM
46 |
47 | ```sh
48 | npm install react-hot-toast
49 | ```
50 |
51 | ## Getting Started
52 |
53 | Add the Toaster to your app first. It will take care of rendering all notifications emitted. Now you can trigger `toast()` from anywhere!
54 |
55 | ```jsx
56 | import toast, { Toaster } from 'react-hot-toast';
57 |
58 | const notify = () => toast('Here is your toast.');
59 |
60 | const App = () => {
61 | return (
62 |
63 | Make me a toast
64 |
65 |
66 | );
67 | };
68 | ```
69 |
70 | ## Documentation
71 |
72 | Find the full API reference on [official documentation](https://react-hot-toast.com/docs).
73 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest',
4 | testEnvironment: 'jsdom',
5 | setupFilesAfterEnv: ['/test/setup.ts'],
6 | };
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-hot-toast",
3 | "description": "Smoking hot React Notifications. Lightweight, customizable and beautiful by default.",
4 | "version": "2.5.2",
5 | "author": "Timo Lins",
6 | "license": "MIT",
7 | "repository": "timolins/react-hot-toast",
8 | "keywords": [
9 | "react",
10 | "notifications",
11 | "toast",
12 | "snackbar"
13 | ],
14 | "main": "dist/index.js",
15 | "types": "dist/index.d.ts",
16 | "exports": {
17 | "./package.json": "./package.json",
18 | ".": {
19 | "types": "./dist/index.d.ts",
20 | "import": "./dist/index.mjs",
21 | "require": "./dist/index.js"
22 | },
23 | "./headless": {
24 | "types": "./headless/index.d.ts",
25 | "import": "./headless/index.mjs",
26 | "require": "./headless/index.js"
27 | }
28 | },
29 | "files": [
30 | "headless",
31 | "dist",
32 | "src"
33 | ],
34 | "engines": {
35 | "node": ">=10"
36 | },
37 | "scripts": {
38 | "start": "tsup --watch",
39 | "build": "tsup",
40 | "test": "jest --runInBand",
41 | "setup": "pnpm i && cd site && pnpm i && cd .. && pnpm run link",
42 | "link": "pnpm link ./site/node_modules/react && pnpm link ./site/node_modules/react-dom",
43 | "size": "size-limit"
44 | },
45 | "husky": {
46 | "hooks": {
47 | "pre-commit": "prettier src --ignore-unknown --write"
48 | }
49 | },
50 | "prettier": {
51 | "printWidth": 80,
52 | "semi": true,
53 | "singleQuote": true,
54 | "arrowParens": "always",
55 | "trailingComma": "es5"
56 | },
57 | "size-limit": [
58 | {
59 | "path": "dist/index.js",
60 | "limit": "5.5 KB"
61 | },
62 | {
63 | "path": "dist/index.mjs",
64 | "limit": "5 KB"
65 | },
66 | {
67 | "path": "headless/index.js",
68 | "limit": "2 KB"
69 | },
70 | {
71 | "path": "headless/index.mjs",
72 | "limit": "2 KB"
73 | }
74 | ],
75 | "devDependencies": {
76 | "@jest/types": "^29.6.3",
77 | "@size-limit/preset-small-lib": "^7.0.8",
78 | "@testing-library/jest-dom": "^6.6.3",
79 | "@testing-library/react": "^16.1.0",
80 | "@types/jest": "^29.5.14",
81 | "@types/react": "^18.3.18",
82 | "@types/react-dom": "^18.3.5",
83 | "jest": "^29.7.0",
84 | "jest-environment-jsdom": "^29.7.0",
85 | "prettier": "^2.8.8",
86 | "react": "^18.3.1",
87 | "react-dom": "^18.3.1",
88 | "size-limit": "^7.0.8",
89 | "ts-jest": "^29.2.5",
90 | "tslib": "^2.8.1",
91 | "tsup": "^6.7.0",
92 | "typescript": "^5.7.2"
93 | },
94 | "dependencies": {
95 | "csstype": "^3.1.3",
96 | "goober": "^2.1.16"
97 | },
98 | "peerDependencies": {
99 | "react": ">=16",
100 | "react-dom": ">=16"
101 | },
102 | "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
103 | }
104 |
--------------------------------------------------------------------------------
/site/.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 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
--------------------------------------------------------------------------------
/site/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 | ```
12 |
13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
14 |
15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
16 |
17 | ## Learn More
18 |
19 | To learn more about Next.js, take a look at the following resources:
20 |
21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
23 |
24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
25 |
26 | ## Deploy on Vercel
27 |
28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
29 |
30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
31 |
--------------------------------------------------------------------------------
/site/assets/arrow.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/site/assets/butter-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/site/assets/butter-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timolins/react-hot-toast/7f7845cd02a577f9f0d2e573b93aa136e5d5caa0/site/assets/butter-2.png
--------------------------------------------------------------------------------
/site/assets/butter-2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/site/assets/checkmark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/site/assets/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/site/assets/logo-small.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/site/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/site/components/code.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import Highlight, {
3 | defaultProps,
4 | Language,
5 | PrismTheme,
6 | } from 'prism-react-renderer';
7 |
8 | const theme: PrismTheme = {
9 | plain: {
10 | backgroundColor: '#351e11',
11 | color: '#d6ceff',
12 | },
13 | styles: [
14 | {
15 | types: ['comment', 'prolog', 'doctype', 'cdata', 'punctuation'],
16 | style: {
17 | color: '#6c6783',
18 | },
19 | },
20 | {
21 | types: ['namespace'],
22 | style: {
23 | opacity: 0.7,
24 | },
25 | },
26 | {
27 | types: ['tag', 'operator', 'number', 'module'],
28 | style: {
29 | color: '#e09142',
30 | },
31 | },
32 | {
33 | types: ['property', 'function'],
34 | style: {
35 | color: '#9a86fd',
36 | },
37 | },
38 | {
39 | types: ['tag-id', 'selector', 'atrule-id'],
40 | style: {
41 | color: '#eeebff',
42 | },
43 | },
44 | {
45 | types: ['attr-name'],
46 | style: {
47 | color: '#c4b9fe',
48 | },
49 | },
50 | {
51 | types: [
52 | 'boolean',
53 | 'string',
54 | 'entity',
55 | 'url',
56 | 'attr-value',
57 | 'keyword',
58 | 'control',
59 | 'directive',
60 | 'unit',
61 | 'statement',
62 | 'regex',
63 | 'at-rule',
64 | 'placeholder',
65 | 'variable',
66 | ],
67 | style: {
68 | color: '#ffcc99',
69 | },
70 | },
71 | {
72 | types: ['deleted'],
73 | style: {
74 | textDecorationLine: 'line-through',
75 | },
76 | },
77 | {
78 | types: ['inserted'],
79 | style: {
80 | textDecorationLine: 'underline',
81 | },
82 | },
83 | {
84 | types: ['italic'],
85 | style: {
86 | fontStyle: 'italic',
87 | },
88 | },
89 | {
90 | types: ['important', 'bold'],
91 | style: {
92 | fontWeight: 'bold',
93 | },
94 | },
95 | {
96 | types: ['important'],
97 | style: {
98 | color: '#c4b9fe',
99 | },
100 | },
101 | ],
102 | };
103 |
104 | export const Code: React.FC<{
105 | snippet: string;
106 | language?: Language;
107 | className?: string;
108 | }> = (props) => {
109 | const language = props.language || 'jsx';
110 |
111 | return (
112 |
118 | {({ className, style, tokens, getLineProps, getTokenProps }) => (
119 |
127 | {tokens.map((line, i) => {
128 | if (tokens.length - 1 === i && line[0].empty) {
129 | return null;
130 | }
131 |
132 | return (
133 |
134 | {line.map((token, key) => (
135 |
136 | ))}
137 |
138 | );
139 | })}
140 |
141 | )}
142 |
143 | );
144 | };
145 |
--------------------------------------------------------------------------------
/site/components/docs-layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Toaster } from 'react-hot-toast';
3 | import { NextSeo } from 'next-seo';
4 | import Link from 'next/link';
5 | import { Footer } from './sections/footer';
6 | import Logo from '../assets/logo-small.svg';
7 |
8 | const TableItem: React.FC<{
9 | href: string;
10 | children?: React.ReactNode;
11 | }> = ({ children, href }) => (
12 |
13 |
14 | {children}
15 |
16 |
17 | );
18 |
19 | const TableHeader: React.FC<{
20 | children?: React.ReactNode;
21 | }> = ({ children }) => (
22 |
23 | {children}
24 |
25 | );
26 |
27 | export default function DocsLayout({ meta, children }) {
28 | return (
29 |
30 |
43 |
44 |
45 |
59 |
60 |
61 |
62 |
63 |
Overview
64 |
65 |
Get Started
66 |
67 |
API
68 |
69 |
toast()
70 |
{`Toaster`}
71 |
{`ToastBar`}
72 |
useToaster()
73 |
74 | useToasterStore()
75 |
76 |
Guides
77 |
Styling
78 |
Releases
79 |
New in 2.0
80 |
81 |
82 |
83 |
84 | {children}
85 |
86 |
87 |
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/site/components/emoji-button.tsx:
--------------------------------------------------------------------------------
1 | export const EmojiButton: React.FC<{
2 | onClick: () => void;
3 | emoji: string | React.ReactElement;
4 | children?: React.ReactNode;
5 | }> = ({ onClick, children, emoji }) => (
6 |
10 |
16 | {emoji}
17 |
18 | {children}
19 |
20 | );
21 |
--------------------------------------------------------------------------------
/site/components/sections/footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 |
4 | export function Footer({ noBadge }: { noBadge?: boolean }) {
5 | return (
6 |
7 |
21 |
22 |
© {new Date().getFullYear()} react-hot-toast
23 | {' · '}
24 |
25 | Built by
26 |
27 | Timo Lins
28 |
29 |
30 |
31 | {!noBadge && (
32 |
44 | )}
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/site/components/sections/splitbee-counter.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export const useSplitbeeCount = (
5 | event: T,
6 | token: string
7 | ): number => {
8 | const [data, setData] = React.useState(0);
9 | const socket = React.useRef(null);
10 | React.useEffect(() => {
11 | if (typeof window !== undefined) {
12 | socket.current = new WebSocket('wss://realtime.react-hot-toast.com/');
13 | socket.current.onopen = (e) => {
14 | socket.current.send(
15 | JSON.stringify({
16 | type: 'subscribe',
17 | data: {
18 | token: token,
19 | events: [event],
20 | },
21 | })
22 | );
23 | };
24 | socket.current.onmessage = (e) => {
25 | const d = JSON.parse(e.data);
26 | setData(d.count);
27 | };
28 |
29 | return () => {};
30 | }
31 | }, []);
32 |
33 | return data;
34 | };
35 |
36 | export const SplitbeeCounter = () => {
37 | const count = useSplitbeeCount('Trigger Toast', 'NTV7AYBLEXW3');
38 |
39 | const letters = count.toString().split('');
40 |
41 | return (
42 |
43 |
44 | Toasts made on this website so far
45 |
46 |
49 | {letters.map((l, i) => (
50 |
57 | {l}
58 |
59 | ))}
60 |
61 |
72 |
73 | );
74 | };
75 |
--------------------------------------------------------------------------------
/site/components/sections/toast-example.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import toast from 'react-hot-toast';
3 |
4 | import { EmojiButton } from '../emoji-button';
5 | import { Code } from '../code';
6 |
7 | const examples: Array<{
8 | title: string;
9 | action: () => void;
10 | emoji: string;
11 | snippet: string;
12 | }> = [
13 | {
14 | title: 'Success',
15 | emoji: '✅',
16 | snippet: "toast.success('Successfully toasted!')",
17 | action: () => {
18 | toast.success('Successfully toasted!');
19 | },
20 | },
21 | {
22 | title: 'Error',
23 | emoji: '❌',
24 | snippet: `toast.error("This didn't work.")`,
25 |
26 | action: () => {
27 | toast.error("This didn't work.");
28 | },
29 | },
30 | {
31 | title: 'Promise',
32 | emoji: '⏳',
33 | snippet: `toast.promise(
34 | saveSettings(settings),
35 | {
36 | loading: 'Saving...',
37 | success: Settings saved! ,
38 | error: Could not save. ,
39 | }
40 | );`,
41 | action: () => {
42 | const promise = new Promise((res, rej) => {
43 | setTimeout(Math.random() > 0.5 ? res : rej, 1000);
44 | });
45 |
46 | toast.promise(
47 | promise,
48 | {
49 | loading: 'Saving...',
50 | success: Settings saved! ,
51 | error: Could not save. ,
52 | },
53 | {
54 | style: {
55 | width: '200px',
56 | paddingRight: '10px',
57 | },
58 | }
59 | );
60 | },
61 | },
62 | {
63 | title: 'Multi Line',
64 | emoji: '↕️',
65 | snippet: `toast(
66 | "This toast is super big. I don't think anyone could eat it in one bite.\\n\\nIt's larger than you expected. You eat it but it does not seem to get smaller.",
67 | {
68 | duration: 6000,
69 | }
70 | );`,
71 | action: () => {
72 | toast(
73 | "This toast is super big. I don't think anyone could eat it in one bite.\n\n It's larger than you expected. You eat it but it does not seem to get smaller.",
74 | {
75 | duration: 6000,
76 | }
77 | );
78 | },
79 | },
80 | {
81 | title: 'Emoji',
82 | emoji: '👏',
83 | snippet: `toast('Good Job!', {
84 | icon: '👏',
85 | });`,
86 | action: () => {
87 | toast('Good Job!', {
88 | icon: '👏',
89 | });
90 | },
91 | },
92 | {
93 | title: 'Dark Mode',
94 | emoji: '🌚',
95 | snippet: `toast('Hello Darkness!',
96 | {
97 | icon: '👏',
98 | style: {
99 | borderRadius: '10px',
100 | background: '#333',
101 | color: '#fff',
102 | },
103 | }
104 | );`,
105 | action: () => {
106 | toast('Hello Darkness!', {
107 | icon: '👏',
108 |
109 | style: {
110 | borderRadius: '200px',
111 | background: '#333',
112 | color: '#fff',
113 | },
114 | });
115 | },
116 | },
117 | {
118 | title: 'JSX Content',
119 | emoji: '🔩',
120 | snippet: `toast((t) => (
121 |
122 | Custom and bold
123 | toast.dismiss(t.id)}>
124 | Dismiss
125 |
126 |
127 | ));`,
128 |
129 | action: () => {
130 | toast((t) => (
131 |
132 | Custom and bold
133 | toast.dismiss(t.id)}
136 | >
137 | Dismiss
138 |
139 |
140 | ));
141 | },
142 | },
143 | {
144 | title: 'Themed',
145 | emoji: '🎨',
146 | snippet: `toast.success('Look at my styles.', {
147 | style: {
148 | border: '1px solid #713200',
149 | padding: '16px',
150 | color: '#713200',
151 | },
152 | iconTheme: {
153 | primary: '#713200',
154 | secondary: '#FFFAEE',
155 | },
156 | });`,
157 |
158 | action: () => {
159 | toast.success('Look at my styles.', {
160 | style: {
161 | border: '1px solid #713200',
162 | padding: '16px',
163 | color: '#713200',
164 | },
165 | iconTheme: {
166 | primary: '#713200',
167 | secondary: '#FFFAEE',
168 | },
169 | });
170 | },
171 | },
172 | {
173 | title: 'Custom Position',
174 | emoji: '⬆️',
175 | snippet: `toast.success('Always at the bottom.', {
176 | position: "bottom-center"
177 | })`,
178 | action: () => {
179 | toast.success('Always at the bottom.', {
180 | position: 'bottom-center',
181 | duration: 10000,
182 | });
183 | },
184 | },
185 | {
186 | title: 'TailwindCSS',
187 | emoji: '️💨',
188 | snippet: `toast.custom((t) => (
189 |
194 |
195 |
196 |
197 |
202 |
203 |
204 |
205 | Emilia Gates
206 |
207 |
208 | Sure! 8:30pm works great!
209 |
210 |
211 |
212 |
213 |
214 | toast.dismiss(t.id)}
216 | className="w-full border border-transparent rounded-none rounded-r-lg p-4 flex items-center justify-center text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
217 | >
218 | Close
219 |
220 |
221 |
222 | ))`,
223 | action: () => {
224 | // toast.custom( );
225 |
226 | toast.custom(
227 | (t) => (
228 |
233 |
234 |
235 |
236 |
241 |
242 |
243 |
244 | Emilia Gates
245 |
246 |
247 | Sure! 8:30pm works great!
248 |
249 |
250 |
251 |
252 |
253 | toast.dismiss(t.id)}
255 | className="w-full border border-transparent rounded-none rounded-r-lg p-4 flex items-center justify-center text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
256 | >
257 | Close
258 |
259 |
260 |
261 | ),
262 | {
263 | duration: 10000,
264 | }
265 | );
266 | },
267 | },
268 | ];
269 |
270 | export const ToastExample = () => {
271 | const [snippet, setSnippet] = useState(examples[0].snippet);
272 | return (
273 |
274 |
275 |
276 | {examples.map((e) => (
277 | {
281 | if (e.snippet) {
282 | setSnippet(e.snippet);
283 | }
284 | (window as any).splitbee?.track('Trigger Toast', {
285 | example: e.title,
286 | });
287 | e.action();
288 | }}
289 | >
290 | {e.title}
291 |
292 | ))}
293 |
294 |
295 |
296 |
297 |
298 |
299 | );
300 | };
301 |
--------------------------------------------------------------------------------
/site/components/sections/toaster-example.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import toast, { ToastPosition } from 'react-hot-toast';
3 | import Arrow from '../../assets/arrow.svg';
4 | import { Code } from '../code';
5 |
6 | import { EmojiButton } from '../emoji-button';
7 |
8 | export const positions: Array = [
9 | 'top-left',
10 | 'top-center',
11 | 'top-right',
12 | 'bottom-left',
13 | 'bottom-center',
14 | 'bottom-right',
15 | ];
16 |
17 | export const ToasterExample: React.FC<{
18 | position: ToastPosition;
19 | onPosition: (pos: ToastPosition) => void;
20 | reverse: boolean;
21 | onReverse: (rev: boolean) => void;
22 | }> = ({ position, onPosition, reverse, onReverse }) => {
23 | const reverseIt = () => {
24 | setTimeout(() => {
25 | toast('Notification 1', {
26 | icon: '1️⃣',
27 | id: 'reverse-1',
28 | });
29 | }, 10);
30 |
31 | setTimeout(
32 | () =>
33 | toast('Notification 2', {
34 | icon: '2️⃣',
35 | id: 'reverse-2',
36 | }),
37 | 250
38 | );
39 | setTimeout(
40 | () =>
41 | toast('Notification 3', {
42 | icon: '3️⃣',
43 | id: 'reverse-3',
44 | }),
45 | 500
46 | );
47 | setTimeout(
48 | () =>
49 | toast('Notification 4', {
50 | icon: '4️⃣',
51 | id: 'reverse-4',
52 | }),
53 | 750
54 | );
55 | (window as any).splitbee?.track('Change Order', {
56 | reverseOrder: !reverse,
57 | });
58 | onReverse(!reverse);
59 | };
60 |
61 | const renderPosition = (p: ToastPosition) => (
62 | {
72 | toast.success(
73 |
74 | Position set to {p}
75 | ,
76 | {
77 | id: 'position',
78 | }
79 | );
80 |
81 | (window as any).splitbee?.track('Change Position', {
82 | position: p,
83 | });
84 |
85 | (window as any).splitbee?.track('Trigger Toast', {
86 | example: 'position',
87 | });
88 |
89 | onPosition(p);
90 | }}
91 | >
92 | {p}
93 |
94 | );
95 |
96 | return (
97 |
98 |
`}
103 | />
104 |
105 | {positions.map((p) => renderPosition(p))}
106 |
107 |
108 |
118 | }
119 | onClick={reverseIt}
120 | >
121 | Toggle Direction
122 |
123 |
124 |
125 | );
126 | };
127 |
--------------------------------------------------------------------------------
/site/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/site/next.config.mjs:
--------------------------------------------------------------------------------
1 | import rehypeSlug from 'rehype-slug';
2 | import remarkGfm from 'remark-gfm';
3 | import nextMdx from '@next/mdx';
4 | import withPlugins from 'next-compose-plugins';
5 |
6 | const withMDX = nextMdx({
7 | extension: /\.mdx?$/,
8 | options: {
9 | rehypePlugins: [rehypeSlug],
10 | remarkPlugins: [remarkGfm],
11 | providerImportSource: '@mdx-js/react',
12 | },
13 | });
14 |
15 | const withSvgr = (nextConfig = {}, nextComposePlugins = {}) => {
16 | return Object.assign({}, nextConfig, {
17 | webpack(config, options) {
18 | config.module.rules.push({
19 | test: /.svg$/,
20 | use: ['@svgr/webpack'],
21 | });
22 |
23 | if (typeof nextConfig.webpack === 'function') {
24 | return nextConfig.webpack(config, options);
25 | }
26 |
27 | return config;
28 | },
29 | });
30 | };
31 |
32 | export default withPlugins(
33 | [
34 | withMDX({
35 | pageExtensions: ['ts', 'tsx', 'md', 'mdx'],
36 | }),
37 | withSvgr,
38 | ],
39 | {
40 | rewrites() {
41 | return [
42 | {
43 | source: '/bee.js',
44 | destination: 'https://cdn.splitbee.io/sb.js',
45 | },
46 | {
47 | source: '/_hive/:slug',
48 | destination: 'https://hive.splitbee.io/:slug',
49 | },
50 | ];
51 | },
52 | }
53 | );
54 |
--------------------------------------------------------------------------------
/site/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "site",
3 | "scripts": {
4 | "dev": "next dev",
5 | "build": "next build",
6 | "start": "next start"
7 | },
8 | "dependencies": {
9 | "@mdx-js/loader": "^2.3.0",
10 | "@mdx-js/react": "^2.3.0",
11 | "@next/mdx": "^12.3.4",
12 | "@svgr/webpack": "^6.5.1",
13 | "@types/prismjs": "^1.26.5",
14 | "@vercel/analytics": "^0.1.11",
15 | "clsx": "^1.1.1",
16 | "next": "^12.3.4",
17 | "next-seo": "^5.15.0",
18 | "postcss": "^8.4.49",
19 | "prism-react-renderer": "^1.3.5",
20 | "react": "^18.3.1",
21 | "react-dom": "^18.3.1",
22 | "react-hot-toast": "link:../",
23 | "rehype-slug": "^5.1.0"
24 | },
25 | "devDependencies": {
26 | "@tailwindcss/typography": "^0.5.15",
27 | "@types/node": "^18.19.68",
28 | "@types/react": "^18.3.18",
29 | "@types/react-dom": "^18.3.5",
30 | "autoprefixer": "^10.4.20",
31 | "next-compose-plugins": "^2.2.1",
32 | "remark-gfm": "^3.0.1",
33 | "tailwindcss": "^3.4.17",
34 | "typescript": "^4.9.5"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/site/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '../styles/tailwind-utils.css';
2 | import '../styles/main.css';
3 | import * as React from 'react';
4 | import Link from 'next/link';
5 | import Head from 'next/head';
6 | import { Analytics } from '@vercel/analytics/react';
7 |
8 |
9 | import { MDXProvider } from '@mdx-js/react';
10 | import { Code } from '../components/code';
11 |
12 | const components = {
13 | a: (props) => (
14 |
15 |
16 |
17 | ),
18 | h1: (props) => {
19 | const id = props.id || '';
20 | return (
21 |
30 | );
31 | },
32 | h2: (props) => {
33 | const id = props.id || '';
34 | return (
35 |
44 | );
45 | },
46 | h3: (props) => {
47 | const id = props.id || '';
48 | return (
49 |
58 | );
59 | },
60 | code: (props) =>
61 | props.className ? (
62 |
63 | ) : (
64 |
68 | ),
69 | };
70 |
71 | function MyApp({ Component, pageProps }) {
72 | return (
73 | <>
74 |
75 | {process.browser && (
76 |
77 | )}
78 |
79 |
80 |
81 |
82 |
83 |
84 | >
85 | );
86 | }
87 |
88 | export default MyApp;
89 |
--------------------------------------------------------------------------------
/site/pages/docs/index.mdx:
--------------------------------------------------------------------------------
1 | import Layout from '../../components/docs-layout';
2 | import toast from 'react-hot-toast';
3 |
4 | export const meta = {
5 | title: 'Documentation',
6 | };
7 |
8 | export default ({ children }) => {children} ;
9 |
10 | # Getting Started
11 |
12 | Add beautiful notifications to your React app with [react-hot-toast](https://github.com/timolins/react-hot-toast).
13 |
14 | ### Install with pnpm
15 |
16 | ```sh
17 | pnpm add react-hot-toast
18 | ```
19 |
20 | ### Install with NPM
21 |
22 | ```sh
23 | npm install react-hot-toast
24 | ```
25 |
26 | ## Basic usage
27 |
28 | ```jsx
29 | import toast, { Toaster } from 'react-hot-toast';
30 |
31 | const notify = () => toast('Here is your toast.');
32 |
33 | const App = () => {
34 | return (
35 |
36 | Make me a toast
37 |
38 |
39 | );
40 | };
41 | ```
42 |
--------------------------------------------------------------------------------
/site/pages/docs/styling.mdx:
--------------------------------------------------------------------------------
1 | import Layout from '../../components/docs-layout';
2 | import toast from 'react-hot-toast';
3 |
4 | export const meta = {
5 | title: 'Styling',
6 | };
7 |
8 | export default ({ children }) => {children} ;
9 |
10 | # Styling
11 |
12 | You can style your notifications globally with the `toastOptions` inside the Toaster component, or for each notification manually.
13 |
14 | ### Set default for all toasts
15 |
16 | ```jsx
17 |
27 | ```
28 |
29 | ### Set default for specific types
30 |
31 | ```jsx
32 |
46 | ```
47 |
48 | ### Style per toast
49 |
50 | ```jsx
51 | toast('I have a border.', {
52 | style: {
53 | border: '1px solid black',
54 | },
55 | });
56 | ```
57 |
58 | ## Change the offset
59 |
60 | If you want to change the offset of your notifications, you can adapt the absolute position in `containerStyle`.
61 |
62 | ```jsx
63 |
71 | ```
72 |
73 | ## Change position of the toaster
74 |
75 | By default, the toaster is position fixed in the window. If you want to place it somewhere else, you can ovewrite the position with `containerStyle`.
76 |
77 | ```jsx
78 |
83 | ```
84 |
85 | ## Change offset between toasts
86 |
87 | If you want to change the offset between notifications change the gutter.
88 |
89 | ```jsx
90 |
91 | ```
92 |
93 | ## Change icon color
94 |
95 | All icon colors can be changed by supplying a `iconTheme` with a `primary` & `secondary` color.
96 |
97 | ```jsx
98 |
108 | ```
109 |
110 | ## Change enter and exit animations
111 |
112 | In this example, we provide a render function with the default ` `. We overwrite the animation style based on the current state.
113 |
114 | ```jsx
115 | import { Toaster, ToastBar } from 'react-hot-toast';
116 |
117 |
118 | {(t) => (
119 |
128 | )}
129 | ;
130 | ```
131 |
--------------------------------------------------------------------------------
/site/pages/docs/toast-bar.mdx:
--------------------------------------------------------------------------------
1 | import Layout from '../../components/docs-layout';
2 | import toast from 'react-hot-toast';
3 |
4 | export const meta = {
5 | title: ' API',
6 | };
7 |
8 | export default ({ children }) => {children} ;
9 |
10 | # ` ` API
11 |
12 | This is the **default toast component** rendered by the [Toaster](/docs/toaster). You can use this component in a [Toaster](/docs/toaster) with a custom render function to overwrite its defaults.
13 |
14 | ## Available options
15 |
16 | ```jsx
17 |
22 | ```
23 |
24 | ## Add custom content
25 |
26 | You can add a **render function to the ToastBar to modify its content**. An object containing The `icon` as well as the `message` are passed into the function.
27 |
28 | ### Add a dismiss button
29 |
30 | In this example we add a basic dismiss button to all toasts, except if the loading one.
31 |
32 | ```jsx
33 | import { toast, Toaster, ToastBar } from 'react-hot-toast';
34 |
35 |
36 | {(t) => (
37 |
38 | {({ icon, message }) => (
39 | <>
40 | {icon}
41 | {message}
42 | {t.type !== 'loading' && (
43 | toast.dismiss(t.id)}>X
44 | )}
45 | >
46 | )}
47 |
48 | )}
49 | ;
50 | ```
51 |
--------------------------------------------------------------------------------
/site/pages/docs/toast.mdx:
--------------------------------------------------------------------------------
1 | import Layout from '../../components/docs-layout';
2 | import toast from 'react-hot-toast';
3 |
4 | export const meta = {
5 | title: 'toast() API',
6 | };
7 |
8 | # `toast()` API
9 |
10 | Call it to create a toast from anywhere, even outside React. Make sure you add the [` `](/docs/toaster) component to your app first.
11 |
12 | ## Available toast options
13 |
14 | You can provide `ToastOptions` as the second argument. They will overwrite all options received from [` `](/docs/toaster).
15 |
16 | ```js
17 | toast('Hello World', {
18 | duration: 4000,
19 | position: 'top-center',
20 |
21 | // Styling
22 | style: {},
23 | className: '',
24 |
25 | // Custom Icon
26 | icon: '👏',
27 |
28 | // Change colors of success/error/loading icon
29 | iconTheme: {
30 | primary: '#000',
31 | secondary: '#fff',
32 | },
33 |
34 | // Aria
35 | ariaProps: {
36 | role: 'status',
37 | 'aria-live': 'polite',
38 | },
39 |
40 | // Additional Configuration
41 | removeDelay: 1000,
42 | });
43 | ```
44 |
45 | ## Creating a toast
46 |
47 | ### Blank
48 |
49 | ```js
50 | toast('Hello World');
51 | ```
52 |
53 | The most basic variant. It does not have an icon by default, but you can provide one via the options. If you don't want any default styles, use `toast.custom()` instead.
54 |
55 | ### Success
56 |
57 | ```js
58 | toast.success('Successfully created!');
59 | ```
60 |
61 | Creates a notification with an animated checkmark. It can be themed with the `iconTheme` option.
62 |
63 | ### Error
64 |
65 | ```js
66 | toast.error('This is an error!');
67 | ```
68 |
69 | Creates a notification with an animated error icon. It can be themed with the `iconTheme` option.
70 |
71 | ### Custom (JSX)
72 |
73 | ```js
74 | toast.custom(Hello World
);
75 | ```
76 |
77 | Creates a custom notification with JSX without default styles.
78 |
79 | ### Loading
80 |
81 | ```js
82 | toast.loading('Waiting...');
83 | ```
84 |
85 | This will create a loading notification. Most likely, you want to update it afterwards. For a friendly alternative, check out `toast.promise()`, which takes care of that automatically.
86 |
87 | ### Promise
88 |
89 | This shorthand is useful for mapping a promise to a toast. It will update automatically when the promise resolves or fails.
90 |
91 | #### Simple Usage
92 |
93 | ```js
94 | const myPromise = fetchData();
95 |
96 | toast.promise(myPromise, {
97 | loading: 'Loading',
98 | success: 'Got the data',
99 | error: 'Error when fetching',
100 | });
101 | ```
102 |
103 | It's recommend to add min-width to your `toast.promise()` calls to **prevent jumps** from different message lengths.
104 |
105 | #### Advanced
106 |
107 | You can provide a function to the success/error messages to incorporate the result/error of the promise. The third argument are `toastOptions` similiar to [` `](/docs/toaster)
108 |
109 | ```js
110 | toast.promise(
111 | myPromise,
112 | {
113 | loading: 'Loading',
114 | success: (data) => `Successfully saved ${data.name}`,
115 | error: (err) => `This just happened: ${err.toString()}`,
116 | },
117 | {
118 | style: {
119 | minWidth: '250px',
120 | },
121 | success: {
122 | duration: 5000,
123 | icon: '🔥',
124 | },
125 | }
126 | );
127 | ```
128 |
129 | #### Using an Async Function
130 |
131 | You can also provide a function that returns a promise, which will be called automatically.
132 |
133 | ```js
134 | toast.promise(
135 | async () => {
136 | const { id } = await fetchData1();
137 | await fetchData2(id);
138 | },
139 | {
140 | loading: 'Loading',
141 | success: 'Got the data',
142 | error: 'Error when fetching',
143 | }
144 | );
145 | ```
146 |
147 | ## Default durations
148 |
149 | Every type has its own duration. You can overwrite them `duration` with the toast options. This can be done per toast options or globally by the [` `](/docs/toaster).
150 |
151 | | type | duration |
152 | | --------- | -------- |
153 | | `blank` | 4000 |
154 | | `error` | 4000 |
155 | | `success` | 2000 |
156 | | `custom` | 4000 |
157 | | `loading` | Infinity |
158 |
159 | ### Dismiss toast programmatically
160 |
161 | You can manually dismiss a notification with `toast.dismiss`. Be aware that it triggers the exit animation and does not remove the Toast instantly. Toasts will auto-remove after 1 second by default.
162 |
163 | #### Dismiss a single toast
164 |
165 | ```js
166 | const toastId = toast.loading('Loading...');
167 |
168 | // ...
169 |
170 | toast.dismiss(toastId);
171 | ```
172 |
173 | You can dismiss all toasts at once, by leaving out the `toastId`.
174 |
175 | #### Dismiss all toasts at once
176 |
177 | ```js
178 | toast.dismiss();
179 | ```
180 |
181 | To remove toasts instantly without any animations, use `toast.remove`.
182 |
183 | #### Configure remove delay
184 |
185 | ```js
186 | toast.success('Successfully created!', { removeDelay: 500 });
187 | ```
188 |
189 | By default, the remove operation is delayed by 1000ms. This is how long a toast should be kept in the DOM after being dismissed. It is used to play the exit animation. This duration (number in milliseconds) can be configured when calling the toast.
190 |
191 | Or, for all toasts, using the Toaster like so:
192 |
193 | ```js
194 |
199 | ```
200 |
201 | #### Remove toasts instantly
202 |
203 | ```js
204 | toast.remove(toastId);
205 |
206 | // or
207 |
208 | toast.remove();
209 | ```
210 |
211 | ### Update an existing toast
212 |
213 | Each toast call returns a unique id. Use in the toast options to update the existing toast.
214 |
215 | ```js
216 | const toastId = toast.loading('Loading...');
217 |
218 | // ...
219 |
220 | toast.success('This worked', {
221 | id: toastId,
222 | });
223 | ```
224 |
225 | ### Prevent duplicate toasts
226 |
227 | To prevent duplicates of the same kind, you can provide a unique permanent id.
228 |
229 | ```js
230 | toast.success('Copied to clipboard!', {
231 | id: 'clipboard',
232 | });
233 | ```
234 |
235 | ### Render JSX custom content
236 |
237 | You can provide a React component instead of text. If you don't want any default styles use `toast.custom()` instead.
238 |
239 | ```jsx
240 | toast(
241 |
242 | Custom and bold
243 | ,
244 | {
245 | icon: ,
246 | }
247 | );
248 | ```
249 |
250 | You can also supply a function that receives the `Toast` as an argument, giving you access to all properties. This allows you to access the toast id, which can be used to add a dismiss button.
251 |
252 | ```jsx
253 | toast(
254 | (t) => (
255 |
256 | Custom and bold
257 | toast.dismiss(t.id)}>Dismiss
258 |
259 | ),
260 | {
261 | icon: ,
262 | }
263 | );
264 | ```
265 |
266 | export default ({ children }) => {children} ;
267 |
--------------------------------------------------------------------------------
/site/pages/docs/toaster.mdx:
--------------------------------------------------------------------------------
1 | import Layout from '../../components/docs-layout';
2 | import toast from 'react-hot-toast';
3 |
4 | export const meta = {
5 | title: ' API',
6 | };
7 |
8 | export default ({ children }) => {children} ;
9 |
10 | # ` ` API
11 |
12 | This component will render all toasts. Alternatively you can create own renderer with the headless [`useToaster()`](/docs/use-toaster) hook.
13 |
14 | ## Available options
15 |
16 | ```jsx
17 |
43 | ```
44 |
45 | ### `position` Prop
46 |
47 | You can change the position of all toasts by modifying supplying `positon` prop.
48 |
49 | | Positions | | |
50 | | ----------- | ------------- | ------------ |
51 | | top-left | top-center | top-right |
52 | | bottom-left | bottom-center | bottom-right |
53 |
54 | ### `reverseOrder` Prop
55 |
56 | Toasts spawn at top by default. Set to `true` if you want new toasts at the end.
57 |
58 | ### `containerClassName` Prop
59 |
60 | Add a custom CSS class name to toaster div. Defaults to `undefined`.
61 |
62 | ### `containerStyle` Prop
63 |
64 | Customize the style of toaster div. This can be used to change the offset of all toasts
65 |
66 | ### `gutter` Prop
67 |
68 | Changes the gap between each toast. Defaults to `8`.
69 |
70 | ### `toastOptions` Prop
71 |
72 | These will act as default options for all toasts. See [`toast()`](/docs/toast) for all available options.
73 |
74 | #### Type specific options
75 |
76 | You can change the defaults for a specific type by adding, `success: {}`, `error: {}`, `loading: {}` or `custom: {}`.
77 |
78 | ## Using a custom render function
79 |
80 | You can provide your **own render function** to the Toaster by passing it as children. It will be called for each [Toast](https://github.com/timolins/react-hot-toast/blob/main/src/core/types.ts#L34) allowing you to render any component based on the toast state.
81 |
82 | ### Minimal example
83 |
84 | ```jsx
85 | import { Toaster, resolveValue } from 'react-hot-toast';
86 |
87 | // In your app
88 |
89 | {(t) => (
90 |
93 | {resolveValue(t.message, t)}
94 |
95 | )}
96 | ;
97 | ```
98 |
99 | `resolveValue()` is needed to resolve all message types: Text, JSX or a function that resolves to JSX.
100 |
101 | ### Adapting the default [` `](/docs/toast-bar)
102 |
103 | You can use this API to modify the default ToastBar as well. In this example we overwrite the animation style based on the current state.
104 |
105 | ```jsx
106 | import { Toaster, ToastBar } from 'react-hot-toast';
107 |
108 |
109 | {(t) => (
110 |
119 | )}
120 | ;
121 | ```
122 |
123 | Check out the [` `](/docs/toast-bar) docs for more options.
124 |
--------------------------------------------------------------------------------
/site/pages/docs/use-toaster-store.mdx:
--------------------------------------------------------------------------------
1 | import Layout from '../../components/docs-layout';
2 | import toast from 'react-hot-toast';
3 |
4 | export const meta = {
5 | title: 'useToasterStore() API',
6 | };
7 |
8 | export default ({ children }) => {children} ;
9 |
10 | # `useToasterStore()` API
11 |
12 | This hook gives you access to the internal toaster state. This is the right choice if you need access to the data without wanting to roll your own toaster.
13 |
14 | In comparison to [`useToaster()`](/docs/use-toaster) it does not handle pausing or provide handlers for creating your own notification system.
15 |
16 | ```jsx
17 | import { useToasterStore } from 'react-hot-toast';
18 |
19 | const { toasts, pausedAt } = useToasterStore();
20 | ```
21 |
--------------------------------------------------------------------------------
/site/pages/docs/use-toaster.mdx:
--------------------------------------------------------------------------------
1 | import Layout from '../../components/docs-layout';
2 | import toast from 'react-hot-toast';
3 |
4 | export const meta = {
5 | title: 'useToaster() API',
6 | };
7 |
8 | export default ({ children }) => {children} ;
9 |
10 | # `useToaster()` API
11 |
12 | The `useToaster()` hook provides you a **headless system that will manage the notification state** for you. This makes building your own notification system much easier.
13 |
14 | It solves the following problems for you:
15 |
16 | - Built-in dispatch system with [`toast()`](/docs/toast)
17 | - Handlers to pause toasts on hover
18 | - Automatically remove expired toasts
19 | - Support for unmount animations. Removal is delayed by 1s, but sets `visible` on the toast to `false`.
20 |
21 | ### Importing from headless
22 |
23 | You can import only the core of the library with `react-hot-toast/headless`. It won't include any styles, dependencies or custom components.
24 |
25 | ```jsx
26 | import { useToaster } from 'react-hot-toast/headless';
27 | ```
28 |
29 | Be aware: [react-hot-toast 2.0](/docs/version-2) adds support for **custom render functions**, an easier method to render custom notification components.
30 |
31 | It's recommended to only have one ` ` or `useToaster()` in your app at a time. If you need the current state without the handlers, you should use [`useToasterStore()`](/docs/use-toaster-store) instead.
32 |
33 | ## Usage with React Native
34 |
35 | Headless mode is perfectly suited to add notifications to your React Native app. You can check out [this example]().
36 |
37 | ## Examples
38 |
39 | ### Basic Example
40 |
41 | ```jsx
42 | import toast, { useToaster } from 'react-hot-toast/headless';
43 |
44 | const Notifications = () => {
45 | const { toasts, handlers } = useToaster();
46 | const { startPause, endPause } = handlers;
47 |
48 | return (
49 |
50 | {toasts
51 | .filter((toast) => toast.visible)
52 | .map((toast) => (
53 |
54 | {toast.message}
55 |
56 | ))}
57 |
58 | );
59 | };
60 |
61 | // Create toasts anywhere
62 | toast('Hello World');
63 | ```
64 |
65 | ### Animated Example
66 |
67 | Instead of mapping over `visibleToasts` we'll use `toasts`, which includes all hidden toasts. We animate them based on `toast.visible`. Toasts will be removed from 1 second after being dismissed, which give us enough time to animate.
68 |
69 | You can play with the demo on [CodeSandbox](https://codesandbox.io/s/react-hot-toast-usetoaster-headless-example-zw7op?file=/src/App.js).
70 |
71 | ```jsx
72 | import { useToaster } from 'react-hot-toast/headless';
73 |
74 | const Notifications = () => {
75 | const { toasts, handlers } = useToaster();
76 | const { startPause, endPause, calculateOffset, updateHeight } = handlers;
77 |
78 | return (
79 |
88 | {toasts.map((toast) => {
89 | const offset = calculateOffset(toast, {
90 | reverseOrder: false,
91 | gutter: 8,
92 | });
93 |
94 | const ref = (el) => {
95 | if (el && typeof toast.height !== "number") {
96 | const height = el.getBoundingClientRect().height;
97 | updateHeight(toast.id, height);
98 | }
99 | };
100 | return (
101 |
114 | {toast.message}
115 |
116 | );
117 | })}
118 |
119 | );
120 | };
121 | ```
122 |
--------------------------------------------------------------------------------
/site/pages/docs/version-2.mdx:
--------------------------------------------------------------------------------
1 | import Layout from '../../components/docs-layout';
2 | import toast from 'react-hot-toast';
3 |
4 | export const meta = {
5 | title: 'react-hot-toast 2.0 changes',
6 | };
7 |
8 | export default ({ children }) => {children} ;
9 |
10 | # What's new in react-hot-toast 2.0
11 |
12 | This release is all about **flexibility**. It allows you to create the notification system of your dreams, even simpler. Before we dig deeper into the new APIs, check out what's included in this release:
13 |
14 |
32 |
33 | As well as a many [other improvements and fixes](#changelog).
34 |
35 | ## Introducing `toast.custom()`
36 |
37 | This new function allows you to **render any React component** on the fly. Pass in JSX, and it will add it to the notification stack. There are no default styles applied, giving you complete control.
38 |
39 | This API makes it super easy to add [Tailwind UI Notifications](https://tailwindui.com/components/application-ui/overlays/notifications) to your React app.
40 |
41 | ```jsx
42 | // Minimal Example
43 | toast.custom(Minimal Example
);
44 |
45 | // Tailwind Example
46 | toast.custom((t) => (
47 |
52 | Hello TailwindCSS! 👋
53 |
54 | ));
55 | ```
56 |
57 |
58 |
60 | toast.custom((t) => (
61 | Hello from TailwindCSS! 👋
66 | ))
67 | }
68 | className="bg-toast-800 text-toast-100 whitespace-nowrap py-1 px-3 shadow-md rounded-lg absolute mt-[-4.5rem] -ml-2 transform -translate-x-full left-full">Run Example
69 |
70 |
71 | In the example above, we pass in a **function that returns JSX**. This allows us to access the current toast state and toggle between the enter and exit animation.
72 |
73 | Instead of CSS keyframe animations, you can use TailwindCSS classes by wrapping it in the [Transition](https://headlessui.dev/react/transition) component from [@headlessui/react](https://headlessui.dev/).
74 |
75 | ## Better accessibility
76 |
77 | The prefers reduced motion is now respected by default. If react-hot-toast detects this setting, it will use fade transitions instead of sliding.
78 |
79 | ## Smoother exit animation
80 |
81 | The exit animation is now less hectic when you have multiple toasts stacked.
82 |
83 | ## Per toast positioning
84 |
85 | From now on, it's possible to have toasts at multiple positions at once. Just add the `position` you want as option when dispatching a toast.
86 |
87 | ```jsx
88 | toast.success('Always at the bottom', {
89 | position: 'bottom-center',
90 | });
91 | ```
92 |
93 |
94 | {
97 | toast.success('Always at the bottom', {
98 | position: 'bottom-center',
99 | });
100 | }}>Run Example
101 |
102 |
103 | ## Relative positioning
104 |
105 | You can now overwrite the default position of the toaster and place it anywhere you want.
106 |
107 | ```jsx
108 |
109 | ```
110 |
111 | ## Simpler offset styling
112 |
113 | There is now a `gutter` option to control the gap between toasts.
114 |
115 | ```jsx
116 |
117 | ```
118 |
119 | The offset is now controlled by the Toaster and can be changed by overwriting the `top`, `right`, `bottom` and `left` styles.
120 |
121 | ```jsx
122 |
123 | ```
124 |
125 | ## Custom Renderer API
126 |
127 | You can now use the [` `](/docs/toaster#using-a-custom-render-function) to render your own components. Pass in a function that receives a [Toast](https://github.com/timolins/react-hot-toast/blob/main/src/core/types.ts#L34) as the first argument, allowing you to render whatever you please.
128 |
129 | This is a great alternative if you are using [`useToaster()`](/docs/use-toaster) to render create custom notfications.
130 |
131 | This API allows us to dynamically react to the current state of your toasts. This can be used to **change the default animations**, add **a custom dismiss button** or render a custom notification, like [TailwindUI Notifications](https://tailwindui.com/components/application-ui/overlays/notifications).
132 |
133 | ```jsx
134 | import { toast, Toaster, ToastBar } from 'react-hot-toast';
135 |
136 | const CustomToaster = () => (
137 |
138 | {(t) => (
139 |
140 | {({ icon, message }) => (
141 | <>
142 | {icon}
143 | {message}
144 | {t.type !== 'loading' && (
145 | toast.dismiss(t.id)}>X
146 | )}
147 | >
148 | )}
149 |
150 | )}
151 |
152 | );
153 | ```
154 |
155 | This example adapts the [ToastBar](/docs/toast-bar) with its new render function API. You can read more about the APIs in the [Toaster](/docs/toaster) & [ToastBar](/docs/toast-bar) docs.
156 |
157 | ## Available now
158 |
159 | Get react-hot-toast 2.0 while it's hot. Upgrading from 1.0.0 should be seamless for most users.
160 |
161 | ```sh
162 | pnpm add react-hot-toast
163 | ```
164 |
165 | ## The future and beyond
166 |
167 | React Hot Toast got a lot more flexible with this version, laying the **foundation for future releases**. Thanks to everyone who helped out; much appreciated!
168 |
169 | In the next releases, I plan to add the [most requested feature](https://github.com/timolins/react-hot-toast/issues/7): a dismiss button. As well as support for [custom toast types](https://github.com/timolins/react-hot-toast/issues/23).
170 |
171 | ---
172 |
173 | ## Changelog
174 |
175 | ### New
176 |
177 | - Easier Customization
178 | - Create your own toast renderer (without useToaster)
179 | - Support for custom render function in Toaster
180 | - Support for custom render function in ToastBar
181 | - `toast.custom()` - Render custom one-off toasts. No default styling will be applied.
182 | - Per toast positioning
183 | - New exit animation
184 | - Change the gutter between toasts with ` `
185 | - Support for relative positioning
186 | - Respect reduce motion OS setting
187 | - Create persistent toasts with `duration: Infinity`
188 |
189 | ### Breaking Changes
190 |
191 | - Use the `top`, `right`, `bottom`, `left` to in `containerStyle` to change the offset, instead of margin
192 | - Loading toasts no longer disappear after 30 seconds
193 | - `role` & `ariaLive` got moved into `ariaProps`
194 | - `useToaster()` no longer exposes `visibleToasts`
195 | - No longer expose `dispatch`
196 |
--------------------------------------------------------------------------------
/site/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { NextSeo } from 'next-seo';
2 | import toast, {
3 | Toaster,
4 | useToasterStore,
5 | ToastPosition,
6 | } from 'react-hot-toast';
7 | import React, { useState } from 'react';
8 | import clsx from 'clsx';
9 | import Link from 'next/link';
10 |
11 | import Logo from '../assets/logo.svg';
12 | import Butter1 from '../assets/butter-1.svg';
13 | import Butter2 from '../assets/butter-2.svg';
14 | import GitHub from '../assets/github.svg';
15 | import Checkmark from '../assets/checkmark.svg';
16 | import { ToastExample } from '../components/sections/toast-example';
17 | import { Footer } from '../components/sections/footer';
18 | import { ToasterExample } from '../components/sections/toaster-example';
19 | import { SplitbeeCounter } from '../components/sections/splitbee-counter';
20 |
21 | import packageInfo from '../../package.json';
22 | const version = packageInfo.version;
23 |
24 | const Feature: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
25 |
26 |
27 | {children}
28 |
29 | );
30 |
31 | const Step: React.FC<{
32 | count: number;
33 | title: string;
34 | subTitle: string;
35 | code: React.ReactElement;
36 | }> = (props) => (
37 |
38 |
39 | {props.count}
40 |
41 |
{props.title}
42 |
{props.subTitle}
43 |
44 | {props.code}
45 |
46 |
47 | );
48 |
49 | const Steps = () => (
50 |
51 |
57 | pnpm add {' '}
58 | react-hot-toast
59 |
60 | }
61 | >
62 |
68 | {''}
69 | {' '}
70 | {'
'}
71 | >
72 | }
73 | >
74 |
80 | {'toast'}
81 | {'("Hello World")'}
82 | >
83 | }
84 | >
85 |
86 | );
87 |
88 | const Features = () => (
89 |
90 | Hot by default
91 | Easy to use
92 | Accessible
93 | Emoji Support
94 | Customizable
95 | Promise API
96 | Lightweight
97 | Pause on hover
98 | Headless Hooks
99 |
100 | );
101 |
102 | export default function Home() {
103 | const [position, setPosition] = useState('top-center');
104 | const [reverse, setReverse] = useState(false);
105 | const { toasts: allToasts } = useToasterStore();
106 |
107 | const shouldFade =
108 | allToasts.filter((t) => t.visible).length && position.includes('top');
109 | return (
110 |
111 |
124 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 | );
250 | }
251 |
--------------------------------------------------------------------------------
/site/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ['tailwindcss'],
3 | };
4 |
--------------------------------------------------------------------------------
/site/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timolins/react-hot-toast/7f7845cd02a577f9f0d2e573b93aa136e5d5caa0/site/public/favicon.png
--------------------------------------------------------------------------------
/site/public/social-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timolins/react-hot-toast/7f7845cd02a577f9f0d2e573b93aa136e5d5caa0/site/public/social-image.png
--------------------------------------------------------------------------------
/site/styles/main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 |
4 | html,
5 | body,
6 | body > div {
7 | @apply flex flex-col justify-between flex-1 min-h-full text-toast-900;
8 | }
9 |
10 | button,
11 | a {
12 | @apply outline-none focus:outline-none ring-offset-green-900 ring-toast-900 ring-opacity-30 transition-shadow duration-100 focus:ring-4;
13 | }
14 |
--------------------------------------------------------------------------------
/site/styles/prism-theme.css:
--------------------------------------------------------------------------------
1 | /* Generated with http://k88hudson.github.io/syntax-highlighting-theme-generator/www */
2 | /* http://k88hudson.github.io/react-markdocs */
3 | /**
4 | * @author k88hudson
5 | *
6 | * Based on prism.js default theme for JavaScript, CSS and HTML
7 | * Based on dabblet (http://dabblet.com)
8 | * @author Lea Verou
9 | */
10 | /*********************************************************
11 | * General
12 | */
13 | pre[class*="language-"],
14 | code[class*="language-"] {
15 | color: #a1724e;
16 | font-size: 13px;
17 | text-shadow: none;
18 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
19 | direction: ltr;
20 | text-align: left;
21 | white-space: pre;
22 | word-spacing: normal;
23 | word-break: normal;
24 | line-height: 1.5;
25 | -moz-tab-size: 4;
26 | -o-tab-size: 4;
27 | tab-size: 4;
28 | -webkit-hyphens: none;
29 | -moz-hyphens: none;
30 | -ms-hyphens: none;
31 | hyphens: none;
32 | }
33 | pre[class*="language-"]::selection,
34 | code[class*="language-"]::selection,
35 | pre[class*="language-"]::mozselection,
36 | code[class*="language-"]::mozselection {
37 | text-shadow: none;
38 | background: #ffff00;
39 | }
40 | @media print {
41 | pre[class*="language-"],
42 | code[class*="language-"] {
43 | text-shadow: none;
44 | }
45 | }
46 | pre[class*="language-"] {
47 | padding: 1em;
48 | margin: .5em 0;
49 | overflow: auto;
50 | background: #faf0de;
51 | }
52 | :not(pre) > code[class*="language-"] {
53 | padding: .1em .3em;
54 | border-radius: .3em;
55 | color: #db4c69;
56 | background: #f9f2f4;
57 | }
58 | /*********************************************************
59 | * Tokens
60 | */
61 | .namespace {
62 | opacity: .7;
63 | }
64 | .token.comment,
65 | .token.prolog,
66 | .token.doctype,
67 | .token.cdata {
68 | color: #dddddd;
69 | }
70 | .token.punctuation {
71 | color: #999999;
72 | }
73 | .token.property,
74 | .token.tag,
75 | .token.boolean,
76 | .token.number,
77 | .token.constant,
78 | .token.symbol,
79 | .token.deleted {
80 | color: #327015;
81 | }
82 | .token.selector,
83 | .token.attr-name,
84 | .token.string,
85 | .token.char,
86 | .token.builtin,
87 | .token.inserted {
88 | color: #54b427;
89 | }
90 | .token.operator,
91 | .token.entity,
92 | .token.url,
93 | .language-css .token.string,
94 | .style .token.string {
95 | color: #8c4913;
96 | background: transparent;
97 | }
98 | .token.atrule,
99 | .token.attr-value,
100 | .token.keyword {
101 | color: #482307;
102 | }
103 | .token.function {
104 | color: #381b05;
105 | }
106 | .token.regex,
107 | .token.important,
108 | .token.variable {
109 | color: #ffedc0;
110 | }
111 | .token.important,
112 | .token.bold {
113 | font-weight: bold;
114 | }
115 | .token.italic {
116 | font-style: italic;
117 | }
118 | .token.entity {
119 | cursor: help;
120 | }
121 | /*********************************************************
122 | * Line highlighting
123 | */
124 | pre[data-line] {
125 | position: relative;
126 | }
127 | pre[class*="language-"] > code[class*="language-"] {
128 | position: relative;
129 | z-index: 1;
130 | }
131 | .line-highlight {
132 | position: absolute;
133 | left: 0;
134 | right: 0;
135 | padding: inherit 0;
136 | margin-top: 1em;
137 | background: #ffe092;
138 | box-shadow: inset 5px 0 0 #482307;
139 | z-index: 0;
140 | pointer-events: none;
141 | line-height: inherit;
142 | white-space: pre;
143 | }
144 |
--------------------------------------------------------------------------------
/site/styles/tailwind-utils.css:
--------------------------------------------------------------------------------
1 | @tailwind utilities;
2 |
--------------------------------------------------------------------------------
/site/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | mode: 'jit',
3 | content: [
4 | './pages/*.tsx',
5 | './pages/**/*.tsx',
6 | './pages/*.mdx',
7 | './pages/**/*.mdx',
8 | './components/*.tsx',
9 | './components/**/*.tsx',
10 | ],
11 | theme: {
12 | extend: {
13 | boxShadow: {
14 | 'small-button': '0px 1px 2px rgba(126, 56, 0, 0.5)',
15 | button:
16 | '-6px 8px 10px rgba(81, 41, 10, 0.1), 0px 2px 2px rgba(81, 41, 10, 0.2)',
17 | 'button-active':
18 | '-1px 2px 5px rgba(81, 41, 10, 0.15), 0px 1px 1px rgba(81, 41, 10, 0.15)',
19 | },
20 | animation: {
21 | enter: 'enter 200ms ease-out',
22 | 'slide-in': 'slide-in 1.2s cubic-bezier(.41,.73,.51,1.02)',
23 | leave: 'leave 150ms ease-in forwards',
24 | },
25 | keyframes: {
26 | enter: {
27 | '0%': { transform: 'scale(0.9)', opacity: 0 },
28 | '100%': { transform: 'scale(1)', opacity: 1 },
29 | },
30 | leave: {
31 | '0%': { transform: 'scale(1)', opacity: 1 },
32 | '100%': { transform: 'scale(0.9)', opacity: 0 },
33 | },
34 | 'slide-in': {
35 | '0%': { transform: 'translateY(-100%)' },
36 | '100%': { transform: 'translateY(0)' },
37 | },
38 | },
39 |
40 | colors: {
41 | toast: {
42 | '50': '#FFF6DF',
43 | '100': '#fdf7f1',
44 | '200': '#F8EEDB',
45 | '300': '#ebbf99',
46 | '400': '#dea373',
47 | '500': '#ce864f',
48 | '600': '#A1724E',
49 | '700': '#8c501c',
50 | '800': '#5c340f',
51 | '900': '#482307',
52 | },
53 | },
54 | typography: (theme) => ({
55 | DEFAULT: {
56 | css: {
57 | '--tw-prose-bullets': theme('colors.toast[400]'),
58 | '--tw-prose-links': theme('colors.toast[600]'),
59 | color: theme('colors.toast.900'),
60 | h1: {
61 | color: theme('colors.toast.900'),
62 | },
63 | h2: {
64 | color: theme('colors.toast.900'),
65 | },
66 | h3: {
67 | color: theme('colors.toast.800'),
68 | },
69 | h4: {
70 | color: theme('colors.toast.900'),
71 | },
72 | a: {
73 | color: theme('colors.toast.600'),
74 | },
75 | strong: {
76 | color: theme('colors.toast.900'),
77 | },
78 | pre: {
79 | color: null,
80 | backgroundColor: null,
81 | overflowX: 'auto',
82 | fontSize: theme('fontSize.base'),
83 | padding: 0,
84 | },
85 | 'pre pre': {
86 | padding: theme('spacing.4'),
87 | margin: 0,
88 | },
89 | 'pre code': {
90 | backgroundColor: 'transparent',
91 | borderWidth: '0',
92 | borderRadius: '0',
93 | fontWeight: '400',
94 | color: 'inherit',
95 | fontFamily: 'inherit',
96 | lineHeight: 'inherit',
97 | },
98 | code: {
99 | color: theme('colors.toast.900'),
100 | fontWeight: '600',
101 | },
102 | 'code::before': {
103 | content: '""',
104 | },
105 | 'code::after': {
106 | content: '""',
107 | },
108 | thead: {
109 | color: theme('colors.toast.900'),
110 | fontWeight: '600',
111 | borderBottomWidth: '1px',
112 | borderBottomColor: theme('colors.toast.200'),
113 | },
114 | 'tbody tr': {
115 | borderBottomWidth: '1px',
116 | borderBottomColor: theme('colors.toast.200'),
117 | },
118 | 'ul > li::before': {
119 | content: '""',
120 | position: 'absolute',
121 | backgroundColor: theme('colors.toast.800'),
122 | borderRadius: '50%',
123 | },
124 | // ...
125 | },
126 | },
127 | }),
128 | },
129 | container: {
130 | padding: '1rem',
131 | center: true,
132 | },
133 | },
134 | variants: {
135 | extend: {
136 | translate: ['active'],
137 | gradientColorStops: ['active'],
138 | boxShadow: ['active'],
139 | },
140 | },
141 | plugins: [require('@tailwindcss/typography')],
142 | };
143 |
--------------------------------------------------------------------------------
/site/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "incremental": true
21 | },
22 | "include": [
23 | "next-env.d.ts",
24 | "types/*.d.ts",
25 | "**/*.ts",
26 | "**/*.tsx",
27 | "**/**/*.ts",
28 | "**/**/*.tsx",
29 | "../**/*.ts",
30 | "../**/*.tsx"
31 | ],
32 | "exclude": [
33 | "node_modules"
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/site/types/mdx.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.mdx' {
2 | let MDXComponent: (props: any) => React.ReactElement;
3 | export default MDXComponent;
4 | }
5 |
--------------------------------------------------------------------------------
/site/types/svg.d.ts:
--------------------------------------------------------------------------------
1 | interface SvgrComponent
2 | extends React.StatelessComponent> {}
3 |
4 | declare module '*.svg' {
5 | const value: SvgrComponent;
6 | export default value;
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/checkmark.tsx:
--------------------------------------------------------------------------------
1 | import { styled, keyframes } from 'goober';
2 |
3 | const circleAnimation = keyframes`
4 | from {
5 | transform: scale(0) rotate(45deg);
6 | opacity: 0;
7 | }
8 | to {
9 | transform: scale(1) rotate(45deg);
10 | opacity: 1;
11 | }`;
12 |
13 | const checkmarkAnimation = keyframes`
14 | 0% {
15 | height: 0;
16 | width: 0;
17 | opacity: 0;
18 | }
19 | 40% {
20 | height: 0;
21 | width: 6px;
22 | opacity: 1;
23 | }
24 | 100% {
25 | opacity: 1;
26 | height: 10px;
27 | }`;
28 |
29 | export interface CheckmarkTheme {
30 | primary?: string;
31 | secondary?: string;
32 | }
33 |
34 | export const CheckmarkIcon = styled('div')`
35 | width: 20px;
36 | opacity: 0;
37 | height: 20px;
38 | border-radius: 10px;
39 | background: ${(p) => p.primary || '#61d345'};
40 | position: relative;
41 | transform: rotate(45deg);
42 |
43 | animation: ${circleAnimation} 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
44 | forwards;
45 | animation-delay: 100ms;
46 | &:after {
47 | content: '';
48 | box-sizing: border-box;
49 | animation: ${checkmarkAnimation} 0.2s ease-out forwards;
50 | opacity: 0;
51 | animation-delay: 200ms;
52 | position: absolute;
53 | border-right: 2px solid;
54 | border-bottom: 2px solid;
55 | border-color: ${(p) => p.secondary || '#fff'};
56 | bottom: 6px;
57 | left: 6px;
58 | height: 10px;
59 | width: 6px;
60 | }
61 | `;
62 |
--------------------------------------------------------------------------------
/src/components/error.tsx:
--------------------------------------------------------------------------------
1 | import { styled, keyframes } from 'goober';
2 |
3 | const circleAnimation = keyframes`
4 | from {
5 | transform: scale(0) rotate(45deg);
6 | opacity: 0;
7 | }
8 | to {
9 | transform: scale(1) rotate(45deg);
10 | opacity: 1;
11 | }`;
12 |
13 | const firstLineAnimation = keyframes`
14 | from {
15 | transform: scale(0);
16 | opacity: 0;
17 | }
18 | to {
19 | transform: scale(1);
20 | opacity: 1;
21 | }`;
22 |
23 | const secondLineAnimation = keyframes`
24 | from {
25 | transform: scale(0) rotate(90deg);
26 | opacity: 0;
27 | }
28 | to {
29 | transform: scale(1) rotate(90deg);
30 | opacity: 1;
31 | }`;
32 |
33 | export interface ErrorTheme {
34 | primary?: string;
35 | secondary?: string;
36 | }
37 |
38 | export const ErrorIcon = styled('div')`
39 | width: 20px;
40 | opacity: 0;
41 | height: 20px;
42 | border-radius: 10px;
43 | background: ${(p) => p.primary || '#ff4b4b'};
44 | position: relative;
45 | transform: rotate(45deg);
46 |
47 | animation: ${circleAnimation} 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
48 | forwards;
49 | animation-delay: 100ms;
50 |
51 | &:after,
52 | &:before {
53 | content: '';
54 | animation: ${firstLineAnimation} 0.15s ease-out forwards;
55 | animation-delay: 150ms;
56 | position: absolute;
57 | border-radius: 3px;
58 | opacity: 0;
59 | background: ${(p) => p.secondary || '#fff'};
60 | bottom: 9px;
61 | left: 4px;
62 | height: 2px;
63 | width: 12px;
64 | }
65 |
66 | &:before {
67 | animation: ${secondLineAnimation} 0.15s ease-out forwards;
68 | animation-delay: 180ms;
69 | transform: rotate(90deg);
70 | }
71 | `;
72 |
--------------------------------------------------------------------------------
/src/components/loader.tsx:
--------------------------------------------------------------------------------
1 | import { styled, keyframes } from 'goober';
2 |
3 | const rotate = keyframes`
4 | from {
5 | transform: rotate(0deg);
6 | }
7 | to {
8 | transform: rotate(360deg);
9 | }
10 | `;
11 |
12 | export interface LoaderTheme {
13 | primary?: string;
14 | secondary?: string;
15 | }
16 |
17 | export const LoaderIcon = styled('div')`
18 | width: 12px;
19 | height: 12px;
20 | box-sizing: border-box;
21 | border: 2px solid;
22 | border-radius: 100%;
23 | border-color: ${(p) => p.secondary || '#e0e0e0'};
24 | border-right-color: ${(p) => p.primary || '#616161'};
25 | animation: ${rotate} 1s linear infinite;
26 | `;
27 |
--------------------------------------------------------------------------------
/src/components/toast-bar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { styled, keyframes } from 'goober';
3 |
4 | import { Toast, ToastPosition, resolveValue, Renderable } from '../core/types';
5 | import { ToastIcon } from './toast-icon';
6 | import { prefersReducedMotion } from '../core/utils';
7 |
8 | const enterAnimation = (factor: number) => `
9 | 0% {transform: translate3d(0,${factor * -200}%,0) scale(.6); opacity:.5;}
10 | 100% {transform: translate3d(0,0,0) scale(1); opacity:1;}
11 | `;
12 |
13 | const exitAnimation = (factor: number) => `
14 | 0% {transform: translate3d(0,0,-1px) scale(1); opacity:1;}
15 | 100% {transform: translate3d(0,${factor * -150}%,-1px) scale(.6); opacity:0;}
16 | `;
17 |
18 | const fadeInAnimation = `0%{opacity:0;} 100%{opacity:1;}`;
19 | const fadeOutAnimation = `0%{opacity:1;} 100%{opacity:0;}`;
20 |
21 | const ToastBarBase = styled('div')`
22 | display: flex;
23 | align-items: center;
24 | background: #fff;
25 | color: #363636;
26 | line-height: 1.3;
27 | will-change: transform;
28 | box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1), 0 3px 3px rgba(0, 0, 0, 0.05);
29 | max-width: 350px;
30 | pointer-events: auto;
31 | padding: 8px 10px;
32 | border-radius: 8px;
33 | `;
34 |
35 | const Message = styled('div')`
36 | display: flex;
37 | justify-content: center;
38 | margin: 4px 10px;
39 | color: inherit;
40 | flex: 1 1 auto;
41 | white-space: pre-line;
42 | `;
43 |
44 | interface ToastBarProps {
45 | toast: Toast;
46 | position?: ToastPosition;
47 | style?: React.CSSProperties;
48 | children?: (components: {
49 | icon: Renderable;
50 | message: Renderable;
51 | }) => Renderable;
52 | }
53 |
54 | const getAnimationStyle = (
55 | position: ToastPosition,
56 | visible: boolean
57 | ): React.CSSProperties => {
58 | const top = position.includes('top');
59 | const factor = top ? 1 : -1;
60 |
61 | const [enter, exit] = prefersReducedMotion()
62 | ? [fadeInAnimation, fadeOutAnimation]
63 | : [enterAnimation(factor), exitAnimation(factor)];
64 |
65 | return {
66 | animation: visible
67 | ? `${keyframes(enter)} 0.35s cubic-bezier(.21,1.02,.73,1) forwards`
68 | : `${keyframes(exit)} 0.4s forwards cubic-bezier(.06,.71,.55,1)`,
69 | };
70 | };
71 |
72 | export const ToastBar: React.FC = React.memo(
73 | ({ toast, position, style, children }) => {
74 | const animationStyle: React.CSSProperties = toast.height
75 | ? getAnimationStyle(
76 | toast.position || position || 'top-center',
77 | toast.visible
78 | )
79 | : { opacity: 0 };
80 |
81 | const icon = ;
82 | const message = (
83 |
84 | {resolveValue(toast.message, toast)}
85 |
86 | );
87 |
88 | return (
89 |
97 | {typeof children === 'function' ? (
98 | children({
99 | icon,
100 | message,
101 | })
102 | ) : (
103 | <>
104 | {icon}
105 | {message}
106 | >
107 | )}
108 |
109 | );
110 | }
111 | );
112 |
--------------------------------------------------------------------------------
/src/components/toast-icon.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { styled, keyframes } from 'goober';
3 |
4 | import { Toast } from '../core/types';
5 | import { ErrorIcon, ErrorTheme } from './error';
6 | import { LoaderIcon, LoaderTheme } from './loader';
7 | import { CheckmarkIcon, CheckmarkTheme } from './checkmark';
8 |
9 | const StatusWrapper = styled('div')`
10 | position: absolute;
11 | `;
12 |
13 | const IndicatorWrapper = styled('div')`
14 | position: relative;
15 | display: flex;
16 | justify-content: center;
17 | align-items: center;
18 | min-width: 20px;
19 | min-height: 20px;
20 | `;
21 |
22 | const enter = keyframes`
23 | from {
24 | transform: scale(0.6);
25 | opacity: 0.4;
26 | }
27 | to {
28 | transform: scale(1);
29 | opacity: 1;
30 | }`;
31 |
32 | export const AnimatedIconWrapper = styled('div')`
33 | position: relative;
34 | transform: scale(0.6);
35 | opacity: 0.4;
36 | min-width: 20px;
37 | animation: ${enter} 0.3s 0.12s cubic-bezier(0.175, 0.885, 0.32, 1.275)
38 | forwards;
39 | `;
40 |
41 | export type IconThemes = Partial<{
42 | success: CheckmarkTheme;
43 | error: ErrorTheme;
44 | loading: LoaderTheme;
45 | }>;
46 |
47 | export const ToastIcon: React.FC<{
48 | toast: Toast;
49 | }> = ({ toast }) => {
50 | const { icon, type, iconTheme } = toast;
51 | if (icon !== undefined) {
52 | if (typeof icon === 'string') {
53 | return {icon} ;
54 | } else {
55 | return icon;
56 | }
57 | }
58 |
59 | if (type === 'blank') {
60 | return null;
61 | }
62 |
63 | return (
64 |
65 |
66 | {type !== 'loading' && (
67 |
68 | {type === 'error' ? (
69 |
70 | ) : (
71 |
72 | )}
73 |
74 | )}
75 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/src/components/toaster.tsx:
--------------------------------------------------------------------------------
1 | import { css, setup } from 'goober';
2 | import * as React from 'react';
3 | import {
4 | resolveValue,
5 | ToasterProps,
6 | ToastPosition,
7 | ToastWrapperProps,
8 | } from '../core/types';
9 | import { useToaster } from '../core/use-toaster';
10 | import { prefersReducedMotion } from '../core/utils';
11 | import { ToastBar } from './toast-bar';
12 |
13 | setup(React.createElement);
14 |
15 | const ToastWrapper = ({
16 | id,
17 | className,
18 | style,
19 | onHeightUpdate,
20 | children,
21 | }: ToastWrapperProps) => {
22 | const ref = React.useCallback(
23 | (el: HTMLElement | null) => {
24 | if (el) {
25 | const updateHeight = () => {
26 | const height = el.getBoundingClientRect().height;
27 | onHeightUpdate(id, height);
28 | };
29 | updateHeight();
30 | new MutationObserver(updateHeight).observe(el, {
31 | subtree: true,
32 | childList: true,
33 | characterData: true,
34 | });
35 | }
36 | },
37 | [id, onHeightUpdate]
38 | );
39 |
40 | return (
41 |
42 | {children}
43 |
44 | );
45 | };
46 |
47 | const getPositionStyle = (
48 | position: ToastPosition,
49 | offset: number
50 | ): React.CSSProperties => {
51 | const top = position.includes('top');
52 | const verticalStyle: React.CSSProperties = top ? { top: 0 } : { bottom: 0 };
53 | const horizontalStyle: React.CSSProperties = position.includes('center')
54 | ? {
55 | justifyContent: 'center',
56 | }
57 | : position.includes('right')
58 | ? {
59 | justifyContent: 'flex-end',
60 | }
61 | : {};
62 | return {
63 | left: 0,
64 | right: 0,
65 | display: 'flex',
66 | position: 'absolute',
67 | transition: prefersReducedMotion()
68 | ? undefined
69 | : `all 230ms cubic-bezier(.21,1.02,.73,1)`,
70 | transform: `translateY(${offset * (top ? 1 : -1)}px)`,
71 | ...verticalStyle,
72 | ...horizontalStyle,
73 | };
74 | };
75 |
76 | const activeClass = css`
77 | z-index: 9999;
78 | > * {
79 | pointer-events: auto;
80 | }
81 | `;
82 |
83 | const DEFAULT_OFFSET = 16;
84 |
85 | export const Toaster: React.FC = ({
86 | reverseOrder,
87 | position = 'top-center',
88 | toastOptions,
89 | gutter,
90 | children,
91 | containerStyle,
92 | containerClassName,
93 | }) => {
94 | const { toasts, handlers } = useToaster(toastOptions);
95 |
96 | return (
97 |
113 | {toasts.map((t) => {
114 | const toastPosition = t.position || position;
115 | const offset = handlers.calculateOffset(t, {
116 | reverseOrder,
117 | gutter,
118 | defaultPosition: position,
119 | });
120 | const positionStyle = getPositionStyle(toastPosition, offset);
121 |
122 | return (
123 |
130 | {t.type === 'custom' ? (
131 | resolveValue(t.message, t)
132 | ) : children ? (
133 | children(t)
134 | ) : (
135 |
136 | )}
137 |
138 | );
139 | })}
140 |
141 | );
142 | };
143 |
--------------------------------------------------------------------------------
/src/core/store.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef } from 'react';
2 | import { DefaultToastOptions, Toast, ToastType } from './types';
3 |
4 | const TOAST_LIMIT = 20;
5 |
6 | export enum ActionType {
7 | ADD_TOAST,
8 | UPDATE_TOAST,
9 | UPSERT_TOAST,
10 | DISMISS_TOAST,
11 | REMOVE_TOAST,
12 | START_PAUSE,
13 | END_PAUSE,
14 | }
15 |
16 | type Action =
17 | | {
18 | type: ActionType.ADD_TOAST;
19 | toast: Toast;
20 | }
21 | | {
22 | type: ActionType.UPSERT_TOAST;
23 | toast: Toast;
24 | }
25 | | {
26 | type: ActionType.UPDATE_TOAST;
27 | toast: Partial;
28 | }
29 | | {
30 | type: ActionType.DISMISS_TOAST;
31 | toastId?: string;
32 | }
33 | | {
34 | type: ActionType.REMOVE_TOAST;
35 | toastId?: string;
36 | }
37 | | {
38 | type: ActionType.START_PAUSE;
39 | time: number;
40 | }
41 | | {
42 | type: ActionType.END_PAUSE;
43 | time: number;
44 | };
45 |
46 | interface State {
47 | toasts: Toast[];
48 | pausedAt: number | undefined;
49 | }
50 |
51 | export const reducer = (state: State, action: Action): State => {
52 | switch (action.type) {
53 | case ActionType.ADD_TOAST:
54 | return {
55 | ...state,
56 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
57 | };
58 |
59 | case ActionType.UPDATE_TOAST:
60 | return {
61 | ...state,
62 | toasts: state.toasts.map((t) =>
63 | t.id === action.toast.id ? { ...t, ...action.toast } : t
64 | ),
65 | };
66 |
67 | case ActionType.UPSERT_TOAST:
68 | const { toast } = action;
69 | return reducer(state, {
70 | type: state.toasts.find((t) => t.id === toast.id)
71 | ? ActionType.UPDATE_TOAST
72 | : ActionType.ADD_TOAST,
73 | toast,
74 | });
75 |
76 | case ActionType.DISMISS_TOAST:
77 | const { toastId } = action;
78 |
79 | return {
80 | ...state,
81 | toasts: state.toasts.map((t) =>
82 | t.id === toastId || toastId === undefined
83 | ? {
84 | ...t,
85 | dismissed: true,
86 | visible: false,
87 | }
88 | : t
89 | ),
90 | };
91 | case ActionType.REMOVE_TOAST:
92 | if (action.toastId === undefined) {
93 | return {
94 | ...state,
95 | toasts: [],
96 | };
97 | }
98 | return {
99 | ...state,
100 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
101 | };
102 |
103 | case ActionType.START_PAUSE:
104 | return {
105 | ...state,
106 | pausedAt: action.time,
107 | };
108 |
109 | case ActionType.END_PAUSE:
110 | const diff = action.time - (state.pausedAt || 0);
111 |
112 | return {
113 | ...state,
114 | pausedAt: undefined,
115 | toasts: state.toasts.map((t) => ({
116 | ...t,
117 | pauseDuration: t.pauseDuration + diff,
118 | })),
119 | };
120 | }
121 | };
122 |
123 | const listeners: Array<(state: State) => void> = [];
124 |
125 | let memoryState: State = { toasts: [], pausedAt: undefined };
126 |
127 | export const dispatch = (action: Action) => {
128 | memoryState = reducer(memoryState, action);
129 | listeners.forEach((listener) => {
130 | listener(memoryState);
131 | });
132 | };
133 |
134 | export const defaultTimeouts: {
135 | [key in ToastType]: number;
136 | } = {
137 | blank: 4000,
138 | error: 4000,
139 | success: 2000,
140 | loading: Infinity,
141 | custom: 4000,
142 | };
143 |
144 | export const useStore = (toastOptions: DefaultToastOptions = {}): State => {
145 | const [state, setState] = useState(memoryState);
146 | const initial = useRef(memoryState);
147 |
148 | // TODO: Switch to useSyncExternalStore when targeting React 18+
149 | useEffect(() => {
150 | if (initial.current !== memoryState) {
151 | setState(memoryState);
152 | }
153 | listeners.push(setState);
154 | return () => {
155 | const index = listeners.indexOf(setState);
156 | if (index > -1) {
157 | listeners.splice(index, 1);
158 | }
159 | };
160 | }, []);
161 |
162 | const mergedToasts = state.toasts.map((t) => ({
163 | ...toastOptions,
164 | ...toastOptions[t.type],
165 | ...t,
166 | removeDelay:
167 | t.removeDelay ||
168 | toastOptions[t.type]?.removeDelay ||
169 | toastOptions?.removeDelay,
170 | duration:
171 | t.duration ||
172 | toastOptions[t.type]?.duration ||
173 | toastOptions?.duration ||
174 | defaultTimeouts[t.type],
175 | style: {
176 | ...toastOptions.style,
177 | ...toastOptions[t.type]?.style,
178 | ...t.style,
179 | },
180 | }));
181 |
182 | return {
183 | ...state,
184 | toasts: mergedToasts,
185 | };
186 | };
187 |
--------------------------------------------------------------------------------
/src/core/toast.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Renderable,
3 | Toast,
4 | ToastOptions,
5 | ToastType,
6 | DefaultToastOptions,
7 | ValueOrFunction,
8 | resolveValue,
9 | } from './types';
10 | import { genId } from './utils';
11 | import { dispatch, ActionType } from './store';
12 |
13 | type Message = ValueOrFunction;
14 |
15 | type ToastHandler = (message: Message, options?: ToastOptions) => string;
16 |
17 | const createToast = (
18 | message: Message,
19 | type: ToastType = 'blank',
20 | opts?: ToastOptions
21 | ): Toast => ({
22 | createdAt: Date.now(),
23 | visible: true,
24 | dismissed: false,
25 | type,
26 | ariaProps: {
27 | role: 'status',
28 | 'aria-live': 'polite',
29 | },
30 | message,
31 | pauseDuration: 0,
32 | ...opts,
33 | id: opts?.id || genId(),
34 | });
35 |
36 | const createHandler =
37 | (type?: ToastType): ToastHandler =>
38 | (message, options) => {
39 | const toast = createToast(message, type, options);
40 | dispatch({ type: ActionType.UPSERT_TOAST, toast });
41 | return toast.id;
42 | };
43 |
44 | const toast = (message: Message, opts?: ToastOptions) =>
45 | createHandler('blank')(message, opts);
46 |
47 | toast.error = createHandler('error');
48 | toast.success = createHandler('success');
49 | toast.loading = createHandler('loading');
50 | toast.custom = createHandler('custom');
51 |
52 | toast.dismiss = (toastId?: string) => {
53 | dispatch({
54 | type: ActionType.DISMISS_TOAST,
55 | toastId,
56 | });
57 | };
58 |
59 | toast.remove = (toastId?: string) =>
60 | dispatch({ type: ActionType.REMOVE_TOAST, toastId });
61 |
62 | toast.promise = (
63 | promise: Promise | (() => Promise),
64 | msgs: {
65 | loading: Renderable;
66 | success?: ValueOrFunction;
67 | error?: ValueOrFunction;
68 | },
69 | opts?: DefaultToastOptions
70 | ) => {
71 | const id = toast.loading(msgs.loading, { ...opts, ...opts?.loading });
72 |
73 | if (typeof promise === 'function') {
74 | promise = promise();
75 | }
76 |
77 | promise
78 | .then((p) => {
79 | const successMessage = msgs.success
80 | ? resolveValue(msgs.success, p)
81 | : undefined;
82 |
83 | if (successMessage) {
84 | toast.success(successMessage, {
85 | id,
86 | ...opts,
87 | ...opts?.success,
88 | });
89 | } else {
90 | toast.dismiss(id);
91 | }
92 | return p;
93 | })
94 | .catch((e) => {
95 | const errorMessage = msgs.error ? resolveValue(msgs.error, e) : undefined;
96 |
97 | if (errorMessage) {
98 | toast.error(errorMessage, {
99 | id,
100 | ...opts,
101 | ...opts?.error,
102 | });
103 | } else {
104 | toast.dismiss(id);
105 | }
106 | });
107 |
108 | return promise;
109 | };
110 |
111 | export { toast };
112 |
--------------------------------------------------------------------------------
/src/core/types.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 |
3 | export type ToastType = 'success' | 'error' | 'loading' | 'blank' | 'custom';
4 | export type ToastPosition =
5 | | 'top-left'
6 | | 'top-center'
7 | | 'top-right'
8 | | 'bottom-left'
9 | | 'bottom-center'
10 | | 'bottom-right';
11 |
12 | export type Renderable = React.ReactElement | string | null;
13 |
14 | export interface IconTheme {
15 | primary: string;
16 | secondary: string;
17 | }
18 |
19 | export type ValueFunction = (arg: TArg) => TValue;
20 | export type ValueOrFunction =
21 | | TValue
22 | | ValueFunction;
23 |
24 | const isFunction = (
25 | valOrFunction: ValueOrFunction
26 | ): valOrFunction is ValueFunction =>
27 | typeof valOrFunction === 'function';
28 |
29 | export const resolveValue = (
30 | valOrFunction: ValueOrFunction,
31 | arg: TArg
32 | ): TValue => (isFunction(valOrFunction) ? valOrFunction(arg) : valOrFunction);
33 |
34 | export interface Toast {
35 | type: ToastType;
36 | id: string;
37 | message: ValueOrFunction;
38 | icon?: Renderable;
39 | duration?: number;
40 | pauseDuration: number;
41 | position?: ToastPosition;
42 | removeDelay?: number;
43 |
44 | ariaProps: {
45 | role: 'status' | 'alert';
46 | 'aria-live': 'assertive' | 'off' | 'polite';
47 | };
48 |
49 | style?: CSSProperties;
50 | className?: string;
51 | iconTheme?: IconTheme;
52 |
53 | createdAt: number;
54 | visible: boolean;
55 | dismissed: boolean;
56 | height?: number;
57 | }
58 |
59 | export type ToastOptions = Partial<
60 | Pick<
61 | Toast,
62 | | 'id'
63 | | 'icon'
64 | | 'duration'
65 | | 'ariaProps'
66 | | 'className'
67 | | 'style'
68 | | 'position'
69 | | 'iconTheme'
70 | | 'removeDelay'
71 | >
72 | >;
73 |
74 | export type DefaultToastOptions = ToastOptions & {
75 | [key in ToastType]?: ToastOptions;
76 | };
77 |
78 | export interface ToasterProps {
79 | position?: ToastPosition;
80 | toastOptions?: DefaultToastOptions;
81 | reverseOrder?: boolean;
82 | gutter?: number;
83 | containerStyle?: React.CSSProperties;
84 | containerClassName?: string;
85 | children?: (toast: Toast) => React.ReactElement;
86 | }
87 |
88 | export interface ToastWrapperProps {
89 | id: string;
90 | className?: string;
91 | style?: React.CSSProperties;
92 | onHeightUpdate: (id: string, height: number) => void;
93 | children?: React.ReactNode;
94 | }
95 |
--------------------------------------------------------------------------------
/src/core/use-toaster.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useCallback } from 'react';
2 | import { dispatch, ActionType, useStore } from './store';
3 | import { toast } from './toast';
4 | import { DefaultToastOptions, Toast, ToastPosition } from './types';
5 |
6 | const updateHeight = (toastId: string, height: number) => {
7 | dispatch({
8 | type: ActionType.UPDATE_TOAST,
9 | toast: { id: toastId, height },
10 | });
11 | };
12 | const startPause = () => {
13 | dispatch({
14 | type: ActionType.START_PAUSE,
15 | time: Date.now(),
16 | });
17 | };
18 |
19 | const toastTimeouts = new Map>();
20 |
21 | export const REMOVE_DELAY = 1000;
22 |
23 | const addToRemoveQueue = (toastId: string, removeDelay = REMOVE_DELAY) => {
24 | if (toastTimeouts.has(toastId)) {
25 | return;
26 | }
27 |
28 | const timeout = setTimeout(() => {
29 | toastTimeouts.delete(toastId);
30 | dispatch({
31 | type: ActionType.REMOVE_TOAST,
32 | toastId: toastId,
33 | });
34 | }, removeDelay);
35 |
36 | toastTimeouts.set(toastId, timeout);
37 | };
38 |
39 | export const useToaster = (toastOptions?: DefaultToastOptions) => {
40 | const { toasts, pausedAt } = useStore(toastOptions);
41 |
42 | useEffect(() => {
43 | if (pausedAt) {
44 | return;
45 | }
46 |
47 | const now = Date.now();
48 | const timeouts = toasts.map((t) => {
49 | if (t.duration === Infinity) {
50 | return;
51 | }
52 |
53 | const durationLeft =
54 | (t.duration || 0) + t.pauseDuration - (now - t.createdAt);
55 |
56 | if (durationLeft < 0) {
57 | if (t.visible) {
58 | toast.dismiss(t.id);
59 | }
60 | return;
61 | }
62 | return setTimeout(() => toast.dismiss(t.id), durationLeft);
63 | });
64 |
65 | return () => {
66 | timeouts.forEach((timeout) => timeout && clearTimeout(timeout));
67 | };
68 | }, [toasts, pausedAt]);
69 |
70 | const endPause = useCallback(() => {
71 | if (pausedAt) {
72 | dispatch({ type: ActionType.END_PAUSE, time: Date.now() });
73 | }
74 | }, [pausedAt]);
75 |
76 | const calculateOffset = useCallback(
77 | (
78 | toast: Toast,
79 | opts?: {
80 | reverseOrder?: boolean;
81 | gutter?: number;
82 | defaultPosition?: ToastPosition;
83 | }
84 | ) => {
85 | const { reverseOrder = false, gutter = 8, defaultPosition } = opts || {};
86 |
87 | const relevantToasts = toasts.filter(
88 | (t) =>
89 | (t.position || defaultPosition) ===
90 | (toast.position || defaultPosition) && t.height
91 | );
92 | const toastIndex = relevantToasts.findIndex((t) => t.id === toast.id);
93 | const toastsBefore = relevantToasts.filter(
94 | (toast, i) => i < toastIndex && toast.visible
95 | ).length;
96 |
97 | const offset = relevantToasts
98 | .filter((t) => t.visible)
99 | .slice(...(reverseOrder ? [toastsBefore + 1] : [0, toastsBefore]))
100 | .reduce((acc, t) => acc + (t.height || 0) + gutter, 0);
101 |
102 | return offset;
103 | },
104 | [toasts]
105 | );
106 |
107 | useEffect(() => {
108 | // Add dismissed toasts to remove queue
109 | toasts.forEach((toast) => {
110 | if (toast.dismissed) {
111 | addToRemoveQueue(toast.id, toast.removeDelay);
112 | } else {
113 | // If toast becomes visible again, remove it from the queue
114 | const timeout = toastTimeouts.get(toast.id);
115 | if (timeout) {
116 | clearTimeout(timeout);
117 | toastTimeouts.delete(toast.id);
118 | }
119 | }
120 | });
121 | }, [toasts]);
122 |
123 | return {
124 | toasts,
125 | handlers: {
126 | updateHeight,
127 | startPause,
128 | endPause,
129 | calculateOffset,
130 | },
131 | };
132 | };
133 |
--------------------------------------------------------------------------------
/src/core/utils.ts:
--------------------------------------------------------------------------------
1 | export const genId = (() => {
2 | let count = 0;
3 | return () => {
4 | return (++count).toString();
5 | };
6 | })();
7 |
8 | export const prefersReducedMotion = (() => {
9 | // Cache result
10 | let shouldReduceMotion: boolean | undefined = undefined;
11 |
12 | return () => {
13 | if (shouldReduceMotion === undefined && typeof window !== 'undefined') {
14 | const mediaQuery = matchMedia('(prefers-reduced-motion: reduce)');
15 | shouldReduceMotion = !mediaQuery || mediaQuery.matches;
16 | }
17 | return shouldReduceMotion;
18 | };
19 | })();
20 |
--------------------------------------------------------------------------------
/src/headless/index.ts:
--------------------------------------------------------------------------------
1 | import { toast } from '../core/toast';
2 |
3 | export type {
4 | DefaultToastOptions,
5 | IconTheme,
6 | Renderable,
7 | Toast,
8 | ToasterProps,
9 | ToastOptions,
10 | ToastPosition,
11 | ToastType,
12 | ValueFunction,
13 | ValueOrFunction,
14 | } from '../core/types';
15 |
16 | export { resolveValue } from '../core/types';
17 | export { useToaster } from '../core/use-toaster';
18 | export { useStore as useToasterStore } from '../core/store';
19 |
20 | export { toast };
21 | export default toast;
22 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { toast } from './core/toast';
2 |
3 | export * from './headless';
4 |
5 | export { ToastBar } from './components/toast-bar';
6 | export { ToastIcon } from './components/toast-icon';
7 | export { Toaster } from './components/toaster';
8 | export { CheckmarkIcon } from './components/checkmark';
9 | export { ErrorIcon } from './components/error';
10 | export { LoaderIcon } from './components/loader';
11 |
12 | export { toast };
13 | export default toast;
14 |
--------------------------------------------------------------------------------
/test/setup.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
3 | // Mock matchMedia
4 | Object.defineProperty(window, 'matchMedia', {
5 | writable: true,
6 | value: jest.fn().mockImplementation((query) => ({
7 | matches: false,
8 | media: query,
9 | onchange: null,
10 | addListener: jest.fn(), // deprecated
11 | removeListener: jest.fn(), // deprecated
12 | addEventListener: jest.fn(),
13 | removeEventListener: jest.fn(),
14 | dispatchEvent: jest.fn(),
15 | })),
16 | });
17 |
18 | // Mock getBoundingClientRect
19 | Element.prototype.getBoundingClientRect = jest.fn(() => {
20 | return {
21 | width: 300,
22 | height: 120,
23 | x: 0,
24 | y: 0,
25 | top: 0,
26 | left: 0,
27 | bottom: 0,
28 | right: 0,
29 | toJSON: () => '{}',
30 | };
31 | });
32 |
--------------------------------------------------------------------------------
/test/toast.test.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import {
3 | render,
4 | screen,
5 | act,
6 | waitFor,
7 | fireEvent,
8 | } from '@testing-library/react';
9 |
10 | import toast, { resolveValue, Toaster, ToastIcon } from '../src';
11 | import { defaultTimeouts } from '../src/core/store';
12 | import { REMOVE_DELAY } from '../src/core/use-toaster';
13 |
14 | beforeEach(() => {
15 | // Tests should run in serial for improved isolation
16 | // To prevent collision with global state, reset all toasts for each test
17 | toast.remove();
18 | jest.useFakeTimers();
19 | });
20 |
21 | afterEach((done) => {
22 | act(() => {
23 | jest.runAllTimers();
24 | jest.useRealTimers();
25 | done();
26 | });
27 | });
28 |
29 | const waitTime = (time: number) => {
30 | act(() => {
31 | jest.advanceTimersByTime(time);
32 | });
33 | };
34 |
35 | const TOAST_DURATION = 1000;
36 |
37 | test('close notification', async () => {
38 | render(
39 | <>
40 | {
43 | toast.success((t) => (
44 |
45 | Example
46 | {
50 | toast.dismiss(t.id);
51 | }}
52 | title={'close'}
53 | >
54 | Close
55 |
56 |
57 | ));
58 | }}
59 | >
60 | Notify!
61 |
62 |
63 | >
64 | );
65 |
66 | fireEvent.click(screen.getByRole('button', { name: /Notify/i }));
67 |
68 | await waitFor(() => screen.getByText(/example/i));
69 |
70 | expect(screen.queryByText(/example/i)).toBeInTheDocument();
71 |
72 | fireEvent.click(await screen.findByRole('button', { name: /close/i }));
73 |
74 | waitTime(REMOVE_DELAY);
75 |
76 | expect(screen.queryByText(/example/i)).not.toBeInTheDocument();
77 | });
78 |
79 | test('promise toast', async () => {
80 | const WAIT_DELAY = 1000;
81 |
82 | render(
83 | <>
84 | {
87 | const sleep = new Promise((resolve) => {
88 | setTimeout(resolve, WAIT_DELAY);
89 | });
90 |
91 | toast.promise(sleep, {
92 | loading: 'Loading...',
93 | success: 'Success!',
94 | error: 'Error!',
95 | });
96 | }}
97 | >
98 | Notify!
99 |
100 |
101 | >
102 | );
103 |
104 | act(() => {
105 | fireEvent.click(screen.getByRole('button', { name: /Notify/i }));
106 | });
107 |
108 | await screen.findByText(/loading/i);
109 |
110 | expect(screen.queryByText(/loading/i)).toBeInTheDocument();
111 |
112 | waitTime(WAIT_DELAY);
113 |
114 | await waitFor(() => {
115 | expect(screen.queryByText(/success/i)).toBeInTheDocument();
116 | });
117 | });
118 |
119 | test('promise toast error', async () => {
120 | const WAIT_DELAY = 1000;
121 |
122 | render(
123 | <>
124 | {
127 | const sleep = new Promise((_, rej) => {
128 | setTimeout(rej, WAIT_DELAY);
129 | });
130 |
131 | toast.promise(sleep, {
132 | loading: 'Loading...',
133 | success: 'Success!',
134 | error: 'Error!',
135 | });
136 | }}
137 | >
138 | Notify!
139 |
140 |
141 | >
142 | );
143 |
144 | act(() => {
145 | fireEvent.click(screen.getByRole('button', { name: /Notify/i }));
146 | });
147 |
148 | await screen.findByText(/loading/i);
149 |
150 | expect(screen.queryByText(/loading/i)).toBeInTheDocument();
151 |
152 | waitTime(WAIT_DELAY);
153 |
154 | await waitFor(() => {
155 | expect(screen.queryByText(/error/i)).toBeInTheDocument();
156 | });
157 | });
158 |
159 | test('error toast with custom duration', async () => {
160 | render(
161 | <>
162 | {
165 | toast.error('An error happened', {
166 | duration: TOAST_DURATION,
167 | });
168 | }}
169 | >
170 | Notify!
171 |
172 |
173 | >
174 | );
175 |
176 | act(() => {
177 | fireEvent.click(screen.getByRole('button', { name: /Notify/i }));
178 | });
179 |
180 | await screen.findByText(/error/i);
181 |
182 | expect(screen.queryByText(/error/i)).toBeInTheDocument();
183 |
184 | waitTime(TOAST_DURATION);
185 |
186 | waitTime(REMOVE_DELAY);
187 |
188 | expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
189 | });
190 |
191 | test('different toasts types with dismiss', async () => {
192 | render(
193 | <>
194 |
195 | >
196 | );
197 |
198 | act(() => {
199 | toast.success('Success!');
200 | });
201 |
202 | act(() => {
203 | toast.error('Error!');
204 | });
205 |
206 | act(() => {
207 | toast('Emoji Icon', {
208 | icon: '✅',
209 | });
210 | });
211 |
212 | act(() => {
213 | toast('Custom Icon', {
214 | icon: ICON ,
215 | });
216 | });
217 | let loadingToastId: string;
218 | act(() => {
219 | loadingToastId = toast.loading('Loading!');
220 | });
221 |
222 | expect(screen.queryByText(/error/i)).toBeInTheDocument();
223 | expect(screen.queryByText(/success/i)).toBeInTheDocument();
224 | expect(screen.queryByText(/loading/i)).toBeInTheDocument();
225 | expect(screen.queryByText('✅')).toBeInTheDocument();
226 | expect(screen.queryByText('ICON')).toBeInTheDocument();
227 |
228 | waitTime(defaultTimeouts.success);
229 |
230 | waitTime(REMOVE_DELAY);
231 |
232 | expect(screen.queryByText(/success/i)).not.toBeInTheDocument();
233 | expect(screen.queryByText(/error/i)).toBeInTheDocument();
234 |
235 | waitTime(defaultTimeouts.error);
236 |
237 | waitTime(REMOVE_DELAY);
238 |
239 | expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
240 |
241 | act(() => {
242 | toast.dismiss(loadingToastId);
243 | });
244 |
245 | waitTime(REMOVE_DELAY);
246 |
247 | expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
248 | });
249 |
250 | test('custom toaster renderer', async () => {
251 | render(
252 | <>
253 |
254 | {(t) => (
255 |
256 |
257 | {resolveValue(t.message, t)}
258 |
259 | )}
260 |
261 | >
262 | );
263 |
264 | act(() => {
265 | toast.success('Success!');
266 | });
267 |
268 | expect(screen.queryByText(/success/i)).toHaveClass('custom-toast');
269 |
270 | act(() => {
271 | toast(Bold );
272 | });
273 |
274 | expect(screen.queryByText(/bold/i)).toBeInTheDocument();
275 |
276 | act(() => {
277 | toast.custom('Custom');
278 | });
279 |
280 | expect(screen.queryByText(/custom/i)).not.toHaveClass('custom-toast');
281 | });
282 |
283 | test('pause toast', async () => {
284 | render(
285 | <>
286 |
287 | {(t) => (
288 |
289 |
290 | {resolveValue(t.message, t)}
291 |
292 | )}
293 |
294 | >
295 | );
296 |
297 | act(() => {
298 | toast.success('Hover me!', {
299 | duration: 1000,
300 | });
301 | });
302 |
303 | waitTime(500);
304 |
305 | const toastElement = screen.getByText(/hover me/i);
306 |
307 | expect(toastElement).toBeInTheDocument();
308 |
309 | fireEvent.mouseEnter(toastElement);
310 |
311 | waitTime(10000);
312 |
313 | expect(toastElement).toBeInTheDocument();
314 |
315 | fireEvent.mouseLeave(toastElement);
316 |
317 | waitTime(1000);
318 | waitTime(1000);
319 |
320 | expect(toastElement).not.toBeInTheDocument();
321 | });
322 |
323 | test('"toast" can be called from useEffect hook', async () => {
324 | const MyComponent = () => {
325 | const [success, setSuccess] = useState(false);
326 | useEffect(() => {
327 | toast.success('Success toast');
328 | setSuccess(true);
329 | }, []);
330 |
331 | return success ? MyComponent finished
: null;
332 | };
333 |
334 | render(
335 | <>
336 |
337 |
338 | >
339 | );
340 |
341 | await screen.findByText(/MyComponent finished/i);
342 | expect(screen.queryByText(/Success toast/i)).toBeInTheDocument();
343 | });
344 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs
3 | "include": ["src", "types", "test"],
4 | "compilerOptions": {
5 | "module": "esnext",
6 | "types": ["jest", "@testing-library/jest-dom"],
7 | "lib": ["dom", "esnext"],
8 | // output .d.ts declaration files for consumers
9 | "declaration": true,
10 | // output .js.map sourcemap files for consumers
11 | "sourceMap": true,
12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index
13 | "rootDir": "./src",
14 | // stricter type-checking for stronger correctness. Recommended by TS
15 | "strict": true,
16 | // linter checks for common issues
17 | "noImplicitReturns": true,
18 | "noFallthroughCasesInSwitch": true,
19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative
20 | "noUnusedParameters": true,
21 | // use Node's module resolution algorithm, instead of the legacy TS one
22 | "moduleResolution": "node",
23 | // transpile JSX to React.createElement
24 | "jsx": "react",
25 | // interop between ESM and CJS modules. Recommended by TS
26 | "esModuleInterop": true,
27 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS
28 | "skipLibCheck": true,
29 | // error out if import and file system have a casing mismatch. Recommended by TS
30 | "forceConsistentCasingInFileNames": true,
31 | "noEmit": true,
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, Options } from 'tsup';
2 |
3 | const commonConfig: Options = {
4 | minify: true,
5 | dts: true,
6 | format: ['esm', 'cjs'],
7 | sourcemap: true,
8 | clean: true,
9 | };
10 | export default defineConfig([
11 | {
12 | ...commonConfig,
13 | esbuildOptions: (options) => {
14 | // Append "use client" to the top of the react entry point
15 | options.banner = {
16 | js: '"use client";',
17 | };
18 | },
19 | entry: ['src/index.ts'],
20 | outDir: 'dist',
21 | },
22 | {
23 | ...commonConfig,
24 | entry: ['src/headless/index.ts'],
25 | outDir: 'headless',
26 | },
27 | ]);
28 |
--------------------------------------------------------------------------------