diff --git a/.changeset/shaggy-beers-breathe.md b/.changeset/shaggy-beers-breathe.md
new file mode 100644
index 0000000000..2b643c4369
--- /dev/null
+++ b/.changeset/shaggy-beers-breathe.md
@@ -0,0 +1,7 @@
+---
+"@heroui/shared-icons": patch
+"@heroui/toast": patch
+"@heroui/theme": patch
+---
+
+Introducing the toast component(#2560)
diff --git a/apps/docs/config/routes.json b/apps/docs/config/routes.json
index f1ec7ce7b2..f2fcfe4321 100644
--- a/apps/docs/config/routes.json
+++ b/apps/docs/config/routes.json
@@ -436,7 +436,7 @@
"title": "Toast",
"keywords": "toast, notification, message",
"path": "/docs/components/toast.mdx",
- "comingSoon": true
+ "newPost": true
},
{
"key": "textarea",
diff --git a/apps/docs/content/components/toast/color.raw.jsx b/apps/docs/content/components/toast/color.raw.jsx
new file mode 100644
index 0000000000..32c6c8ddaa
--- /dev/null
+++ b/apps/docs/content/components/toast/color.raw.jsx
@@ -0,0 +1,24 @@
+import {addToast, Button} from "@heroui/react";
+
+export default function App() {
+ return (
+
+ {["default", "primary", "secondary", "success", "warning", "danger"].map((color) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/docs/content/components/toast/color.ts b/apps/docs/content/components/toast/color.ts
new file mode 100644
index 0000000000..6c2d8d6af9
--- /dev/null
+++ b/apps/docs/content/components/toast/color.ts
@@ -0,0 +1,9 @@
+import App from "./color.raw.jsx?raw";
+
+const react = {
+ "/App.jsx": App,
+};
+
+export default {
+ ...react,
+};
diff --git a/apps/docs/content/components/toast/custom-close-icon.raw.jsx b/apps/docs/content/components/toast/custom-close-icon.raw.jsx
new file mode 100644
index 0000000000..496a944e63
--- /dev/null
+++ b/apps/docs/content/components/toast/custom-close-icon.raw.jsx
@@ -0,0 +1,44 @@
+import {addToast, Button} from "@heroui/react";
+
+const CustomToastComponent = () => {
+ return (
+
+ );
+};
+
+export default function App() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/docs/content/components/toast/custom-close-icon.ts b/apps/docs/content/components/toast/custom-close-icon.ts
new file mode 100644
index 0000000000..ea4122cd0b
--- /dev/null
+++ b/apps/docs/content/components/toast/custom-close-icon.ts
@@ -0,0 +1,9 @@
+import App from "./custom-close-icon.raw.jsx?raw";
+
+const react = {
+ "/App.jsx": App,
+};
+
+export default {
+ ...react,
+};
diff --git a/apps/docs/content/components/toast/custom-styles.raw.jsx b/apps/docs/content/components/toast/custom-styles.raw.jsx
new file mode 100644
index 0000000000..10e5bff9fa
--- /dev/null
+++ b/apps/docs/content/components/toast/custom-styles.raw.jsx
@@ -0,0 +1,50 @@
+import {addToast, Button, cn} from "@heroui/react";
+
+const CustomToastComponent = () => {
+ return (
+
+ );
+};
+
+export default function App() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/docs/content/components/toast/custom-styles.ts b/apps/docs/content/components/toast/custom-styles.ts
new file mode 100644
index 0000000000..da3ea9093a
--- /dev/null
+++ b/apps/docs/content/components/toast/custom-styles.ts
@@ -0,0 +1,9 @@
+import App from "./custom-styles.raw.jsx?raw";
+
+const react = {
+ "/App.jsx": App,
+};
+
+export default {
+ ...react,
+};
diff --git a/apps/docs/content/components/toast/index.ts b/apps/docs/content/components/toast/index.ts
new file mode 100644
index 0000000000..c884ac0ac2
--- /dev/null
+++ b/apps/docs/content/components/toast/index.ts
@@ -0,0 +1,17 @@
+import color from "./color";
+import variants from "./variants";
+import customStyles from "./custom-styles";
+import radius from "./radius";
+import placement from "./placement";
+import usage from "./usage";
+import customCloseIcon from "./custom-close-icon";
+
+export const toastContent = {
+ color,
+ variants,
+ customStyles,
+ radius,
+ placement,
+ usage,
+ customCloseIcon,
+};
diff --git a/apps/docs/content/components/toast/placement.raw.jsx b/apps/docs/content/components/toast/placement.raw.jsx
new file mode 100644
index 0000000000..a7ab53233d
--- /dev/null
+++ b/apps/docs/content/components/toast/placement.raw.jsx
@@ -0,0 +1,36 @@
+import {addToast, Button, ToastProvider} from "@heroui/react";
+import React from "react";
+
+export default function App() {
+ const [placement, setPlacement] = React.useState("right-bottom");
+
+ return (
+ <>
+
+
+ {[
+ "left-top",
+ "right-top",
+ "center-top",
+ "left-bottom",
+ "right-bottom",
+ "center-bottom",
+ ].map((position) => (
+ {
+ setPlacement(position);
+ addToast({
+ title: "Toast title",
+ description: "Toast displayed successfully",
+ });
+ }}
+ >
+ {position}
+
+ ))}
+
+ >
+ );
+}
diff --git a/apps/docs/content/components/toast/placement.ts b/apps/docs/content/components/toast/placement.ts
new file mode 100644
index 0000000000..eee2244366
--- /dev/null
+++ b/apps/docs/content/components/toast/placement.ts
@@ -0,0 +1,9 @@
+import App from "./placement.raw.jsx?raw";
+
+const react = {
+ "/App.jsx": App,
+};
+
+export default {
+ ...react,
+};
diff --git a/apps/docs/content/components/toast/radius.raw.jsx b/apps/docs/content/components/toast/radius.raw.jsx
new file mode 100644
index 0000000000..1414cfb235
--- /dev/null
+++ b/apps/docs/content/components/toast/radius.raw.jsx
@@ -0,0 +1,24 @@
+import {addToast, Button} from "@heroui/react";
+
+export default function App() {
+ return (
+
+ {["none", "sm", "md", "lg", "full"].map((radius) => (
+
+ addToast({
+ title: "Toast title",
+ description: "Toast displayed successfully",
+ radius: radius,
+ })
+ }
+ >
+ {radius}
+
+ ))}
+
+ );
+}
diff --git a/apps/docs/content/components/toast/radius.ts b/apps/docs/content/components/toast/radius.ts
new file mode 100644
index 0000000000..7b78db1ce0
--- /dev/null
+++ b/apps/docs/content/components/toast/radius.ts
@@ -0,0 +1,9 @@
+import App from "./radius.raw.jsx?raw";
+
+const react = {
+ "/App.jsx": App,
+};
+
+export default {
+ ...react,
+};
diff --git a/apps/docs/content/components/toast/usage.raw.jsx b/apps/docs/content/components/toast/usage.raw.jsx
new file mode 100644
index 0000000000..6f8ee0e9da
--- /dev/null
+++ b/apps/docs/content/components/toast/usage.raw.jsx
@@ -0,0 +1,118 @@
+import {addToast, Button} from "@heroui/react";
+
+export default function App() {
+ return (
+
+
{
+ addToast({
+ title: "Toast Title",
+ });
+ }}
+ >
+ Default
+
+
+
{
+ addToast({
+ title: "Toast Title",
+ description: "Toast Description",
+ });
+ }}
+ >
+ With Description
+
+
+
{
+ addToast({
+ title: "Toast Title",
+ description: "Toast Description",
+ hideIcon: true,
+ });
+ }}
+ >
+ Hidden Icon
+
+
+
{
+ addToast({
+ title: "Toast Title",
+ description: "Toast Description",
+ promise: new Promise((resolve) => setTimeout(resolve, 3000)),
+ });
+ }}
+ >
+ Promise (3000ms)
+
+
+
{
+ addToast({
+ title: "Toast Title",
+ description: "Toast Description",
+ endContent: (
+
+ Upgrade
+
+ ),
+ variant: "faded",
+ });
+ }}
+ >
+ With endContent
+
+
+
{
+ addToast({
+ title: "Toast Title",
+ description: "Toast Description",
+ timeout: 3000,
+ shouldShowTimeoutProgess: true,
+ });
+ }}
+ >
+ Show Timeout Progress (3000ms)
+
+
+
+ addToast({
+ title: "Toast title",
+ description: "Toast displayed successfully",
+ icon: (
+
+ ),
+ })
+ }
+ >
+ Custom Icon
+
+
+ );
+}
diff --git a/apps/docs/content/components/toast/usage.ts b/apps/docs/content/components/toast/usage.ts
new file mode 100644
index 0000000000..1118304c37
--- /dev/null
+++ b/apps/docs/content/components/toast/usage.ts
@@ -0,0 +1,9 @@
+import App from "./usage.raw.jsx?raw";
+
+const react = {
+ "/App.jsx": App,
+};
+
+export default {
+ ...react,
+};
diff --git a/apps/docs/content/components/toast/variants.raw.jsx b/apps/docs/content/components/toast/variants.raw.jsx
new file mode 100644
index 0000000000..7d4d5b9d8d
--- /dev/null
+++ b/apps/docs/content/components/toast/variants.raw.jsx
@@ -0,0 +1,30 @@
+import {addToast, Button} from "@heroui/react";
+
+export default function App() {
+ return (
+
+ {[
+ ["solid", "solid"],
+ ["bordered", "bordered"],
+ ["flat", "faded"],
+ ].map((variant) => (
+
+ addToast({
+ title: "Toast title",
+ description: "Toast displayed successfully",
+ // @ts-ignore
+ variant: variant[0],
+ color: "secondary",
+ })
+ }
+ >
+ {variant[0]}
+
+ ))}
+
+ );
+}
diff --git a/apps/docs/content/components/toast/variants.ts b/apps/docs/content/components/toast/variants.ts
new file mode 100644
index 0000000000..ddea95fb2e
--- /dev/null
+++ b/apps/docs/content/components/toast/variants.ts
@@ -0,0 +1,9 @@
+import App from "./variants.raw.jsx?raw";
+
+const react = {
+ "/App.jsx": App,
+};
+
+export default {
+ ...react,
+};
diff --git a/apps/docs/content/docs/components/toast.mdx b/apps/docs/content/docs/components/toast.mdx
new file mode 100644
index 0000000000..d7a1c27fea
--- /dev/null
+++ b/apps/docs/content/docs/components/toast.mdx
@@ -0,0 +1,343 @@
+---
+title: "Toast"
+description: "Toast are temporary notifications that provide concise feedback about an action or event."
+---
+
+import {toastContent} from "@/content/components/toast";
+
+# Toast
+
+Toasts are temporary notifications that provide concise feedback about an action or event.
+
+
+
+---
+
+
+
+## Installation
+
+
+
+## Import
+
+
+
+## Requirement
+
+The `ToastProvider` must be added to the application before using the `addToast` function. This is required to initialize the context for managing toasts.
+
+```jsx {4,9}
+// app/providers.tsx
+
+import {HeroUIProvider} from '@heroui/react'
+import {ToastProvider} from "@heroui/toast";
+
+export default function Providers({children}) {
+ return (
+
+
+ {children}
+
+ )
+}
+```
+
+
+
+```jsx {3,9,11}
+// app/layout.tsx
+
+import {Providers} from "./providers";
+
+export default function RootLayout({children}) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+```
+
+
+### Usage
+
+
+
+### Colors
+
+Toast comes with 6 color variants to convey different meanings.
+
+
+
+### Variants
+
+
+
+### Radius
+
+
+
+### Toast Placement
+
+
+
+### Custom Styles
+
+You can customize the alert by passing custom Tailwind CSS classes to the component slots.
+
+
+
+### Custom Close Icon
+
+You can pass a custom close icon to the toast by passing the `closeIcon` prop and a custom class name to the `closeButton` slot.
+
+
+
+### Global Toast Props
+
+You can pass global toast props to the `ToastProvider` to apply to all toasts.
+
+```jsx
+
+```
+
+
+
+## Data Attributes
+
+Toast has the following attributes on the `base` element:
+
+- **data-has-title**: When the toast has a title
+- **data-has-description**: When the toast has a description
+- **data-animation**: Shows the current animation of toast ("entering", "queued", "exiting", "undefined")
+- **data-placement**: Where the toast is placed on the view-port.
+- **data-drag-value**: Value by which the toast is dragged from it's original position. (This remains "0" in case of disabledAnimation)
+
+
+
+### Slots
+
+Toast has the following slots:
+
+- `base`: The main toast container element
+- `title`: The title element
+- `description`: The description element
+- `icon`: The icon element
+- `loadingIcon`: The icon to be displayed until `promise` is resolved/rejected.
+- `content`: The wrapper for the title, description and icon content.
+- `motionDiv`: The motion.div for the FramerMotion.
+- `progressTrack`: The track of the progressBar.
+- `progressIndicator`: The indicator of the progressBar.
+- `closeButton`: The close button element
+- `closeIcon`: The icon which resides in the close button.
+
+
+## Accessibility
+
+- Toast has role of `alert`
+- All Toasts are present in ToastRegion.
+- Close button has aria-label="Close" by default
+- When no toast are present, ToastRegion is removed from the DOM
+
+
+
+## API
+
+### Toast Props
+
+>",
+ description: "Allows to set custom class names for the toast slots.",
+ default: "-"
+ }
+ ]}
+/>
+
+### ToastProvider Props
+
+
+
+
+### Toast Events
+
+ void",
+ description: "Handler called when the close button is clicked",
+ default: "-"
+ }
+ ]}
+/>
\ No newline at end of file
diff --git a/apps/docs/tailwind.config.js b/apps/docs/tailwind.config.js
index 9668d14742..8f3748517e 100644
--- a/apps/docs/tailwind.config.js
+++ b/apps/docs/tailwind.config.js
@@ -69,6 +69,9 @@ module.exports = {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
+ spacing: {
+ 'toast-gap': 'var(--toast-gap)',
+ },
typography: (theme) => ({
DEFAULT: {
css: {
diff --git a/packages/components/toast/README.md b/packages/components/toast/README.md
new file mode 100644
index 0000000000..a1e2fee852
--- /dev/null
+++ b/packages/components/toast/README.md
@@ -0,0 +1,22 @@
+# @heroui/toast
+
+Toast Component helps to provide feedback on user-actions.
+
+## Installation
+
+```sh
+yarn add @heroui/toast
+# or
+npm i @heroui/toast
+```
+
+## Contribution
+
+Yes please! See the
+[contributing guidelines](https://github.com/heroui-inc/heroui/blob/canary/CONTRIBUTING.md)
+for details.
+
+## License
+
+This project is licensed under the terms of the
+[MIT license](https://github.com/heroui-inc/heroui/blob/canary/LICENSE).
diff --git a/packages/components/toast/__tests__/toast.test.tsx b/packages/components/toast/__tests__/toast.test.tsx
new file mode 100644
index 0000000000..613a9db712
--- /dev/null
+++ b/packages/components/toast/__tests__/toast.test.tsx
@@ -0,0 +1,177 @@
+import * as React from "react";
+import {render, screen} from "@testing-library/react";
+import userEvent, {UserEvent} from "@testing-library/user-event";
+
+import {addToast, ToastProvider} from "../src";
+
+const title = "Testing Title";
+const description = "Testing Description";
+
+describe("Toast", () => {
+ let user: UserEvent;
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
+
+ it("should render correctly", () => {
+ const wrapper = render(
+ <>
+
+ {
+ addToast({
+ title: "toast title",
+ description: "toast description",
+ });
+ }}
+ >
+ Show Toast
+
+ >,
+ );
+
+ expect(() => wrapper.unmount()).not.toThrow();
+ });
+
+ it("ref should be forwarded", async () => {
+ const ref = React.createRef();
+
+ const wrapper = render(
+ <>
+
+ {
+ addToast({
+ title: "toast title",
+ description: "toast description",
+ ref: ref,
+ });
+ }}
+ >
+ Show Toast
+
+ >,
+ );
+
+ const button = wrapper.getByTestId("button");
+
+ await user.click(button);
+ expect(ref.current).not.toBeNull();
+ });
+
+ it("should display title and description when component is rendered", async () => {
+ const wrapper = render(
+ <>
+
+ {
+ addToast({
+ title: title,
+ description: description,
+ });
+ }}
+ >
+ Show Toast
+
+ >,
+ );
+
+ const button = wrapper.getByTestId("button");
+
+ await user.click(button);
+
+ const region = screen.getByRole("region");
+
+ expect(region).toContainHTML(title);
+ expect(region).toContainHTML(description);
+
+ await user.click(wrapper.getAllByRole("button")[0]);
+ });
+
+ it("should close", async () => {
+ const wrapper = render(
+ <>
+
+ {
+ addToast({
+ title: title,
+ description: description,
+ });
+ }}
+ >
+ Show Toast
+
+ >,
+ );
+
+ const button = wrapper.getByTestId("button");
+
+ await user.click(button);
+
+ const initialCloseButtons = wrapper.getAllByRole("button");
+ const initialButtonLength = initialCloseButtons.length;
+
+ await user.click(initialCloseButtons[0]);
+
+ const finalCloseButtons = wrapper.getAllByRole("button");
+ const finalButtonLength = finalCloseButtons.length;
+
+ expect(initialButtonLength).toEqual(finalButtonLength + 1);
+ });
+
+ it("should work with placement", async () => {
+ const wrapper = render(
+ <>
+
+ {
+ addToast({
+ title: title,
+ description: description,
+ });
+ }}
+ >
+ Show Toast
+
+ >,
+ );
+
+ const region = wrapper.getByRole("region");
+
+ expect(region).toHaveAttribute("data-placement", "left-bottom");
+ });
+
+ it("should have loading-icon when promise prop is passed.", async () => {
+ const wrapper = render(
+ <>
+
+ {
+ addToast({
+ title: title,
+ description: description,
+ promise: new Promise((resolve) => setTimeout(resolve, 3000)),
+ });
+ }}
+ >
+ Show Toast
+
+ >,
+ );
+
+ const button = wrapper.getByTestId("button");
+
+ await user.click(button);
+
+ const loadingIcon = wrapper.getByLabelText("loadingIcon");
+
+ expect(loadingIcon).toBeTruthy();
+ });
+});
diff --git a/packages/components/toast/package.json b/packages/components/toast/package.json
new file mode 100644
index 0000000000..02df8fa1f0
--- /dev/null
+++ b/packages/components/toast/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "@heroui/toast",
+ "version": "2.0.0",
+ "description": "Toast are temporary notifications that provide concise feedback about an action or event",
+ "keywords": [
+ "toast"
+ ],
+ "author": "Junior Garcia ",
+ "homepage": "https://heroui.com",
+ "license": "MIT",
+ "main": "src/index.ts",
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/heroui-inc/heroui.git",
+ "directory": "packages/components/toast"
+ },
+ "bugs": {
+ "url": "https://github.com/heroui-inc/heroui/issues"
+ },
+ "scripts": {
+ "build": "tsup src --dts",
+ "build:fast": "tsup src",
+ "dev": "pnpm build:fast --watch",
+ "clean": "rimraf dist .turbo",
+ "typecheck": "tsc --noEmit",
+ "prepack": "clean-package",
+ "postpack": "clean-package restore"
+ },
+ "peerDependencies": {
+ "@heroui/system": ">=2.4.8",
+ "@heroui/theme": ">=2.4.7",
+ "react": ">=18 || >=19.0.0-rc.0",
+ "react-dom": ">=18 || >=19.0.0-rc.0",
+ "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1"
+ },
+ "dependencies": {
+ "@heroui/react-utils": "workspace:*",
+ "@heroui/shared-utils": "workspace:*",
+ "@heroui/shared-icons": "workspace:*",
+ "@heroui/use-is-mobile": "workspace:*",
+ "@heroui/spinner": "workspace:*",
+ "@react-aria/toast": "3.0.0-beta.19",
+ "@react-aria/utils": "3.27.0",
+ "@react-aria/interactions": "3.23.0",
+ "@react-stately/toast": "3.0.0-beta.7",
+ "@react-stately/utils": "3.10.5"
+ },
+ "devDependencies": {
+ "@heroui/system": "workspace:*",
+ "@heroui/theme": "workspace:*",
+ "@heroui/button": "workspace:*",
+ "clean-package": "2.2.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "clean-package": "../../../clean-package.config.json"
+}
diff --git a/packages/components/toast/src/index.ts b/packages/components/toast/src/index.ts
new file mode 100644
index 0000000000..25f2660b91
--- /dev/null
+++ b/packages/components/toast/src/index.ts
@@ -0,0 +1,14 @@
+import Toast from "./toast";
+import {ToastProvider} from "./toast-provider";
+
+// export types
+export type {ToastProps} from "./toast";
+
+// export hooks
+export {useToast} from "./use-toast";
+export {addToast} from "./toast-provider";
+export {closeAll} from "./toast-provider";
+
+// export component
+export {Toast};
+export {ToastProvider};
diff --git a/packages/components/toast/src/toast-provider.tsx b/packages/components/toast/src/toast-provider.tsx
new file mode 100644
index 0000000000..c871da2e89
--- /dev/null
+++ b/packages/components/toast/src/toast-provider.tsx
@@ -0,0 +1,83 @@
+import {ToastOptions, ToastQueue, useToastQueue} from "@react-stately/toast";
+import {useProviderContext} from "@heroui/system";
+
+import {ToastRegion} from "./toast-region";
+import {ToastProps} from "./use-toast";
+
+let globalToastQueue: ToastQueue | null = null;
+
+interface ToastProviderProps {
+ maxVisibleToasts?: number;
+ placement?:
+ | "right-bottom"
+ | "left-bottom"
+ | "center-bottom"
+ | "right-top"
+ | "left-top"
+ | "center-top";
+ disableAnimation?: boolean;
+ toastProps?: ToastProps;
+ toastOffset?: number;
+}
+
+export const getToastQueue = () => {
+ if (!globalToastQueue) {
+ globalToastQueue = new ToastQueue({
+ maxVisibleToasts: Infinity,
+ hasExitAnimation: true,
+ });
+ }
+
+ return globalToastQueue;
+};
+
+export const ToastProvider = ({
+ placement = "right-bottom",
+ disableAnimation: disableAnimationProp = false,
+ maxVisibleToasts = 3,
+ toastOffset = 0,
+ toastProps = {},
+}: ToastProviderProps) => {
+ const toastQueue = useToastQueue(getToastQueue());
+ const globalContext = useProviderContext();
+ const disableAnimation = disableAnimationProp ?? globalContext?.disableAnimation ?? false;
+
+ if (toastQueue.visibleToasts.length == 0) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export const addToast = ({...props}: ToastProps & ToastOptions) => {
+ if (!globalToastQueue) {
+ return;
+ }
+
+ const options: Partial = {
+ priority: props?.priority,
+ };
+
+ globalToastQueue.add(props, options);
+};
+
+export const closeAll = () => {
+ if (!globalToastQueue) {
+ return;
+ }
+
+ const keys = globalToastQueue.visibleToasts.map((toast) => toast.key);
+
+ keys.map((key) => {
+ globalToastQueue?.close(key);
+ });
+};
diff --git a/packages/components/toast/src/toast-region.tsx b/packages/components/toast/src/toast-region.tsx
new file mode 100644
index 0000000000..05b36e6579
--- /dev/null
+++ b/packages/components/toast/src/toast-region.tsx
@@ -0,0 +1,106 @@
+import {useCallback, useEffect, useMemo, useRef, useState} from "react";
+import {useToastRegion, AriaToastRegionProps} from "@react-aria/toast";
+import {QueuedToast, ToastState} from "@react-stately/toast";
+import {useHover} from "@react-aria/interactions";
+import {mergeProps} from "@react-aria/utils";
+import {toastRegion, ToastRegionVariantProps} from "@heroui/theme";
+
+import Toast from "./toast";
+import {ToastProps} from "./use-toast";
+
+interface ToastRegionProps extends AriaToastRegionProps, ToastRegionVariantProps {
+ toastQueue: ToastState;
+ placement?:
+ | "right-bottom"
+ | "left-bottom"
+ | "center-bottom"
+ | "right-top"
+ | "left-top"
+ | "center-top";
+ maxVisibleToasts: number;
+ toastOffset?: number;
+ toastProps?: ToastProps;
+}
+
+export function ToastRegion({
+ toastQueue,
+ placement,
+ disableAnimation,
+ maxVisibleToasts,
+ toastOffset,
+ toastProps = {},
+ ...props
+}: ToastRegionProps) {
+ const ref = useRef(null);
+ const {regionProps} = useToastRegion(props, toastQueue, ref);
+ const {hoverProps, isHovered} = useHover({
+ isDisabled: false,
+ });
+
+ const [isTouched, setIsTouched] = useState(false);
+
+ const slots = useMemo(
+ () =>
+ toastRegion({
+ disableAnimation,
+ }),
+ [disableAnimation],
+ );
+
+ useEffect(() => {
+ function handleTouchOutside(event: TouchEvent) {
+ if (ref.current && !(ref.current as HTMLDivElement).contains(event.target as Node)) {
+ setIsTouched(false);
+ }
+ }
+ document.addEventListener("touchstart", handleTouchOutside);
+
+ return () => {
+ document.removeEventListener("touchstart", handleTouchOutside);
+ };
+ }, []);
+
+ const [heights, setHeights] = useState([]);
+ const total = toastQueue.visibleToasts?.length ?? 0;
+
+ const handleTouchStart = useCallback(() => {
+ setIsTouched(true);
+ }, []);
+
+ return (
+
+ {toastQueue.visibleToasts.map((toast: QueuedToast, index) => {
+ if (disableAnimation && total - index > maxVisibleToasts) {
+ return null;
+ }
+
+ if (total - index <= 4 || (isHovered && total - index <= maxVisibleToasts + 1)) {
+ return (
+
+ );
+ }
+
+ return null;
+ })}
+
+ );
+}
diff --git a/packages/components/toast/src/toast.tsx b/packages/components/toast/src/toast.tsx
new file mode 100644
index 0000000000..11d4e820cc
--- /dev/null
+++ b/packages/components/toast/src/toast.tsx
@@ -0,0 +1,130 @@
+import {forwardRef} from "@heroui/system";
+import {Button, ButtonProps} from "@heroui/button";
+import {
+ CloseIcon,
+ DangerIcon,
+ InfoFilledIcon,
+ SuccessIcon,
+ WarningIcon,
+} from "@heroui/shared-icons";
+import {AnimatePresence, m, LazyMotion} from "framer-motion";
+import {cloneElement, isValidElement} from "react";
+import {Spinner} from "@heroui/spinner";
+
+import {UseToastProps, useToast} from "./use-toast";
+
+const loadFeatures = () => import("framer-motion").then((res) => res.domMax);
+
+export interface ToastProps extends UseToastProps {}
+
+const iconMap = {
+ primary: InfoFilledIcon,
+ secondary: InfoFilledIcon,
+ success: SuccessIcon,
+ warning: WarningIcon,
+ danger: DangerIcon,
+} as const;
+
+const Toast = forwardRef<"div", ToastProps>((props, ref) => {
+ const {
+ Component,
+ icon,
+ loadingIcon,
+ domRef,
+ endContent,
+ color,
+ hideIcon,
+ closeIcon,
+ disableAnimation,
+ progressBarRef,
+ classNames,
+ slots,
+ isProgressBarVisible,
+ getToastProps,
+ getContentProps,
+ getTitleProps,
+ getDescriptionProps,
+ getCloseButtonProps,
+ getIconProps,
+ getMotionDivProps,
+ getCloseIconProps,
+ getLoadingIconProps,
+ isLoading,
+ } = useToast({
+ ...props,
+ ref,
+ });
+
+ const customIcon = icon && isValidElement(icon) ? cloneElement(icon, getIconProps()) : null;
+ const IconComponent = iconMap[color] || iconMap.primary;
+ const customLoadingIcon =
+ loadingIcon && isValidElement(loadingIcon)
+ ? cloneElement(loadingIcon, getLoadingIconProps())
+ : null;
+ const loadingIconComponent = isLoading
+ ? customLoadingIcon || (
+
+ )
+ : null;
+
+ const customCloseIcon =
+ closeIcon && isValidElement(closeIcon) ? cloneElement(closeIcon, {}) : null;
+
+ const toastContent = (
+
+
+ {hideIcon && !isLoading
+ ? null
+ : loadingIconComponent || customIcon || }
+
+
{props.toast.content.title}
+
{props.toast.content.description}
+
+
+ {isProgressBarVisible && (
+
+ )}
+
+ {customCloseIcon || }
+
+ {endContent}
+
+ );
+
+ return (
+ <>
+ {disableAnimation ? (
+ toastContent
+ ) : (
+
+
+
+
+ {toastContent}
+
+
+
+
+ )}
+ >
+ );
+});
+
+Toast.displayName = "HeroUI.Toast";
+
+export default Toast;
diff --git a/packages/components/toast/src/use-toast.ts b/packages/components/toast/src/use-toast.ts
new file mode 100644
index 0000000000..c826ac013b
--- /dev/null
+++ b/packages/components/toast/src/use-toast.ts
@@ -0,0 +1,604 @@
+import type {SlotsToClasses, ToastSlots, ToastVariantProps} from "@heroui/theme";
+
+import {HTMLHeroUIProps, PropGetter, mapPropsVariants, useProviderContext} from "@heroui/system";
+import {toast as toastTheme} from "@heroui/theme";
+import {ReactRef, useDOMRef} from "@heroui/react-utils";
+import {clsx, dataAttr, isEmpty, objectToDeps} from "@heroui/shared-utils";
+import {ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react";
+import {useToast as useToastAria, AriaToastProps} from "@react-aria/toast";
+import {chain, mergeProps} from "@react-aria/utils";
+import {QueuedToast, ToastState} from "@react-stately/toast";
+import {MotionProps} from "framer-motion";
+import {useHover} from "@react-aria/interactions";
+import {useIsMobile} from "@heroui/use-is-mobile";
+
+export interface ToastProps extends ToastVariantProps {
+ /**
+ * Ref to the DOM node.
+ */
+ ref?: ReactRef;
+ /**
+ * title of the toast
+ */
+ title?: ReactNode;
+ /**
+ * description of the toast
+ */
+ description?: string;
+ /**
+ * Promise based on which the notification will be styled.
+ */
+ promise?: Promise;
+ /**
+ * Classname or List of classes to change the classNames of the element.
+ * if `className` is passed, it will be added to the base slot.
+ *
+ * @example
+ * ```ts
+ * addToast({
+ * classNames={{
+ * base:"base-classes",
+ * content: "content-classes"
+ * description: "description-classes"
+ * title: "title-classes"
+ * loadingIcon: "loading-icon-classes",
+ * icon: "icon-classes",
+ * progressTrack: "progress-track-classes",
+ * progressIndicator: "progress-indicator-classes",
+ * closeButton: "closeButton-classes"
+ * closeIcon: "closeIcon-classes"
+ * }}
+ * })
+ * ```
+ */
+ classNames?: SlotsToClasses;
+ /**
+ * Content to be displayed in the end side of the toast
+ */
+ endContent?: ReactNode;
+ /**
+ * Icon to be displayed in the toast - overrides the default icon
+ */
+ icon?: ReactNode;
+ /**
+ * Icon to be displayed in the close button - overrides the default close icon
+ */
+ closeIcon?: ReactNode | ((props: any) => ReactNode);
+ /**
+ * Icon to be displayed in the loading toast - overrides the loading icon
+ */
+ loadingIcon?: ReactNode;
+ /**
+ * Whether the toast-icon should be hidden.
+ * @default false
+ */
+ hideIcon?: boolean;
+ /**
+ * Time to auto-close the toast.
+ */
+ timeout?: number;
+ /**
+ * hides the close button
+ */
+ hideCloseButton?: boolean;
+ /**
+ * function which is called when toast is closed.
+ */
+ onClose?: () => void;
+ /**
+ * props that will be passed to the m.div
+ */
+ motionProps?: MotionProps;
+ /**
+ * should apply styles to indicate timeout progress
+ */
+ shouldShowTimeoutProgess?: boolean;
+}
+
+interface Props extends Omit, "title">, ToastProps {
+ toast: QueuedToast;
+ index: number;
+ total: number;
+ state: ToastState;
+ heights: number[];
+ setHeights: (val: number[]) => void;
+ disableAnimation?: boolean;
+ isRegionExpanded: boolean;
+ placement?:
+ | "right-bottom"
+ | "left-bottom"
+ | "center-bottom"
+ | "right-top"
+ | "left-top"
+ | "center-top";
+ toastOffset?: number;
+}
+
+export type UseToastProps = Props &
+ ToastVariantProps &
+ Omit, "div">;
+
+const SWIPE_THRESHOLD_X = 100;
+const SWIPE_THRESHOLD_Y = 20;
+const INITIAL_POSITION = 50;
+
+export function useToast(originalProps: UseToastProps) {
+ const [props, variantProps] = mapPropsVariants(originalProps, toastTheme.variantKeys);
+ const {
+ ref,
+ as,
+ title,
+ description,
+ className,
+ classNames,
+ toast,
+ endContent,
+ closeIcon,
+ hideIcon = false,
+ placement: placementProp = "right-bottom",
+ isRegionExpanded,
+ hideCloseButton = false,
+ state,
+ total = 1,
+ index = 0,
+ heights,
+ promise: promiseProp,
+ setHeights,
+ toastOffset = 0,
+ motionProps,
+ timeout = 6000,
+ shouldShowTimeoutProgess = false,
+ icon,
+ onClose,
+ ...otherProps
+ } = props;
+
+ const {isHovered: isToastHovered, hoverProps} = useHover({
+ isDisabled: false,
+ });
+
+ const globalContext = useProviderContext();
+ const disableAnimation =
+ originalProps?.disableAnimation ?? globalContext?.disableAnimation ?? false;
+
+ const isMobile = useIsMobile();
+ let placement = placementProp;
+
+ if (isMobile) {
+ if (placementProp.includes("top")) {
+ placement = "center-top";
+ } else {
+ placement = "center-bottom";
+ }
+ }
+
+ const animationRef = useRef(null);
+ const startTime = useRef(null);
+ const progressRef = useRef(0);
+ const progressBarRef = useRef(null);
+ const pausedTime = useRef(0);
+ const timeElapsed = useRef(0);
+
+ useEffect(() => {
+ const updateProgress = (timestamp: number) => {
+ if (!timeout) {
+ return;
+ }
+
+ if (startTime.current === null) {
+ startTime.current = timestamp;
+ }
+
+ if (isToastHovered || isRegionExpanded || index != total - 1) {
+ pausedTime.current += timestamp - startTime.current;
+ startTime.current = null;
+ animationRef.current = requestAnimationFrame(updateProgress);
+
+ return;
+ }
+
+ const elapsed = timestamp - startTime.current + pausedTime.current;
+
+ timeElapsed.current = elapsed;
+ if (timeElapsed.current >= timeout) {
+ state.close(toast.key);
+ }
+
+ progressRef.current = Math.min((elapsed / timeout) * 100, 100);
+
+ if (progressBarRef.current) {
+ progressBarRef.current.style.width = `${
+ shouldShowTimeoutProgess ? progressRef.current : 0
+ }%`;
+ }
+
+ if (progressRef.current < 100) {
+ animationRef.current = requestAnimationFrame(updateProgress);
+ }
+ };
+
+ animationRef.current = requestAnimationFrame(updateProgress);
+
+ return () => {
+ if (animationRef.current !== null) {
+ cancelAnimationFrame(animationRef.current);
+ }
+ };
+ }, [timeout, shouldShowTimeoutProgess, state, isToastHovered, index, total, isRegionExpanded]);
+
+ const [isLoading, setIsLoading] = useState(!!promiseProp);
+
+ useEffect(() => {
+ if (!promiseProp) return;
+ promiseProp.finally(() => {
+ setIsLoading(false);
+ });
+ }, [promiseProp]);
+
+ const Component = as || "div";
+ const loadingIcon: ReactNode = icon;
+
+ const domRef = useDOMRef(ref);
+ const baseStyles = clsx(className, classNames?.base);
+ const {toastProps, contentProps, titleProps, descriptionProps, closeButtonProps} = useToastAria(
+ props,
+ state,
+ domRef,
+ );
+
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const [initialHeight, setInitialHeight] = useState(0);
+
+ // Following was inspired from sonner ❤️
+ useLayoutEffect(() => {
+ if (!domRef.current || !mounted) {
+ return;
+ }
+ const toastNode = domRef.current;
+ const originalHeight = toastNode.style.height;
+
+ toastNode.style.height = "auto";
+ const computedStyle = getComputedStyle(toastNode);
+ const marginTop = parseFloat(computedStyle.marginTop);
+ const marginBottom = parseFloat(computedStyle.marginBottom);
+ const newHeight = toastNode.getBoundingClientRect().height + marginTop + marginBottom;
+
+ toastNode.style.height = originalHeight;
+
+ setInitialHeight((prevHeight) => (prevHeight !== newHeight ? newHeight : prevHeight));
+ const updatedHeights = [...heights];
+
+ if (updatedHeights.length > index) {
+ updatedHeights[index] = newHeight;
+ } else {
+ updatedHeights.push(newHeight);
+ }
+ setHeights(updatedHeights);
+ }, [mounted, total, setHeights, index]);
+
+ let liftHeight = 4;
+
+ for (let idx = index + 1; idx < total; idx++) {
+ liftHeight += heights[idx];
+ }
+
+ const frontHeight = heights[heights.length - 1];
+
+ const slots = useMemo(
+ () =>
+ toastTheme({
+ ...variantProps,
+ disableAnimation,
+ }),
+ [objectToDeps(variantProps)],
+ );
+
+ const multiplier = placement.includes("top") ? 1 : -1;
+ const toastVariants = {
+ hidden: {opacity: 0, y: -INITIAL_POSITION * multiplier},
+ visible: {opacity: 1, y: 0},
+ exit: {opacity: 0, y: -INITIAL_POSITION * multiplier},
+ };
+
+ const [drag, setDrag] = useState(false);
+ const [dragValue, setDragValue] = useState(0);
+
+ const shouldCloseToast = (offsetX: number, offsetY: number) => {
+ const isRight = placement.includes("right");
+ const isLeft = placement.includes("left");
+ const isCenterTop = placement === "center-top";
+ const isCenterBottom = placement === "center-bottom";
+
+ if (
+ (isRight && offsetX >= SWIPE_THRESHOLD_X) ||
+ (isLeft && offsetX <= -SWIPE_THRESHOLD_X) ||
+ (isCenterTop && offsetY <= -SWIPE_THRESHOLD_Y) ||
+ (isCenterBottom && offsetY >= SWIPE_THRESHOLD_Y)
+ ) {
+ return true;
+ }
+ };
+
+ const getDragElasticConstraints = (placement: string) => {
+ const elasticConstraint = {top: 0, bottom: 0, right: 0, left: 0};
+
+ if (placement === "center-bottom") {
+ elasticConstraint.bottom = 1;
+
+ return elasticConstraint;
+ }
+ if (placement === "center-top") {
+ elasticConstraint.top = 1;
+
+ return elasticConstraint;
+ }
+ if (placement.includes("right")) {
+ elasticConstraint.right = 1;
+
+ return elasticConstraint;
+ }
+ if (placement.includes("left")) {
+ elasticConstraint.left = 1;
+
+ return elasticConstraint;
+ }
+
+ elasticConstraint.left = 1;
+ elasticConstraint.right = 1;
+
+ return elasticConstraint;
+ };
+
+ let opacityValue: undefined | number = undefined;
+
+ if ((drag && placement === "center-bottom") || placement === "center-top") {
+ opacityValue = Math.max(0, 1 - dragValue / (SWIPE_THRESHOLD_Y + 5));
+ } else if (drag) {
+ opacityValue = Math.max(0, 1 - dragValue / (SWIPE_THRESHOLD_X + 20));
+ }
+
+ const getToastProps: PropGetter = useCallback(
+ (props = {}) => ({
+ ref: domRef,
+ className: slots.base({class: clsx(baseStyles, classNames?.base)}),
+ "data-has-title": dataAttr(!isEmpty(title)),
+ "data-has-description": dataAttr(!isEmpty(description)),
+ "data-placement": placement,
+ "data-drag-value": dragValue,
+ "data-toast": true,
+ "data-animation": toast.animation,
+ "aria-label": "toast",
+ onTransitionEnd: () => {
+ if (toast.animation === "exiting") {
+ state.remove(toast.key);
+ }
+ },
+ style: {
+ opacity: opacityValue,
+ },
+ ...mergeProps(props, otherProps, toastProps, hoverProps),
+ }),
+ [slots, classNames, toastProps, hoverProps, toast, toast.animation, toast.key, opacityValue],
+ );
+
+ const getIconProps: PropGetter = useCallback(
+ (props = {}) => ({
+ "aria-label": "descriptionIcon",
+ className: slots.icon({class: classNames?.icon}),
+ ...props,
+ }),
+ [],
+ );
+
+ const getLoadingIconProps: PropGetter = useCallback(
+ (props = {}) => ({
+ className: slots.loadingIcon({class: classNames?.loadingIcon}),
+ ...props,
+ }),
+ [],
+ );
+
+ const getContentProps: PropGetter = useCallback(
+ (props = {}) => ({
+ className: slots.content({class: classNames?.content}),
+ ...mergeProps(props, otherProps, contentProps),
+ }),
+ [contentProps],
+ );
+
+ const getTitleProps: PropGetter = useCallback(
+ (props = {}) => ({
+ className: slots.title({class: classNames?.title}),
+ ...mergeProps(props, otherProps, titleProps),
+ }),
+ [titleProps],
+ );
+
+ const getDescriptionProps: PropGetter = useCallback(
+ (props = {}) => ({
+ className: slots.description({class: classNames?.description}),
+ ...mergeProps(props, otherProps, descriptionProps),
+ }),
+ [descriptionProps],
+ );
+
+ const getCloseButtonProps: PropGetter = useCallback(
+ (props = {}) => ({
+ className: slots.closeButton({class: classNames?.closeButton}),
+ "aria-label": "closeButton",
+ "data-hidden": dataAttr(hideCloseButton),
+ ...mergeProps(props, closeButtonProps, {
+ onPress: chain(() => {
+ setTimeout(() => document.body.focus(), 0);
+ }, onClose),
+ }),
+ }),
+ [closeButtonProps, onClose],
+ );
+
+ const getCloseIconProps: PropGetter = useCallback(
+ (props = {}) => ({
+ className: slots.closeIcon({class: classNames?.closeIcon}),
+ "aria-label": "closeIcon",
+ ...props,
+ }),
+ [],
+ );
+
+ const getMotionDivProps = useCallback(
+ (
+ props = {},
+ ): MotionProps & {
+ "data-drag": string | boolean;
+ "data-placement": string;
+ "data-drag-value": number;
+ className: string;
+ } => {
+ const isCloseToEnd = total - index - 1 <= 2;
+ const dragDirection = placement === "center-bottom" || placement === "center-top" ? "y" : "x";
+ const dragConstraints = {left: 0, right: 0, top: 0, bottom: 0};
+ const dragElastic = getDragElasticConstraints(placement);
+
+ const animateProps = (() => {
+ if (placement.includes("top")) {
+ return {
+ top:
+ isRegionExpanded || drag
+ ? liftHeight + toastOffset
+ : (total - 1 - index) * 8 + toastOffset,
+ bottom: "auto",
+ };
+ } else if (placement.includes("bottom")) {
+ return {
+ bottom:
+ isRegionExpanded || drag
+ ? liftHeight + toastOffset
+ : (total - 1 - index) * 8 + toastOffset,
+ top: "auto",
+ };
+ }
+
+ return {};
+ })();
+
+ return {
+ animate: {
+ opacity: isCloseToEnd ? 1 : 0,
+ pointerEvents: isCloseToEnd ? "all" : "none",
+ scaleX: isRegionExpanded || drag ? 1 : 1 - (total - 1 - index) * 0.1,
+ height: isRegionExpanded || drag ? initialHeight : frontHeight,
+ y: 0,
+ ...animateProps,
+ },
+ drag: dragDirection,
+ dragConstraints,
+ exit: {opacity: 0},
+ initial: {opacity: 0, scale: 1, y: -40 * multiplier},
+ transition: {duration: 0.3, ease: "easeOut"},
+ variants: toastVariants,
+ dragElastic,
+ onDragEnd: (_, info) => {
+ const {x: offsetX, y: offsetY} = info.offset;
+
+ setDrag(false);
+
+ if (shouldCloseToast(offsetX, offsetY)) {
+ state.close(toast.key);
+ state.remove(toast.key);
+
+ return;
+ }
+ setDragValue(0);
+ },
+ onDrag: (_, info) => {
+ let updatedDragValue = 0;
+
+ if (placement === "center-top") {
+ updatedDragValue = -info.offset.y;
+ } else if (placement === "center-bottom") {
+ updatedDragValue = info.offset.y;
+ } else if (placement.includes("right")) {
+ updatedDragValue = info.offset.x;
+ } else if (placement.includes("left")) {
+ updatedDragValue = -info.offset.x;
+ }
+
+ if (updatedDragValue >= 0) {
+ setDragValue(updatedDragValue);
+ }
+ },
+ onDragStart: () => {
+ setDrag(true);
+ },
+ "data-drag": dataAttr(drag),
+ "data-placement": placement,
+ "data-drag-value": dragValue,
+ className: slots.motionDiv({class: classNames?.motionDiv}),
+ ...props,
+ ...motionProps,
+ };
+ },
+ [
+ closeButtonProps,
+ total,
+ index,
+ placement,
+ isRegionExpanded,
+ liftHeight,
+ multiplier,
+ initialHeight,
+ frontHeight,
+ toastVariants,
+ classNames,
+ drag,
+ dataAttr,
+ setDrag,
+ shouldCloseToast,
+ slots,
+ ],
+ );
+
+ return {
+ Component,
+ title,
+ description,
+ icon,
+ loadingIcon,
+ domRef,
+ closeIcon,
+ classNames,
+ color: variantProps["color"],
+ hideIcon,
+ placement,
+ state,
+ toast,
+ disableAnimation,
+ isProgressBarVisible: !!timeout,
+ total,
+ index,
+ getToastProps,
+ getTitleProps,
+ getContentProps,
+ getDescriptionProps,
+ getCloseButtonProps,
+ getIconProps,
+ getMotionDivProps,
+ getCloseIconProps,
+ getLoadingIconProps,
+ progressBarRef,
+ endContent,
+ slots,
+ isRegionExpanded,
+ liftHeight,
+ frontHeight,
+ initialHeight,
+ isLoading,
+ };
+}
+
+export type UseToastReturn = ReturnType;
diff --git a/packages/components/toast/stories/toast.stories.tsx b/packages/components/toast/stories/toast.stories.tsx
new file mode 100644
index 0000000000..8358f2b58e
--- /dev/null
+++ b/packages/components/toast/stories/toast.stories.tsx
@@ -0,0 +1,410 @@
+import React, {useEffect} from "react";
+import {Meta} from "@storybook/react";
+import {cn, toast} from "@heroui/theme";
+import {Button} from "@heroui/button";
+
+import {Toast, ToastProps, ToastProvider, addToast, closeAll} from "../src";
+
+export default {
+ title: "Components/Toast",
+ component: Toast,
+ argTypes: {
+ variant: {
+ control: {type: "select"},
+ options: ["flat", "bordered", "solid"],
+ },
+ color: {
+ control: {type: "select"},
+ options: ["default", "foreground", "primary", "secondary", "success", "warning", "danger"],
+ },
+ radius: {
+ control: {type: "select"},
+ options: ["none", "sm", "md", "lg", "full"],
+ },
+ size: {
+ control: {type: "select"},
+ options: ["sm", "md", "lg"],
+ },
+ hideIcon: {
+ control: {
+ type: "boolean",
+ },
+ },
+ shadow: {
+ control: {type: "select"},
+ options: ["sm", "md", "lg"],
+ },
+ placement: {
+ control: {type: "select"},
+ options: [
+ "right-bottom",
+ "left-bottom",
+ "center-bottom",
+ "right-top",
+ "left-top",
+ "center-top",
+ ],
+ },
+ hideCloseButton: {
+ control: {
+ type: "boolean",
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ (Story) => {
+ useEffect(() => {
+ return () => {
+ closeAll();
+ };
+ }, []);
+
+ return ;
+ },
+ ],
+} as Meta;
+
+const defaultProps = {
+ ...toast.defaultVariants,
+};
+
+const Template = (args: ToastProps) => {
+ return (
+ <>
+
+
+ {
+ addToast({
+ title: "Toast Title",
+ ...args,
+ });
+ }}
+ >
+ Show toast
+
+
+ >
+ );
+};
+
+const ShowTimeoutProgressTemplate = (args: ToastProps) => {
+ return (
+ <>
+
+ {
+ addToast({
+ title: "Toast Title",
+ description: "Toast Description",
+ timeout: 3000,
+ shouldShowTimeoutProgess: true,
+ ...args,
+ });
+ }}
+ >
+ Toast
+
+ >
+ );
+};
+
+const WithEndContentTemplate = (args) => {
+ return (
+ <>
+
+ {
+ addToast({
+ title: "Toast Title",
+ description: "Toast Description",
+ endContent: (
+
+ Upgrade
+
+ ),
+ color: "warning",
+ variant: "faded",
+ ...args,
+ });
+ }}
+ >
+ Toast
+
+ >
+ );
+};
+
+const PlacementTemplate = (args: ToastProps) => {
+ return (
+ <>
+
+
+ {
+ addToast({
+ title: "Toast Title",
+ description: "Toast Displayed Successfully",
+ ...args,
+ });
+ }}
+ >
+ Show toast
+
+
+ >
+ );
+};
+
+const DisableAnimationTemplate = (args: ToastProps) => {
+ return (
+ <>
+
+
+ {
+ addToast({
+ title: "Toast Title",
+ description: "Toast Displayed Successfully",
+ ...args,
+ });
+ }}
+ >
+ Show toast
+
+
+ >
+ );
+};
+
+const PromiseToastTemplate = (args: ToastProps) => {
+ return (
+ <>
+
+
+ {
+ addToast({
+ title: "Toast Title",
+ description: "Toast Displayed Successfully",
+ promise: new Promise((resolve) => setTimeout(resolve, 4000)),
+ ...args,
+ });
+ }}
+ >
+ Show toast
+
+
+ >
+ );
+};
+
+const CustomToastComponent = (args) => {
+ const color = args.color;
+ const colorMap = {
+ primary: "before:bg-primary border-primary-200 dark:border-primary-100",
+ secondary: "before:bg-secondary border-secondary-200 dark:border-secondary-100",
+ success: "before:bg-success border-success-200 dark:border-success-100",
+ warning: "before:bg-warning border-warning-200 dark:border-warning-100",
+ danger: "before:bg-danger border-danger-200 dark:border-danger-100",
+ };
+
+ return (
+ <>
+ {
+ addToast({
+ title: "Sucessful!",
+ description: "Document uploaded to cloud successful.",
+ classNames: {
+ base: cn([
+ "bg-default-50 dark:bg-background shadow-sm",
+ "border-1",
+ "relative before:content-[''] before:absolute before:z-10",
+ "before:left-0 before:top-[-1px] before:bottom-[-1px] before:w-1",
+ "rounded-l-none border-l-0",
+ "rounded-md",
+ "flex flex-col items-start",
+ colorMap[color],
+ ]),
+ icon: "w-6 h-6 fill-current",
+ },
+ endContent: (
+
+
+ View Document
+
+
+ Maybe Later
+
+
+ ),
+ color: color,
+ });
+ }}
+ >
+ Toast
+
+ >
+ );
+};
+
+const CustomToastTemplate = (args) => {
+ const colors = ["primary", "secondary", "warning", "danger", "success"];
+
+ return (
+ <>
+
+
+ {colors.map((color, idx) => (
+
+ ))}
+
+ >
+ );
+};
+
+const CustomCloseButtonTemplate = (args) => {
+ return (
+ <>
+
+
+ addToast({
+ title: "Toast Title",
+ description: "Toast Description",
+ closeIcon: (
+
+ ),
+ })
+ }
+ >
+ Toast
+
+ >
+ );
+};
+
+export const Default = {
+ render: Template,
+ args: {
+ ...defaultProps,
+ },
+};
+
+export const WithDescription = {
+ render: Template,
+ args: {
+ description: "Toast displayed successfully.",
+ ...defaultProps,
+ },
+};
+
+export const WithCustomIcon = {
+ render: Template,
+ args: {
+ ...defaultProps,
+ title: "Custom Icon",
+ icon: (
+
+ ),
+ },
+};
+
+export const iconHidden = {
+ render: Template,
+ args: {
+ ...defaultProps,
+ hideIcon: true,
+ },
+};
+
+export const DisableAnimation = {
+ render: DisableAnimationTemplate,
+ args: {
+ ...defaultProps,
+ },
+};
+
+export const PromiseToast = {
+ render: PromiseToastTemplate,
+ args: {
+ ...defaultProps,
+ },
+};
+
+export const ShowTimeoutProgress = {
+ render: ShowTimeoutProgressTemplate,
+ args: {
+ ...defaultProps,
+ },
+};
+
+export const Placement = {
+ render: PlacementTemplate,
+ args: {
+ ...defaultProps,
+ },
+};
+
+export const WithEndContent = {
+ render: WithEndContentTemplate,
+ args: {
+ ...defaultProps,
+ },
+};
+
+export const CustomStyles = {
+ render: CustomToastTemplate,
+ args: {
+ ...defaultProps,
+ },
+};
+
+export const CustomCloseButton = {
+ render: CustomCloseButtonTemplate,
+ args: {
+ ...defaultProps,
+ },
+};
diff --git a/packages/components/toast/tsconfig.json b/packages/components/toast/tsconfig.json
new file mode 100644
index 0000000000..5d012f6e61
--- /dev/null
+++ b/packages/components/toast/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "tailwind-variants": ["../../../node_modules/tailwind-variants"]
+ },
+ },
+ "include": ["src", "index.ts"]
+}
diff --git a/packages/components/toast/tsup.config.ts b/packages/components/toast/tsup.config.ts
new file mode 100644
index 0000000000..3e2bcff6cc
--- /dev/null
+++ b/packages/components/toast/tsup.config.ts
@@ -0,0 +1,8 @@
+import {defineConfig} from "tsup";
+
+export default defineConfig({
+ clean: true,
+ target: "es2019",
+ format: ["cjs", "esm"],
+ banner: {js: '"use client";'},
+});
diff --git a/packages/core/react/package.json b/packages/core/react/package.json
index 2b8b3d613c..6f5202b0ee 100644
--- a/packages/core/react/package.json
+++ b/packages/core/react/package.json
@@ -89,6 +89,7 @@
"@heroui/drawer": "workspace:*",
"@heroui/form": "workspace:*",
"@heroui/alert": "workspace:*",
+ "@heroui/toast": "workspace:*",
"@react-aria/visually-hidden": "3.8.19"
},
"peerDependencies": {
diff --git a/packages/core/react/src/index.ts b/packages/core/react/src/index.ts
index 2b29b0ac5e..50aec9e35c 100644
--- a/packages/core/react/src/index.ts
+++ b/packages/core/react/src/index.ts
@@ -47,6 +47,7 @@ export * from "@heroui/form";
export * from "@heroui/alert";
export * from "@heroui/drawer";
export * from "@heroui/input-otp";
+export * from "@heroui/toast";
/**
* React Aria - Exports
diff --git a/packages/core/theme/src/components/index.ts b/packages/core/theme/src/components/index.ts
index dee45f4401..68832da61e 100644
--- a/packages/core/theme/src/components/index.ts
+++ b/packages/core/theme/src/components/index.ts
@@ -41,3 +41,4 @@ export * from "./date-picker";
export * from "./alert";
export * from "./drawer";
export * from "./form";
+export * from "./toast";
diff --git a/packages/core/theme/src/components/toast.ts b/packages/core/theme/src/components/toast.ts
new file mode 100644
index 0000000000..5160e132c9
--- /dev/null
+++ b/packages/core/theme/src/components/toast.ts
@@ -0,0 +1,388 @@
+import type {VariantProps} from "tailwind-variants";
+
+import {tv} from "../utils/tv";
+
+const toastRegion = tv({
+ slots: {
+ base: "relative z-50",
+ },
+ variants: {
+ disableAnimation: {
+ false: {
+ base: "",
+ },
+ true: {
+ base: [
+ "data-[placement=right-bottom]:bottom-0 data-[placement=right-bottom]:right-0 w-full px-2 sm:w-auto sm:px-0 data-[placement=right-bottom]:fixed data-[placement=right-bottom]:flex data-[placement=right-bottom]:flex-col",
+ "data-[placement=left-bottom]:bottom-0 data-[placement=left-bottom]:left-0 w-full px-2 sm:w-auto sm:px-0 data-[placement=left-bottom]:fixed data-[placement=left-bottom]:flex data-[placement=left-bottom]:flex-col",
+ "data-[placement=center-bottom]:bottom-0 data-[placement=center-bottom]:fixed w-full px-2 sm:w-auto sm:px-0 data-[placement=center-bottom]:flex data-[placement=center-bottom]:flex-col data-[placement=center-bottom]:left-1/2 data-[placement=center-bottom]:-translate-x-1/2",
+ "data-[placement=right-top]:top-0 data-[placement=right-top]:right-0 w-full px-2 sm:w-auto sm:px-0 data-[placement=right-top]:fixed data-[placement=right-top]:flex data-[placement=right-top]:flex-col",
+ "data-[placement=left-top]:top-0 data-[placement=left-top]:left-0 w-full px-2 sm:w-auto sm:px-0 data-[placement=left-top]:fixed data-[placement=left-top]:flex data-[placement=left-top]:flex-col",
+ "data-[placement=center-top]:top-0 data-[placement=center-top]:fixed w-full px-2 sm:w-auto sm:px-0 data-[placement=center-top]:flex data-[placement=center-top]:flex-col data-[placement=center-top]:left-1/2 data-[placement=center-top]:-translate-x-1/2",
+ ],
+ },
+ },
+ },
+ defaultVariants: {
+ disableAnimation: false,
+ },
+});
+
+const toast = tv({
+ slots: {
+ base: [
+ "flex gap-x-4 items-center",
+ "group",
+ "cursor-pointer",
+ "relative",
+ "z-50",
+ "box-border",
+ "outline-none",
+ "p-3 sm:mx-1",
+ "my-1",
+ "w-full sm:w-[270px] md:w-[300px]",
+ "min-h-4",
+ ],
+ title: ["font-medium", "text-small", "me-4", "text-foreground"],
+ description: ["text-small", "me-4", "text-default-600"],
+ icon: ["w-6 h-6 fill-current"],
+ loadingIcon: ["w-6 h-6 fill-current"],
+ content: ["flex flex-grow flex-row gap-x-4 items-center relative"],
+ progressTrack: ["absolute top-0 inset-0 bg-transparent overflow-hidden"],
+ progressIndicator: ["h-full bg-default-400 opacity-20"],
+ motionDiv: [
+ "fixed",
+ "px-4 sm:px-0",
+ "data-[placement=right-bottom]:bottom-0 data-[placement=right-bottom]:right-0 data-[placement=right-bottom]:mx-auto w-full sm:data-[placement=right-bottom]:w-max mb-1 sm:data-[placement=right-bottom]:mr-2",
+ "data-[placement=left-bottom]:bottom-0 data-[placement=left-bottom]:left-0 data-[placement=left-bottom]:mx-auto w-full sm:data-[placement=left-bottom]:w-max mb-1 sm:data-[placement=left-bottom]:ml-2",
+ "data-[placement=center-bottom]:bottom-0 data-[placement=center-bottom]:left-0 data-[placement=center-bottom]:right-0 w-full sm:data-[placement=center-bottom]:w-max sm:data-[placement=center-bottom]:mx-auto",
+ "data-[placement=right-top]:top-0 data-[placement=right-top]:right-0 data-[placement=right-top]:mx-auto w-full sm:data-[placement=right-top]:w-max sm:data-[placement=right-top]:mr-2",
+ "data-[placement=left-top]:top-0 data-[placement=left-top]:left-0 data-[placement=left-top]:mx-auto w-full sm:data-[placement=left-top]:w-max sm:data-[placement=left-top]:ml-2",
+ "data-[placement=center-top]:top-0 data-[placement=center-top]:left-0 data-[placement=center-top]:right-0 w-full sm:data-[placement=center-top]:w-max sm:data-[placement=center-top]:mx-auto",
+ ],
+ closeButton: [
+ "opacity-0 pointer-events-none group-hover:pointer-events-auto p-0 group-hover:opacity-100 w-6 h-6 min-w-4 absolute -right-2 -top-2 items-center justify-center bg-transparent text-default-400 hover:text-default-600 border border-3 border-transparent",
+ "data-[hidden=true]:hidden",
+ ],
+ closeIcon: ["rounded-full w-full h-full p-0.5 border border-default-400 bg-default-100"],
+ },
+ variants: {
+ size: {
+ sm: {
+ icon: "w-5 h-5",
+ loadingIcon: "w-5 h-5",
+ title: "text-sm font-medium",
+ description: "text-xs",
+ },
+ md: {
+ title: "text-sm font-semibold",
+ description: "text-xs",
+ },
+ lg: {
+ title: "text-md font-semibold",
+ description: "text-sm",
+ },
+ },
+ variant: {
+ flat: "bg-content1 border border-default-100",
+ solid: "bg-default text-default-foreground",
+ bordered: "bg-background border border-default-200",
+ },
+ color: {
+ default: "",
+ foreground: {
+ progressIndicator: "h-full opacity-20 bg-foreground-400",
+ },
+ primary: {
+ progressIndicator: "h-full opacity-20 bg-primary-400",
+ },
+ secondary: {
+ progressIndicator: "h-full opacity-20 bg-secondary-400",
+ },
+ success: {
+ progressIndicator: "h-full opacity-20 bg-success-400",
+ },
+ warning: {
+ progressIndicator: "h-full opacity-20 bg-warning-400",
+ },
+ danger: {
+ progressIndicator: "h-full opacity-20 bg-danger-400",
+ },
+ },
+ radius: {
+ none: {
+ base: "rounded-none",
+ progressTrack: "rounded-none",
+ },
+ sm: {
+ base: "rounded-small",
+ progressTrack: "rounded-small",
+ },
+ md: {
+ base: "rounded-medium",
+ progressTrack: "rounded-medium",
+ },
+ lg: {
+ base: "rounded-large",
+ progressTrack: "rounded-large",
+ },
+ full: {
+ base: "rounded-full",
+ closeButton: "-top-px -right-px",
+ progressTrack: "rounded-full",
+ },
+ },
+ disableAnimation: {
+ true: {
+ closeButton: "transition-none",
+ base: "data-[animation=exiting]:opacity-0",
+ },
+ false: {
+ closeButton: "transition-opacity ease-in duration-300",
+ base: [
+ "data-[animation=exiting]:transform",
+ "data-[animation=exiting]:delay-100",
+ "data-[animation=exiting]:data-[placement=right-bottom]:translate-x-28",
+ "data-[animation=exiting]:data-[placement=left-bottom]:-translate-x-28",
+ "data-[animation=exiting]:data-[placement=center-bottom]:translate-y-28",
+ "data-[animation=exiting]:data-[placement=right-top]:translate-x-28",
+ "data-[animation=exiting]:data-[placement=left-top]:-translate-x-28",
+ "data-[animation=exiting]:data-[placement=center-top]:-translate-y-28",
+ "data-[animation=exiting]:opacity-0",
+ "data-[animation=exiting]:duration-200",
+ ],
+ },
+ },
+ shadow: {
+ none: {
+ base: "shadow-none",
+ },
+ sm: {
+ base: "shadow-small",
+ },
+ md: {
+ base: "shadow-medium",
+ },
+ lg: {
+ base: "shadow-large",
+ },
+ },
+ },
+ defaultVariants: {
+ size: "md",
+ variant: "flat",
+ radius: "md",
+ shadow: "sm",
+ },
+ compoundVariants: [
+ // flat and color
+ {
+ variant: "flat",
+ color: "foreground",
+ class: {
+ base: "bg-foreground text-background",
+ closeButton: "text-foreground-400 hover:text-foreground-600",
+ closeIcon: "border border-foreground-400 bg-foreground-100",
+ title: "text-background-600",
+ description: "text-background-500",
+ },
+ },
+ {
+ variant: "flat",
+ color: "primary",
+ class: {
+ base: "bg-primary-50 text-primary-600 border-primary-100",
+ closeButton: "text-primary-400 hover:text-primary-600",
+ closeIcon: "border border-primary-400 bg-primary-100",
+ title: "text-primary-600",
+ description: "text-primary-500",
+ },
+ },
+ {
+ variant: "flat",
+ color: "secondary",
+ class: {
+ base: "bg-secondary-50 text-secondary-600 border-secondary-100",
+ closeButton: "text-secondary-400 hover:text-secondary-600",
+ closeIcon: "border border-secondary-400 bg-secondary-100",
+ title: "text-secondary-600",
+ description: "text-secondary-500",
+ },
+ },
+ {
+ variant: "flat",
+ color: "success",
+ class: {
+ base: "bg-success-50 text-success-600 border-success-100",
+ closeButton: "text-success-400 hover:text-success-600",
+ closeIcon: "border border-success-400 bg-success-100",
+ title: "text-success-600",
+ description: "text-success-500",
+ },
+ },
+ {
+ variant: "flat",
+ color: "warning",
+ class: {
+ base: "bg-warning-50 text-warning-600 border-warning-100",
+ closeButton: "text-warning-400 hover:text-warning-600",
+ closeIcon: "border border-warning-400 bg-warning-100",
+ title: "text-warning-600",
+ description: "text-warning-500",
+ },
+ },
+ {
+ variant: "flat",
+ color: "danger",
+ class: {
+ base: "bg-danger-50 text-danger-600 border-danger-100",
+ closeButton: "text-danger-400 hover:text-danger-600",
+ closeIcon: "border border-danger-400 bg-danger-100",
+ title: "text-danger-600",
+ description: "text-danger-500",
+ },
+ },
+ // bordered and color
+ {
+ variant: "bordered",
+ color: "foreground",
+ class: {
+ base: "bg-foreground border-foreground-400 text-background",
+ closeButton: "text-foreground-400 hover:text-foreground-600",
+ closeIcon: "border border-foreground-400 bg-foreground-100",
+ title: "text-background-600",
+ description: "text-background-500",
+ },
+ },
+ {
+ variant: "bordered",
+ color: "primary",
+ class: {
+ base: "border-primary-400 text-primary-600",
+ closeButton: "text-primary-400 hover:text-primary-600",
+ closeIcon: "border border-primary-400 bg-primary-100",
+ title: "text-primary-600",
+ description: "text-primary-500",
+ },
+ },
+ {
+ variant: "bordered",
+ color: "secondary",
+ class: {
+ base: "border-secondary-400 text-secondary-600",
+ closeButton: "text-secondary-400 hover:text-secondary-600",
+ closeIcon: "border border-secondary-400 bg-secondary-100",
+ title: "text-secondary-600",
+ description: "text-secondary-500",
+ },
+ },
+ {
+ variant: "bordered",
+ color: "success",
+ class: {
+ base: "border-success-400 text-success-600",
+ closeButton: "text-success-400 hover:text-success-600",
+ closeIcon: "border border-success-400 bg-success-100",
+ title: "text-success-600",
+ description: "text-success-500",
+ },
+ },
+ {
+ variant: "bordered",
+ color: "warning",
+ class: {
+ base: "border-warning-400 text-warning-600",
+ closeButton: "text-warning-400 hover:text-warning-600",
+ closeIcon: "border border-warning-400 bg-warning-100",
+ title: "text-warning-600",
+ description: "text-warning-500",
+ },
+ },
+ {
+ variant: "bordered",
+ color: "danger",
+ class: {
+ base: "border-danger-400 text-danger-600",
+ closeButton: "text-danger-400 hover:text-danger-600",
+ closeIcon: "border border-danger-400 bg-danger-100",
+ title: "text-danger-600",
+ description: "text-danger-500",
+ },
+ },
+ // solid and color
+ {
+ variant: "solid",
+ color: "foreground",
+ class: {
+ base: "bg-foreground text-background",
+ closeButton: "text-foreground-400 hover:text-foreground-600",
+ closeIcon: "border border-foreground-400 bg-foreground-100",
+ title: "text-background-600",
+ description: "text-background-500",
+ },
+ },
+ {
+ variant: "solid",
+ color: "primary",
+ class: {
+ base: "bg-primary-100 text-primary-600",
+ closeButton: "text-primary-400 hover:text-primary-600",
+ closeIcon: "border border-primary-400 bg-primary-100",
+ title: "text-primary-600",
+ description: "text-primary-500",
+ },
+ },
+ {
+ variant: "solid",
+ color: "secondary",
+ class: {
+ base: "bg-secondary-100 text-secondary-600",
+ closeButton: "text-secondary-400 hover:text-secondary-600",
+ closeIcon: "border border-secondary-400 bg-secondary-100",
+ title: "text-secondary-600",
+ description: "text-secondary-500",
+ },
+ },
+ {
+ variant: "solid",
+ color: "success",
+ class: {
+ base: "bg-success-100 text-success-600",
+ closeButton: "text-success-400 hover:text-success-600",
+ closeIcon: "border border-success-400 bg-success-100",
+ title: "text-success-600",
+ description: "text-success-500",
+ },
+ },
+ {
+ variant: "solid",
+ color: "warning",
+ class: {
+ base: "bg-warning-100 text-warning-600",
+ closeButton: "text-warning-400 hover:text-warning-600",
+ closeIcon: "border border-warning-400 bg-warning-100",
+ title: "text-warning-600",
+ description: "text-warning-500",
+ },
+ },
+ {
+ variant: "solid",
+ color: "danger",
+ class: {
+ base: "bg-danger-100 text-danger-600",
+ closeButton: "text-danger-400 hover:text-danger-600",
+ closeIcon: "border border-danger-400 bg-danger-100",
+ title: "text-danger-600",
+ description: "text-danger-500",
+ },
+ },
+ ],
+});
+
+export type ToastVariantProps = VariantProps;
+export type ToastSlots = keyof ReturnType;
+
+export type ToastRegionVariantProps = VariantProps;
+export type ToastRegionSlots = keyof ReturnType;
+
+export {toast, toastRegion};
diff --git a/packages/utilities/shared-icons/src/close.tsx b/packages/utilities/shared-icons/src/close.tsx
index 13ef5c2b00..51681d6e2b 100644
--- a/packages/utilities/shared-icons/src/close.tsx
+++ b/packages/utilities/shared-icons/src/close.tsx
@@ -17,6 +17,7 @@ export const CloseIcon = (
return (