diff --git a/.changeset/lovely-eggs-juggle.md b/.changeset/lovely-eggs-juggle.md
new file mode 100644
index 00000000000..c84e83e7ba3
--- /dev/null
+++ b/.changeset/lovely-eggs-juggle.md
@@ -0,0 +1,5 @@
+---
+"@razorpay/blade": minor
+---
+
+feat(blade): add toast component
diff --git a/packages/blade/package.json b/packages/blade/package.json
index d4a989760a5..82f15a7dee6 100644
--- a/packages/blade/package.json
+++ b/packages/blade/package.json
@@ -138,6 +138,7 @@
"use-presence": "1.1.0",
"@use-gesture/react": "10.2.24",
"@floating-ui/react": "0.25.4",
+ "react-hot-toast": "2.4.1",
"@emotion/react": "11.11.1",
"@table-library/react-table-library": "4.1.7",
"tinycolor2": "1.6.0"
@@ -284,6 +285,7 @@
"react-native-pager-view": "^6.2.1",
"react-native-svg": "^12.3.0",
"react-native-gesture-handler": "^2.9.0",
+ "react-hot-toast": "2.4.1",
"@gorhom/bottom-sheet": "^4.4.6",
"@gorhom/portal": "^1.0.14",
"@razorpay/i18nify-js": "^1.4.0"
diff --git a/packages/blade/src/components/Toast/Toast.native.tsx b/packages/blade/src/components/Toast/Toast.native.tsx
new file mode 100644
index 00000000000..fa7440a9b67
--- /dev/null
+++ b/packages/blade/src/components/Toast/Toast.native.tsx
@@ -0,0 +1,17 @@
+import type { ToastProps } from './types';
+import { throwBladeError } from '~utils/logger';
+
+const Toast = (
+ _props: ToastProps & {
+ isVisible?: boolean;
+ },
+): React.ReactElement => {
+ throwBladeError({
+ message: 'Toast is not yet implemented for native',
+ moduleName: 'Toast',
+ });
+
+ return <>>;
+};
+
+export { Toast };
diff --git a/packages/blade/src/components/Toast/Toast.stories.tsx b/packages/blade/src/components/Toast/Toast.stories.tsx
new file mode 100644
index 00000000000..59264ac4acb
--- /dev/null
+++ b/packages/blade/src/components/Toast/Toast.stories.tsx
@@ -0,0 +1,209 @@
+/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
+/* eslint-disable jsx-a11y/label-has-associated-control */
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { Title } from '@storybook/addon-docs';
+import type { StoryFn, Meta } from '@storybook/react';
+import React from 'react';
+import { useToast } from './useToast';
+import { Toast } from './Toast';
+import type { ToastProps } from './';
+import { ToastContainer } from './';
+import StoryPageWrapper from '~utils/storybook/StoryPageWrapper';
+import { Sandbox } from '~utils/storybook/Sandbox';
+import { Box } from '~components/Box';
+import { Button } from '~components/Button';
+import { Heading, Text } from '~components/Typography';
+
+const Page = (): React.ReactElement => {
+ return (
+
+ Usage
+
+ {`
+ import { ToastContainer, useToast } from '@razorpay/blade/components';
+
+ function App(): React.ReactElement {
+ const toast = useToast();
+
+ // Integrating Blade Toast in your App
+ // 1. Render the ToastContainer component at the root of your app
+ // 2. Utilize the methods exposed via useToast hook to show/dismiss toasts
+ return (
+
+
+
+
+ );
+ }
+
+ export default App;
+ `}
+
+
+ );
+};
+
+export default {
+ title: 'Components/Toast',
+ component: Toast,
+ tags: ['autodocs'],
+ argTypes: {
+ isVisible: {
+ table: {
+ disable: true,
+ },
+ },
+ id: {
+ table: {
+ disable: true,
+ },
+ },
+ },
+ parameters: {
+ docs: {
+ page: Page,
+ },
+ },
+} as Meta;
+
+const texts = {
+ negative: 'Unable to fetch merchant details',
+ positive: 'Customer details failed successfully',
+ notice: 'Your KYC is pending',
+ information: 'Your transaction will be settled in 3 business days',
+ neutral: 'Your transaction will be settled in 3 business days',
+} as const;
+
+const BasicToastTemplate: StoryFn = (args) => {
+ const toast = useToast();
+
+ if (args.type === 'promotional') {
+ args.content = {args.content};
+ }
+
+ return (
+
+
+ After changing storybook controls, press the show "toast button" to see changes
+
+
+
+
+ );
+};
+
+BasicToastTemplate.storyName = 'Basic';
+export const Basic = BasicToastTemplate.bind({});
+Basic.args = {
+ color: 'neutral',
+ type: 'informational',
+ autoDismiss: false,
+ content: 'Payment successful',
+ action: {
+ text: 'Okay',
+ onClick: ({ toastId }) => console.log(toastId),
+ },
+};
+
+const ToastVariantsTemplate: StoryFn = () => {
+ const toast = useToast();
+ const hasPromoToast = toast.toasts.some((t) => t.type === 'promotional');
+
+ const showInformationalToast = ({ color }: { color: ToastProps['color'] }) => {
+ toast.show({
+ content: texts[color!],
+ color,
+ action: {
+ text: 'Okay',
+ onClick: ({ toastId }) => toast.dismiss(toastId),
+ },
+ onDismissButtonClick: ({ toastId }) => console.log(`${toastId} Dismissed!`),
+ });
+ };
+
+ const showPromotionalToast = () => {
+ toast.show({
+ type: 'promotional',
+ content: (
+
+ Introducing TurboUPI
+
+ Lightning-fast payments with the new Razorpay Turbo UPI
+
+ Turbo UPI allows end-users to complete their payment in-app, with no redirections or
+ dependence on third-party UPI apps. With Turbo UPI, payments will be 5x faster with a
+ significantly-improved success rate of 10%!
+
+
+ ),
+ action: {
+ text: 'Try TurboUPI',
+ onClick: ({ toastId }) => toast.dismiss(toastId),
+ },
+ onDismissButtonClick: ({ toastId }) => console.log(`${toastId} Dismissed!`),
+ });
+ };
+
+ return (
+
+ Show Informational Toasts:
+
+
+
+
+
+
+
+ Show Promotional Toasts:
+
+ Note: There can only be 1 promotional toast at a time
+
+
+
+
+
+
+ );
+};
+
+export const ToastVariants = ToastVariantsTemplate.bind({});
+ToastVariants.storyName = 'Toast Variants';
diff --git a/packages/blade/src/components/Toast/Toast.web.tsx b/packages/blade/src/components/Toast/Toast.web.tsx
new file mode 100644
index 00000000000..ffdb57c9fd9
--- /dev/null
+++ b/packages/blade/src/components/Toast/Toast.web.tsx
@@ -0,0 +1,177 @@
+/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import React from 'react';
+import toast from 'react-hot-toast';
+import type { FlattenSimpleInterpolation } from 'styled-components';
+import styled, { css, keyframes } from 'styled-components';
+import type { ToastProps } from './types';
+import { Box } from '~components/Box';
+import { Button } from '~components/Button';
+import { IconButton } from '~components/Button/IconButton';
+import {
+ AlertOctagonIcon,
+ AlertTriangleIcon,
+ CheckCircleIcon,
+ CloseIcon,
+ InfoIcon,
+} from '~components/Icons';
+import BaseBox from '~components/Box/BaseBox';
+import { Text } from '~components/Typography';
+import { castWebType, makeMotionTime, useTheme } from '~utils';
+import getIn from '~utils/lodashButBetter/get';
+import { makeAccessible } from '~utils/makeAccessible';
+
+const iconMap = {
+ positive: CheckCircleIcon,
+ negative: AlertOctagonIcon,
+ information: InfoIcon,
+ neutral: InfoIcon,
+ notice: AlertTriangleIcon,
+};
+
+const borderColorMap = {
+ positive: 'feedback.border.positive.intense',
+ negative: 'feedback.border.negative.intense',
+ notice: 'feedback.border.notice.intense',
+ information: 'feedback.border.information.intense',
+ neutral: 'feedback.border.neutral.intense',
+} as const;
+
+const slideIn = keyframes`
+ from {
+ opacity: 0;
+ transform: translateY(100%);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+`;
+
+const slideOut = keyframes`
+ from {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ to {
+ opacity: 0;
+ transform: translateY(100%);
+ }
+`;
+
+const AnimatedFade = styled(BaseBox)<{
+ animationType: FlattenSimpleInterpolation | null;
+ toastBorderColor: string;
+}>(({ animationType, toastBorderColor }) => {
+ return css`
+ overflow: hidden;
+ border: 1px solid ${toastBorderColor};
+ ${animationType}
+ `;
+});
+
+const Toast = ({
+ type,
+ color = 'neutral',
+ leading,
+ action,
+ content,
+ onDismissButtonClick,
+ isVisible,
+ id,
+}: ToastProps & {
+ isVisible?: boolean;
+}): React.ReactElement => {
+ const { theme } = useTheme();
+ const Icon = leading || iconMap[color];
+ const isPromotional = type === 'promotional';
+ const actionButton = action ? (
+
+
+
+ ) : null;
+
+ const enter = css`
+ opacity: 0;
+ animation: ${slideIn} ${makeMotionTime(theme.motion.duration.gentle)}
+ ${castWebType(theme.motion.easing.entrance.effective)} forwards;
+ `;
+
+ const exit = css`
+ opacity: 1;
+ animation: ${slideOut} ${makeMotionTime(theme.motion.duration.moderate)}
+ ${castWebType(theme.motion.easing.exit.effective)} forwards;
+ `;
+
+ return (
+
+ {Icon ? (
+
+
+
+ ) : null}
+
+ {isPromotional ? (
+ content
+ ) : (
+
+ {content}
+
+ )}
+ {isPromotional && actionButton}
+
+
+ {!isPromotional && actionButton}
+ ) => {
+ event.stopPropagation();
+ onDismissButtonClick?.({ event, toastId: id! });
+ toast.dismiss(id);
+ }}
+ icon={CloseIcon}
+ />
+
+
+ );
+};
+
+export { Toast };
diff --git a/packages/blade/src/components/Toast/ToastContainer.native.tsx b/packages/blade/src/components/Toast/ToastContainer.native.tsx
new file mode 100644
index 00000000000..b799a30c56a
--- /dev/null
+++ b/packages/blade/src/components/Toast/ToastContainer.native.tsx
@@ -0,0 +1,12 @@
+import { throwBladeError } from '~utils/logger';
+
+const ToastContainer = (): React.ReactElement => {
+ throwBladeError({
+ message: 'ToastContainer is not yet implemented for native',
+ moduleName: 'ToastContainer',
+ });
+
+ return <>>;
+};
+
+export { ToastContainer };
diff --git a/packages/blade/src/components/Toast/ToastContainer.web.tsx b/packages/blade/src/components/Toast/ToastContainer.web.tsx
new file mode 100644
index 00000000000..b39d7290927
--- /dev/null
+++ b/packages/blade/src/components/Toast/ToastContainer.web.tsx
@@ -0,0 +1,318 @@
+import type { ToastPosition, ToasterProps, Toast } from 'react-hot-toast';
+import { resolveValue, useToaster } from 'react-hot-toast';
+import React from 'react';
+import styled from 'styled-components';
+import {
+ PEEKS,
+ MAX_TOASTS,
+ SCALE_FACTOR,
+ GUTTER,
+ PEEK_GUTTER,
+ TOAST_MAX_WIDTH,
+ TOAST_Z_INDEX,
+ MIN_TOAST_MOBILE,
+ MIN_TOAST_DESKTOP,
+ CONTAINER_GUTTER_MOBILE,
+ CONTAINER_GUTTER_DESKTOP,
+} from './constants';
+import { makeMotionTime, makeSize, useTheme } from '~utils';
+import BaseBox from '~components/Box/BaseBox';
+import type { Theme } from '~components/BladeProvider';
+import { useIsMobile } from '~utils/useIsMobile';
+import { metaAttribute } from '~utils/metaAttribute';
+
+type CalculateYPositionProps = {
+ toast: Toast;
+ index: number;
+ isExpanded: boolean;
+ reverseOrder?: boolean;
+};
+
+const StyledToastWrapper = styled(BaseBox)<{
+ isVisible: boolean;
+ index: number;
+ isExpanded: boolean;
+ isPromotional: boolean;
+}>(({ isVisible, index, isExpanded, isPromotional }) => {
+ let opacity = isVisible ? 1 : 0;
+ // Only make the PEEKING and MAX_TOASTS toasts visible,
+ // Every other toasts should be hidden
+ if (index < PEEKS + MAX_TOASTS) {
+ opacity = 1;
+ } else if (isPromotional || isExpanded) {
+ opacity = 1;
+ } else {
+ opacity = 0;
+ }
+
+ return {
+ '& > *': {
+ pointerEvents: opacity === 1 ? 'auto' : 'none',
+ },
+ opacity,
+ };
+});
+
+const getPositionStyle = (
+ position: ToastPosition,
+ offset: number,
+ scale: number,
+ theme: Theme,
+): React.CSSProperties => {
+ const top = position.includes('top');
+ const verticalStyle: React.CSSProperties = top ? { top: 0 } : { bottom: 0 };
+ const horizontalStyle: React.CSSProperties = position.includes('center')
+ ? {
+ justifyContent: 'center',
+ }
+ : position.includes('right')
+ ? {
+ justifyContent: 'flex-end',
+ }
+ : {};
+
+ return {
+ left: 0,
+ right: 0,
+ display: 'flex',
+ position: 'absolute',
+ transformOrigin: 'center',
+ transition: `${makeMotionTime(theme.motion.duration.gentle)} ${
+ theme.motion.easing.standard.effective
+ }`,
+ transitionProperty: 'transform, opacity, height',
+ transform: `translateY(${offset * (top ? 1 : -1)}px) scale(${scale})`,
+ ...verticalStyle,
+ ...horizontalStyle,
+ };
+};
+
+function isPromotionalToast(toast: Toast): boolean {
+ // @ts-expect-error
+ return toast.type == 'promotional';
+}
+
+const Toaster: React.FC = ({
+ reverseOrder,
+ position = 'top-center',
+ toastOptions,
+ containerClassName,
+}) => {
+ const { toasts, handlers } = useToaster(toastOptions);
+ const { theme } = useTheme();
+ const [frontToastHeight, setFrontToastHeight] = React.useState(0);
+ const [hasManuallyExpanded, setHasManuallyExpanded] = React.useState(false);
+ const isMobile = useIsMobile();
+ const minToasts = isMobile ? MIN_TOAST_MOBILE : MIN_TOAST_DESKTOP;
+ const containerGutter = isMobile ? CONTAINER_GUTTER_MOBILE : CONTAINER_GUTTER_DESKTOP;
+
+ const infoToasts = React.useMemo(() => toasts.filter((toast) => !isPromotionalToast(toast)), [
+ toasts,
+ ]);
+ const promoToasts = React.useMemo(() => toasts.filter((toast) => isPromotionalToast(toast)), [
+ toasts,
+ ]);
+
+ // always keep promo toasts at the bottom of the stack
+ const recomputedToasts = React.useMemo(() => [...infoToasts, ...promoToasts], [
+ infoToasts,
+ promoToasts,
+ ]);
+
+ const hasPromoToast = promoToasts.length > 0 && promoToasts[0]?.visible;
+ const promoToastHeight = promoToasts[0]?.height ?? 0;
+ const isExpanded = hasManuallyExpanded || recomputedToasts.length <= minToasts;
+
+ React.useLayoutEffect(() => {
+ // find the first toast which is visible
+ const firstToast = infoToasts.find((t, index) => t.visible && index === 0);
+ if (firstToast) {
+ setFrontToastHeight(firstToast.height ?? 0);
+ }
+ }, [infoToasts]);
+
+ // calculate total height of all toasts
+ const totalHeight = React.useMemo(() => {
+ return (
+ recomputedToasts
+ // only consider visible recomputedToasts
+ .filter((toast) => toast.visible)
+ .reduce((prevHeight, toast) => prevHeight + (toast.height ?? 0), 0) +
+ recomputedToasts.length * GUTTER
+ );
+ }, [recomputedToasts]);
+
+ // Stacking logic explained in detail:
+ // https://www.loom.com/share/522d9a445e2f41e1886cce4decb9ab9d?sid=4287acf6-8d44-431b-93e1-c1a0d40a0aba
+ //
+ // 1. 3 toasts can be stacked on top of each other
+ // 2. After 3 toasts, the toasts will be scaled down and peek from behind
+ // 3. There can be maximum of 3 toasts peeking from behind
+ // 4. After 3 peeking toasts, the toasts will be hidden
+ // 5. If there is a promo toast, all toasts will be lifted up
+ // 6. Promo toasts will always be on the bottom
+ const calculateYPosition = React.useCallback(
+ ({ toast, index }: CalculateYPositionProps) => {
+ // find the current toast index
+ const toastIndex = infoToasts.findIndex((t) => t.id === toast.id);
+ // number of toasts before this toast
+ const toastsBefore = infoToasts.filter((toast, i) => i < toastIndex && toast.visible).length;
+
+ let scale = Math.max(0.7, 1 - toastsBefore * SCALE_FACTOR);
+ // first toast should always have a scale of 1
+ if (index < MAX_TOASTS) {
+ scale = 1;
+ }
+
+ // y position of toast,
+ let offset = infoToasts
+ .filter((toast) => toast.visible)
+ .slice(0, toastsBefore)
+ .reduce((y, toast) => {
+ // if the toast is expanded, add the height of the toast + gutter
+ if (isExpanded) {
+ return y + (toast.height ?? 0) + GUTTER;
+ }
+ // if the toast is not expanded, add only the peek gutter
+ return y + PEEK_GUTTER;
+ }, 0);
+
+ // lift all info toasts up if there is a promo toast
+ if (hasPromoToast) {
+ offset += GUTTER + promoToastHeight;
+ }
+
+ // if this is a promo toast, then put it at the bottom and force the scale to 1
+ if (isPromotionalToast(toast)) {
+ offset = 0;
+ scale = 1;
+ }
+
+ return { offset, scale: isExpanded ? 1 : scale };
+ },
+ [hasPromoToast, infoToasts, isExpanded, promoToastHeight],
+ );
+
+ const handleMouseEnter = (): void => {
+ if (isMobile) return;
+ setHasManuallyExpanded(true);
+ handlers.startPause();
+ };
+
+ const handleMouseLeave = (): void => {
+ if (isMobile) return;
+ setHasManuallyExpanded(false);
+ handlers.endPause();
+ };
+
+ const handleToastClick = (): void => {
+ if (!isMobile) return;
+ setHasManuallyExpanded((prev) => {
+ const next = !prev;
+ if (next) {
+ handlers.startPause();
+ } else {
+ handlers.endPause();
+ }
+ return next;
+ });
+ };
+
+ return (
+
+ {/*
+ * Mouseover container,
+ * fills in the gap between toasts so that mouseleave doesn't trigger in the gaps
+ */}
+
+ {recomputedToasts.map((toast, index) => {
+ const toastPosition = toast.position ?? position;
+ const isPromotional = isPromotionalToast(toast);
+ const { offset, scale } = calculateYPosition({
+ toast,
+ isExpanded,
+ reverseOrder,
+ index,
+ });
+ const positionStyle = getPositionStyle(toastPosition, offset, scale, theme);
+ // recalculate height of toast
+ const ref = (el: HTMLDivElement): void => {
+ if (el && typeof toast.height !== 'number') {
+ const height = el.getBoundingClientRect().height;
+ handlers.updateHeight(toast.id, height);
+ }
+ };
+
+ let toastHeight = toast.height;
+ if (index > MAX_TOASTS - 1 && !isPromotional) {
+ toastHeight = frontToastHeight;
+ }
+ if (isExpanded) {
+ toastHeight = toast.height;
+ }
+
+ return (
+ {
+ if (isPromotional) return;
+ handleMouseEnter();
+ }}
+ onMouseLeave={() => {
+ if (isPromotional) return;
+ handleMouseLeave();
+ }}
+ onClick={() => {
+ if (isPromotional) return;
+ handleToastClick();
+ }}
+ >
+
+ {resolveValue(toast.message, { ...toast, index })}
+
+
+ );
+ })}
+
+ );
+};
+
+const ToastContainer = (): React.ReactElement => {
+ return ;
+};
+
+export { ToastContainer };
diff --git a/packages/blade/src/components/Toast/__tests__/Toast.test.stories.tsx b/packages/blade/src/components/Toast/__tests__/Toast.test.stories.tsx
new file mode 100644
index 00000000000..a7f85b2e544
--- /dev/null
+++ b/packages/blade/src/components/Toast/__tests__/Toast.test.stories.tsx
@@ -0,0 +1,237 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+/* eslint-disable import/no-extraneous-dependencies */
+import type { StoryFn } from '@storybook/react';
+import { within, userEvent } from '@storybook/testing-library';
+import { expect, jest } from '@storybook/jest';
+import React from 'react';
+import type { ToastProps } from '../types';
+import { useToast } from '../useToast';
+import { ToastContainer } from '../ToastContainer';
+import type { Toast } from '../Toast';
+import { Button } from '~components/Button';
+import { Box } from '~components/Box';
+
+const onDismissButtonClick = jest.fn();
+const ToastExample = (props: ToastProps): React.ReactElement => {
+ const toast = useToast();
+
+ React.useEffect(() => {
+ toast.dismiss();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
+
+ );
+};
+
+const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));
+
+export const TestToastShow: StoryFn = (): React.ReactElement => {
+ return ;
+};
+
+TestToastShow.play = async () => {
+ const { getByRole, queryByText } = within(document.body);
+ await sleep(1000);
+
+ const toastContent = 'Payment successful';
+ await expect(queryByText(toastContent)).not.toBeInTheDocument();
+ const button = getByRole('button', { name: 'Show Toast' });
+ await userEvent.click(button);
+ await sleep(400);
+ await expect(queryByText(toastContent)).toBeVisible();
+ await sleep(4000);
+ await expect(queryByText(toastContent)).not.toBeVisible();
+};
+
+export const TestToastDismiss: StoryFn = (): React.ReactElement => {
+ onDismissButtonClick.mockReset();
+ return ;
+};
+
+TestToastDismiss.play = async () => {
+ const { getByRole, queryByText } = within(document.body);
+ await sleep(1000);
+
+ const toastContent = 'Payment successful';
+ await expect(queryByText(toastContent)).not.toBeInTheDocument();
+ const button = getByRole('button', { name: 'Show Toast' });
+ await userEvent.click(button);
+ await sleep(400);
+ await expect(queryByText(toastContent)).toBeVisible();
+ const dismissButton = getByRole('button', { name: 'Dismiss toast' });
+ await userEvent.click(dismissButton);
+ await expect(onDismissButtonClick).toBeCalledTimes(1);
+ await sleep(400);
+ await expect(queryByText(toastContent)).not.toBeVisible();
+};
+
+export const ToastHover: StoryFn = (): React.ReactElement => {
+ onDismissButtonClick.mockReset();
+ return ;
+};
+
+ToastHover.play = async () => {
+ const { getByRole, queryByText, getByTestId } = within(document.body);
+ await sleep(1000);
+
+ const toastContent = 'Payment successful';
+ await expect(queryByText(toastContent)).not.toBeInTheDocument();
+ const button = getByRole('button', { name: 'Show Toast' });
+ await userEvent.click(button);
+ await sleep(400);
+ await expect(queryByText(toastContent)).toBeVisible();
+ // hover into toast container
+ const toastContainer = getByTestId('toast-mouseover-container')!;
+ await userEvent.hover(toastContainer);
+ // wait for 2s, while hovering over the toast container the toasts should not dismiss
+ await sleep(2000);
+ await expect(queryByText(toastContent)).toBeVisible();
+ // hover out of toast container
+ await userEvent.unhover(toastContainer);
+ await sleep(1000);
+ await expect(queryByText(toastContent)).not.toBeVisible();
+};
+
+export const ToastStacking: StoryFn = (): React.ReactElement => {
+ const toast = useToast();
+
+ React.useEffect(() => {
+ toast.dismiss();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+ToastStacking.play = async () => {
+ const { getByRole, getAllByRole, queryByText, getByTestId } = within(document.body);
+ await sleep(1000);
+
+ const button1 = getByRole('button', { name: 'Show 1' });
+ const button2 = getByRole('button', { name: 'Show 2' });
+ const button3 = getByRole('button', { name: 'Show 3' });
+ const button4 = getByRole('button', { name: 'Show 4' });
+ const promoButton = getByRole('button', { name: 'Show Promo' });
+
+ // fire 3 toasts
+ await userEvent.click(button1);
+ await userEvent.click(button2);
+ await userEvent.click(button3);
+
+ // wait for 400ms
+ await sleep(400);
+
+ // expect 3 toasts to be visible
+ await expect(queryByText('Toast 1')).toBeVisible();
+ await expect(queryByText('Toast 2')).toBeVisible();
+ await expect(queryByText('Toast 3')).toBeVisible();
+
+ // expect 3 toasts to be stacked on top of each other
+ const toastContainer = getByTestId('toast-mouseover-container')!;
+ await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(120);
+
+ // fire 4th toast
+ await userEvent.click(button4);
+ await sleep(400);
+ await expect(queryByText('Toast 4')).toBeVisible();
+
+ await expect(toastContainer.getBoundingClientRect().height).toBeLessThan(40);
+
+ const toast4 = queryByText('Toast 4')! as Element;
+
+ // hover into toast container
+ await userEvent.hover(toast4);
+ await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(160);
+ await sleep(400);
+ await userEvent.unhover(toast4);
+ await expect(toastContainer.getBoundingClientRect().height).toBeLessThan(40);
+
+ // fire promo toast
+ await userEvent.click(promoButton);
+ await sleep(400);
+ await expect(queryByText('Promo Toast')).toBeVisible();
+ await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(30);
+
+ // hover over promo toast, should not increase height
+ const promoToast = queryByText('Promo Toast')! as Element;
+ await userEvent.hover(promoToast);
+ await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(30);
+ // unhover
+ await userEvent.unhover(promoToast);
+ await sleep(400);
+ await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(30);
+
+ // dissmiss two info toasts
+ await userEvent.click(getAllByRole('button', { name: 'Dismiss toast' })[0]);
+ await userEvent.click(getAllByRole('button', { name: 'Dismiss toast' })[1]);
+
+ await sleep(400);
+ await expect(queryByText('Toast 3')).not.toBeVisible();
+ await expect(queryByText('Toast 4')).not.toBeVisible();
+
+ await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(130);
+};
+
+export default {
+ title: 'Components/Interaction Tests/Toast',
+ parameters: {
+ controls: {
+ disable: true,
+ },
+ a11y: { disable: true },
+ essentials: { disable: true },
+ actions: { disable: true },
+ },
+};
diff --git a/packages/blade/src/components/Toast/_decisions/decisions.md b/packages/blade/src/components/Toast/_decisions/decisions.md
index df66bdf1f9c..6806ced3fc7 100644
--- a/packages/blade/src/components/Toast/_decisions/decisions.md
+++ b/packages/blade/src/components/Toast/_decisions/decisions.md
@@ -83,7 +83,7 @@ type ToastProps = {
/**
* @default `neutral`
*/
- color?: 'neutral' | 'positive' | 'negative' | 'warning' | 'information'
+ color?: 'neutral' | 'positive' | 'negative' | 'notice' | 'information'
/**
* Content of the toast
diff --git a/packages/blade/src/components/Toast/constants.ts b/packages/blade/src/components/Toast/constants.ts
new file mode 100644
index 00000000000..f6b82ee2e8c
--- /dev/null
+++ b/packages/blade/src/components/Toast/constants.ts
@@ -0,0 +1,22 @@
+import { size } from '~tokens/global';
+
+export const TOAST_MAX_WIDTH = size['360'];
+// higher than modal
+export const TOAST_Z_INDEX = 2000;
+
+// Space between the toasts
+export const GUTTER = 12;
+// Space between the collapsed toast's peek
+export const PEEK_GUTTER = 12;
+// Gap between the toast container and the page body
+export const CONTAINER_GUTTER_MOBILE = 16;
+export const CONTAINER_GUTTER_DESKTOP = 24;
+// How much to scale down the peeking toasts
+export const SCALE_FACTOR = 0.05;
+// While collapsed, how many toasts to show
+export const MAX_TOASTS = 1;
+// Minimum toasts to show on mobile and desktop
+export const MIN_TOAST_MOBILE = 1;
+export const MIN_TOAST_DESKTOP = 3;
+// While collapsed, how many toasts should be peeking
+export const PEEKS = 3;
diff --git a/packages/blade/src/components/Toast/index.ts b/packages/blade/src/components/Toast/index.ts
new file mode 100644
index 00000000000..df9b1d09820
--- /dev/null
+++ b/packages/blade/src/components/Toast/index.ts
@@ -0,0 +1,3 @@
+export * from './ToastContainer';
+export * from './useToast';
+export * from './types';
diff --git a/packages/blade/src/components/Toast/types.ts b/packages/blade/src/components/Toast/types.ts
new file mode 100644
index 00000000000..8f4daa3fa89
--- /dev/null
+++ b/packages/blade/src/components/Toast/types.ts
@@ -0,0 +1,73 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import type React from 'react';
+import type { ButtonProps } from '~components/Button';
+import type { FeedbackColors } from '~tokens/theme/theme';
+
+type ToastProps = {
+ /**
+ * @default `informational`
+ */
+ type?: 'informational' | 'promotional';
+
+ /**
+ * Content of the toast
+ */
+ content: React.ReactNode;
+
+ /**
+ * @default `neutral`
+ */
+ color?: FeedbackColors;
+
+ /**
+ * Can be used to render an icon
+ */
+ leading?: React.ComponentType;
+
+ /**
+ * If true, the toast will be dismissed after few seconds
+ *
+ * Duration for promotional toast is 8s
+ * Duration for informational toast is 4s
+ *
+ * @default false
+ */
+ autoDismiss?: boolean;
+
+ /**
+ * Duration in milliseconds for which the toast will be visible
+ *
+ * @default 4000 for informational toast
+ * @default 8000 for promotional toast
+ */
+ duration?: number;
+
+ /**
+ * Called when the toast is dismissed or duration runs out
+ */
+ onDismissButtonClick?: ({
+ event,
+ toastId,
+ }: {
+ event: React.MouseEvent;
+ toastId: string;
+ }) => void;
+
+ /**
+ * Primary action of toast
+ */
+ action?: {
+ text: string;
+ onClick?: ({ event, toastId }: { event: ButtonProps['onClick']; toastId: string }) => void;
+ isLoading?: boolean;
+ };
+
+ /**
+ * Forwarded to react-hot-toast
+ *
+ * This can be used to programatically update toasts by providing an id
+ */
+ id?: string;
+};
+
+export type { ToastProps };
diff --git a/packages/blade/src/components/Toast/useToast.tsx b/packages/blade/src/components/Toast/useToast.tsx
new file mode 100644
index 00000000000..3886e7113eb
--- /dev/null
+++ b/packages/blade/src/components/Toast/useToast.tsx
@@ -0,0 +1,70 @@
+import type { Toast } from 'react-hot-toast';
+import toast, { useToasterStore } from 'react-hot-toast';
+import type { ToastProps } from './types';
+import { Toast as ToastComponent } from './Toast';
+import { logger } from '~utils/logger';
+
+type BladeToast = Omit & ToastProps;
+type UseToastReturn = {
+ toasts: BladeToast[];
+ show: (props: ToastProps) => string;
+ dismiss: (id?: string) => void;
+};
+
+const useToast = (): UseToastReturn => {
+ const { toasts } = useToasterStore();
+ const show = (props: ToastProps): string => {
+ props.type = props.type ?? 'informational';
+
+ // Do not show promotional toasts if there is already one
+ if (
+ toasts.find((t) => {
+ // @ts-expect-error - react-hot-toast doesn't recognize our promotional type
+ return t.type === 'promotional';
+ }) &&
+ props.type === 'promotional'
+ ) {
+ if (__DEV__) {
+ logger({
+ message: 'There can only be one promotional toast at a time',
+ type: 'warn',
+ moduleName: 'Toast',
+ });
+ }
+ return '';
+ }
+
+ const isPromoToast = props.type === 'promotional';
+ if (props.autoDismiss === undefined) {
+ // Promotional toasts should not auto dismiss
+ props.autoDismiss = !isPromoToast;
+ }
+
+ if (props.duration === undefined) {
+ // Set default durations
+ if (isPromoToast) {
+ props.duration = 8000;
+ } else {
+ props.duration = 4000;
+ }
+ }
+
+ // If autoDismiss is false, set duration to infinity
+ if (!props.autoDismiss) {
+ props.duration = Infinity;
+ }
+
+ return toast.custom(({ visible, id }) => {
+ return ;
+ }, props);
+ };
+
+ return {
+ toasts: (toasts as unknown) as BladeToast[],
+ show,
+ dismiss: toast.dismiss,
+ };
+};
+
+export type { UseToastReturn };
+export { useToast };
diff --git a/packages/blade/src/components/index.ts b/packages/blade/src/components/index.ts
index 5c3c0d6887c..6add468277f 100644
--- a/packages/blade/src/components/index.ts
+++ b/packages/blade/src/components/index.ts
@@ -39,6 +39,7 @@ export * from './Table';
export * from './Tabs';
export * from './Tag';
export * from './Tooltip';
+export * from './Toast';
export * from './types';
export * from './Typography';
export * from './VisuallyHidden';
diff --git a/yarn.lock b/yarn.lock
index d862797671a..e7da1d54dae 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6801,12 +6801,12 @@
schema-utils "^3.0.0"
source-map "^0.7.3"
-"@razorpay/i18nify-js@^1.4.0":
+"@razorpay/i18nify-js@1.4.0", "@razorpay/i18nify-js@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@razorpay/i18nify-js/-/i18nify-js-1.4.0.tgz#4988e9a10b55c6fbe1b045afa7f0f240eea01845"
integrity sha512-JY1g3Kn4e8ktsMbrDHOh8TEtPvJOT/A6FKGfNcaEVZ7uJM5Ew9fwP5j6orSF7FaGEZ8PVEIoCsycKhwm9fLfrA==
-"@razorpay/i18nify-react@^4.0.0":
+"@razorpay/i18nify-react@4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@razorpay/i18nify-react/-/i18nify-react-4.0.0.tgz#f7cd2a019cb5b201c9086398486e492971d16ca5"
integrity sha512-dOLilNaWS9397R/1hZM4ffFJDmqLUPkqZBp+Bmra3wo8tdhbk43fTAMrZhk1o9EU+WqxiP9Wup8mnZQ33qP0/g==
@@ -18010,6 +18010,11 @@ gonzales-pe@^4.3.0:
dependencies:
minimist "^1.2.5"
+goober@^2.1.10:
+ version "2.1.14"
+ resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.14.tgz#4a5c94fc34dc086a8e6035360ae1800005135acd"
+ integrity sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==
+
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
@@ -27001,6 +27006,13 @@ react-github-button@^0.1.11:
dependencies:
prop-types "^15.5.10"
+react-hot-toast@2.4.1:
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994"
+ integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==
+ dependencies:
+ goober "^2.1.10"
+
react-inspector@^6.0.0:
version "6.0.2"
resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.2.tgz#aa3028803550cb6dbd7344816d5c80bf39d07e9d"