diff --git a/apps/www/src/components/playground/toast-examples.tsx b/apps/www/src/components/playground/toast-examples.tsx
index a60833d0b..faf8d923e 100644
--- a/apps/www/src/components/playground/toast-examples.tsx
+++ b/apps/www/src/components/playground/toast-examples.tsx
@@ -1,73 +1,85 @@
'use client';
-import { Button, Flex, ToastContainer, toast } from '@raystack/apsara';
+import { Button, Flex, Toast, toastManager } from '@raystack/apsara';
import PlaygroundLayout from './playground-layout';
export function ToastExamples() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ toastManager.add({ title: 'Success toast', type: 'success' })
+ }
+ >
+ Success Toast
+
+
+ toastManager.add({ title: 'Error toast', type: 'error' })
+ }
+ >
+ Error Toast
+
+
+ toastManager.add({ title: 'Warning toast', type: 'warning' })
+ }
+ >
+ Warning Toast
+
+
+ toastManager.add({ title: 'Info toast', type: 'info' })
+ }
+ >
+ Info Toast
+
+
+
+
+ toastManager.add({
+ title: 'With description',
+ description: 'This toast has a title and a description.',
+ type: 'success'
+ })
+ }
+ >
+ Description Toast
+
+
+ toastManager.add({
+ title: 'Item deleted',
+ description: '1 item was moved to trash.',
+ actionProps: {
+ children: 'Undo',
+ onClick: () => console.log('Undo clicked')
+ }
+ })
+ }
+ >
+ Action Toast
+
+
+ toastManager.promise(
+ new Promise(resolve => setTimeout(resolve, 2000)),
+ {
+ loading: 'Loading...',
+ success: 'Done!',
+ error: 'Failed!'
+ }
+ )
+ }
+ >
+ Promise Toast
+
+
+
);
}
diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts
index 64c9b91d8..4d5a06714 100644
--- a/apps/www/src/content/docs/components/toast/demo.ts
+++ b/apps/www/src/content/docs/components/toast/demo.ts
@@ -3,13 +3,269 @@
export const preview = {
type: 'code',
code: `
- function ToastTest(){
- return
-
- toast.success("This is a toast")}>
- Trigger toast
+ function ToastPreview() {
+ return (
+
+
+ toastManager.add({ title: "This is a toast" })}>
+ Trigger toast
+
+
+
+ )
+ }`
+};
+
+export const basicDemo = {
+ type: 'code',
+ code: `
+ toastManager.add({ title: "Hello from Apsara!" })}>
+ Show toast
+ `
+};
+
+export const typesDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Default',
+ code: `
+ toastManager.add({ title: "Default toast" })}>
+ Default
+ `
+ },
+ {
+ name: 'Success',
+ code: `
+ toastManager.add({ title: "Saved successfully", type: "success" })}>
+ Success
+ `
+ },
+ {
+ name: 'Error',
+ code: `
+ toastManager.add({ title: "Something went wrong", type: "error" })}>
+ Error
+ `
+ },
+ {
+ name: 'Warning',
+ code: `
+ toastManager.add({ title: "Heads up!", type: "warning" })}>
+ Warning
+ `
+ },
+ {
+ name: 'Info',
+ code: `
+ toastManager.add({ title: "FYI: System update available", type: "info" })}>
+ Info
+ `
+ }
+ ]
+};
+
+export const descriptionDemo = {
+ type: 'code',
+ code: `
+
+ toastManager.add({
+ title: "File uploaded",
+ description: "Your document has been uploaded successfully.",
+ type: "success"
+ })}>
+ With description
+
+ toastManager.add({
+ title: "Connection lost",
+ description: "Please check your internet connection and try again.",
+ type: "error"
+ })}>
+ Error with description
-
-}`
+ `
+};
+
+export const actionDemo = {
+ type: 'code',
+ code: `
+ toastManager.add({
+ title: "Item deleted",
+ description: "1 item was moved to trash.",
+ actionProps: {
+ children: "Undo",
+ onClick: () => toastManager.add({ title: "Item restored", type: "success" })
+ }
+ })}>
+ Action toast
+ `
+};
+
+export const promiseDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Basic',
+ code: `
+ {
+ const promise = new Promise((resolve) => setTimeout(resolve, 2000));
+ toastManager.promise(promise, {
+ loading: "Loading data...",
+ success: "Data loaded successfully!",
+ error: "Failed to load data."
+ });
+ }}>
+ Promise toast
+ `
+ },
+ {
+ name: 'With options',
+ code: `
+ {
+ const promise = new Promise((resolve) => setTimeout(resolve, 2000));
+ toastManager.promise(promise, {
+ loading: { title: "Saving", description: "Please wait..." },
+ success: { title: "Saved", description: "Document saved.", type: "success" },
+ error: { title: "Failed", description: "Could not save document.", type: "error" }
+ });
+ }}>
+ Promise with options
+ `
+ }
+ ]
+};
+
+export const positionDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Top Left',
+ code: `
+ function ToastPreview() {
+ const manager = Toast.createToastManager();
+ return (
+
+
+ manager.add({ title: "Top left toast", type: "success" })}>
+ Trigger toast
+
+
+
+ )
+ }`
+ },
+ {
+ name: 'Top Center',
+ code: `
+ function ToastPreview() {
+ const manager = Toast.createToastManager();
+ return (
+
+
+ manager.add({ title: "Top center toast", type: "success" })}>
+ Trigger toast
+
+
+
+ )
+ }`
+ },
+ {
+ name: 'Top Right',
+ code: `
+ function ToastPreview() {
+ const manager = Toast.createToastManager();
+ return (
+
+
+ manager.add({ title: "Top right toast", type: "success" })}>
+ Trigger toast
+
+
+
+ )
+ }`
+ },
+ {
+ name: 'Bottom Left',
+ code: `
+ function ToastPreview() {
+ const manager = Toast.createToastManager();
+ return (
+
+
+ manager.add({ title: "Bottom left toast", type: "success" })}>
+ Trigger toast
+
+
+
+ )
+ }`
+ },
+ {
+ name: 'Bottom Center',
+ code: `
+ function ToastPreview() {
+ const manager = Toast.createToastManager();
+ return (
+
+
+ manager.add({ title: "Bottom center toast", type: "success" })}>
+ Trigger toast
+
+
+
+ )
+ }`
+ },
+ {
+ name: 'Bottom Right',
+ code: `
+ function ToastPreview() {
+ const manager = Toast.createToastManager();
+ return (
+
+
+ manager.add({ title: "Bottom right toast", type: "success" })}>
+ Trigger toast
+
+
+
+ )
+ }`
+ }
+ ]
+};
+
+export const updateDemo = {
+ type: 'code',
+ code: `
+ function UpdateToast() {
+ const idRef = React.useRef(null);
+ return (
+
+ {
+ idRef.current = toastManager.add({ title: "Processing...", type: "loading", timeout: 0 });
+ }}>
+ Start processing
+
+ {
+ if (idRef.current) {
+ toastManager.update(idRef.current, { title: "Done!", type: "success", timeout: 3000 });
+ idRef.current = null;
+ }
+ }}>
+ Mark as done
+
+ {
+ if (idRef.current) {
+ toastManager.close(idRef.current);
+ idRef.current = null;
+ }
+ }}>
+ Dismiss
+
+
+ )
+ }`
};
diff --git a/apps/www/src/content/docs/components/toast/index.mdx b/apps/www/src/content/docs/components/toast/index.mdx
index ae85c17ba..d53884bdf 100644
--- a/apps/www/src/content/docs/components/toast/index.mdx
+++ b/apps/www/src/content/docs/components/toast/index.mdx
@@ -1,55 +1,97 @@
---
title: Toast
-description: Toast wrapper and function. Toast internally uses Sonner
+description: Displays temporary notification messages using Base UI Toast primitives.
source: packages/raystack/components/toast
---
-import { preview, basicDemo, iconsDemo, disabledDemo } from "./demo.ts";
+import { preview, basicDemo, typesDemo, descriptionDemo, actionDemo, promiseDemo, positionDemo, updateDemo } from "./demo.ts";
## Anatomy
-Import and assemble the component:
+Import and set up the toast system:
```tsx
-import { ToastContainer, toast } from '@raystack/apsara'
+import { Toast, toastManager } from '@raystack/apsara'
-
+// Wrap your app with Toast.Provider (once, at root)
+
+
+
+
+// Trigger toasts from anywhere
+toastManager.add({ title: 'Hello!', type: 'success' })
```
## API Reference
-Renders a temporary notification message.
+### Toast.Provider
+
+Renders the toast viewport and manages the toast lifecycle. Place once at the root of your app.
+
+
+
+### toastManager.add(options)
+
+Creates a new toast and returns its unique ID. The `toastManager` can be imported and used anywhere — including outside React components (e.g., API interceptors, utility functions).
+
+
+
+### toastManager methods
-
+| Method | Signature | Description |
+|--------|-----------|-------------|
+| `add` | `(options) => string` | Create a toast, returns its ID |
+| `close` | `(id: string) => void` | Close a specific toast by ID |
+| `update` | `(id: string, options) => void` | Update an existing toast's properties |
+| `promise` | `(promise, { loading, success, error }) => Promise` | Create a toast that tracks a promise lifecycle |
## Examples
-### Basic Usage
+### Basic Toast
-```tsx
-import { ToastContainer, toast, Button } from '@raystack/apsara'
-
-
-
- toast.success("Data loaded successfully.", {
- dismissible: true,
- action: (
- console.log("Toast appears")}>
- Click Me
-
- ),
- })
- }>
- Trigger toast
-
-```
+
+
+### Type Variants
+
+Use the `type` prop to change the visual styling of the toast.
+
+
+
+### With Title and Description
+
+
+
+### With Action Button
+
+Use `actionProps` to render an action button inside the toast.
+
+
+
+### Promise Toast
+
+Track an async operation with automatic loading, success, and error states.
+
+
+
+### Positioning
+
+Control where toasts appear on screen with the `position` prop on `Toast.Provider`.
+
+
+
+### Close and Update
+
+Create a toast, then update or close it programmatically using the returned ID.
+
+
## Accessibility
-- Uses `role="alert"` for important notifications
-- Automatically announced to screen readers via `aria-live` region
-- Dismissible toasts support keyboard interaction
+- Uses `aria-live` regions for screen reader announcements
+- `priority: 'high'` uses `role="alert"` (assertive) for urgent notifications
+- `priority: 'low'` (default) uses `role="status"` (polite) for non-urgent notifications
+- Close button has `aria-label="Close toast"`
+- Supports keyboard navigation and Escape to dismiss
+- Swipe-to-dismiss gesture support
diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts
index 2c25d7e8f..14ed93021 100644
--- a/apps/www/src/content/docs/components/toast/props.ts
+++ b/apps/www/src/content/docs/components/toast/props.ts
@@ -1,15 +1,4 @@
-export interface ToastProps {
- /**
- * Text shown below the title.
- */
- description?: string;
-
- /**
- * Auto-close time in milliseconds.
- * @default 4000
- */
- duration?: number;
-
+export interface ToastProviderProps {
/**
* Toast position on screen.
* @default "bottom-right"
@@ -23,74 +12,70 @@ export interface ToastProps {
| 'bottom-center';
/**
- * Allow user to dismiss toast.
- * @default true
+ * Maximum number of visible toasts.
+ * @default 3
*/
- dismissible?: boolean;
+ limit?: number;
/**
- * Leading icon element.
+ * Default auto-dismiss time in milliseconds.
+ * @default 5000
*/
- icon?: React.ReactNode;
+ timeout?: number;
+
+ children?: React.ReactNode;
+}
+export interface ToastManagerAddOptions {
/**
- * Inverts the color scheme.
- * @default false
+ * The title of the toast.
*/
- invert?: boolean;
+ title?: React.ReactNode;
/**
- * Show close button.
- * @default false
+ * The description of the toast.
*/
- closeButton?: boolean;
+ description?: React.ReactNode;
/**
- * Remove default styling.
- * @default false
+ * The type of the toast. Controls visual styling.
*/
- unstyled?: boolean;
+ type?: 'success' | 'error' | 'info' | 'warning' | 'loading';
/**
- * Primary button configuration.
+ * Auto-dismiss time in milliseconds. 0 prevents auto-dismiss.
+ * @default 5000
*/
- action?: {
- label: string;
- onClick: () => void;
- };
+ timeout?: number;
/**
- * Secondary button configuration.
+ * Announcement priority for screen readers.
+ * @default "low"
*/
- cancel?: {
- label: string;
- onClick: () => void;
- };
+ priority?: 'low' | 'high';
/**
- * Styles for the primary button.
- * @default {}
+ * Callback when the toast is closed.
*/
- actionButtonStyle?: React.CSSProperties;
+ onClose?: () => void;
/**
- * Styles for the secondary button.
- * @default {}
+ * Callback when the toast is removed after closing animation completes.
*/
- cancelButtonStyle?: React.CSSProperties;
+ onRemove?: () => void;
/**
- * Called on manual dismiss.
+ * Props for the action button rendered in the toast.
*/
- onDismiss?: () => void;
+ actionProps?: React.ComponentPropsWithoutRef<'button'>;
/**
- * Called when the toast auto-closes.
+ * Custom data to attach to the toast.
*/
- onAutoClose?: () => void;
+ data?: Record;
/**
- * Custom toast ID.
+ * Optional custom ID for the toast. Auto-generated if not provided.
*/
id?: string;
}
diff --git a/packages/raystack/components/toast/__tests__/toast.test.tsx b/packages/raystack/components/toast/__tests__/toast.test.tsx
index 3585fcb18..fe49d1220 100644
--- a/packages/raystack/components/toast/__tests__/toast.test.tsx
+++ b/packages/raystack/components/toast/__tests__/toast.test.tsx
@@ -1,28 +1,49 @@
-import { render, screen, waitFor } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-import { ToastContainer, toast } from '../toast';
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { Toast, toastManager } from '../toast';
+
+const renderWithProvider = (
+ props?: Partial>
+) => {
+ return render(
+
+ App content
+
+ );
+};
describe('Toast', () => {
- beforeEach(() => {
- render();
+ describe('Toast.Provider', () => {
+ it('renders provider with children', () => {
+ renderWithProvider();
+ expect(screen.getByText('App content')).toBeInTheDocument();
+ });
});
- describe('ToastContainer', () => {
- it('renders ToastContainer component', () => {
- expect(screen.getByLabelText('Notifications alt+T')).toBeInTheDocument();
+ describe('toastManager.add()', () => {
+ beforeEach(() => {
+ renderWithProvider();
});
- });
- describe('Toast Function', () => {
- it('shows basic toast message', async () => {
- toast('Hello World');
+ it('shows basic toast with title', async () => {
+ act(() => {
+ toastManager.add({ title: 'Hello World' });
+ });
expect(await screen.findByText('Hello World')).toBeInTheDocument();
});
- it('shows JSX content in toast', async () => {
- const jsxContent = JSX Content
;
- toast(jsxContent);
- expect(await screen.findByTestId('jsx-content')).toBeInTheDocument();
+ it('shows toast with title and description', async () => {
+ act(() => {
+ toastManager.add({
+ title: 'Toast Title',
+ description: 'Toast description text'
+ });
+ });
+ expect(await screen.findByText('Toast Title')).toBeInTheDocument();
+ expect(
+ await screen.findByText('Toast description text')
+ ).toBeInTheDocument();
});
const toastTypes = [
@@ -32,32 +53,79 @@ describe('Toast', () => {
'info',
'loading'
] as const;
+
toastTypes.forEach(type => {
- it(`supports ${type} toast`, async () => {
- toast[type]('Success message');
- expect(await screen.findByText('Success message')).toBeInTheDocument();
+ it(`supports ${type} type`, async () => {
+ act(() => {
+ toastManager.add({ title: `${type} message`, type });
+ });
+ const toastEl = await screen.findByText(`${type} message`);
+ expect(toastEl).toBeInTheDocument();
+ expect(toastEl.closest(`[data-type="${type}"]`)).toBeInTheDocument();
});
});
+ });
+
+ describe('toastManager.close()', () => {
+ beforeEach(() => {
+ renderWithProvider();
+ });
+
+ it('closes a specific toast by id', async () => {
+ let id: string;
+ act(() => {
+ id = toastManager.add({ title: 'Dismissible toast' });
+ });
+ expect(await screen.findByText('Dismissible toast')).toBeInTheDocument();
- it('supports custom toast with options', async () => {
- const customOptions = {
- duration: 5000,
- description: 'Custom description'
- };
+ act(() => {
+ toastManager.close(id!);
+ });
- toast('Custom toast', customOptions);
- expect(await screen.findByText('Custom toast')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.queryByText('Dismissible toast')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('toastManager.update()', () => {
+ beforeEach(() => {
+ renderWithProvider();
});
- it('supports promise-based toast', async () => {
- const promise = new Promise(resolve => {
- setTimeout(() => resolve('Promise resolved'), 100);
+ it('updates an existing toast', async () => {
+ let id: string;
+ act(() => {
+ id = toastManager.add({ title: 'Original title' });
});
+ expect(await screen.findByText('Original title')).toBeInTheDocument();
- toast.promise(promise, {
- loading: 'Loading...',
- success: 'Success!',
- error: 'Error!'
+ act(() => {
+ toastManager.update(id!, { title: 'Updated title' });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Updated title')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('toastManager.promise()', () => {
+ beforeEach(() => {
+ renderWithProvider();
+ });
+
+ it('shows loading then success on resolution', async () => {
+ const promise = new Promise(resolve =>
+ setTimeout(() => resolve('ok'), 50)
+ );
+
+ act(() => {
+ toastManager.promise(promise, {
+ loading: 'Loading...',
+ success: 'Success!',
+ error: 'Error!'
+ });
});
expect(await screen.findByText('Loading...')).toBeInTheDocument();
@@ -70,116 +138,116 @@ describe('Toast', () => {
);
});
- it('supports dismiss functionality', async () => {
- const toastId = toast('Dismissible toast');
- expect(await screen.findByText('Dismissible toast')).toBeInTheDocument();
-
- toast.dismiss(toastId);
+ it('shows loading then error on rejection', async () => {
+ const promise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('fail')), 50)
+ );
- await waitFor(() => {
- expect(screen.queryByText('Dismissible toast')).not.toBeInTheDocument();
+ let result: Promise;
+ act(() => {
+ result = toastManager.promise(promise, {
+ loading: 'Loading...',
+ success: 'Success!',
+ error: 'Error!'
+ });
});
+
+ expect(await screen.findByText('Loading...')).toBeInTheDocument();
+
+ // Catch the rejection to prevent unhandled promise rejection
+ await result!.catch(() => undefined);
+
+ await waitFor(
+ () => {
+ expect(screen.getByText('Error!')).toBeInTheDocument();
+ },
+ { timeout: 200 }
+ );
});
+ });
- it('supports dismissAll functionality', async () => {
- toast('First toast');
- toast('Second toast');
+ describe('Toast close button', () => {
+ beforeEach(() => {
+ renderWithProvider();
+ });
- expect(await screen.findByText('First toast')).toBeInTheDocument();
- expect(await screen.findByText('Second toast')).toBeInTheDocument();
+ it('renders close button and dismisses toast on click', async () => {
+ const user = userEvent.setup();
- // Dismiss all toasts by calling dismiss without ID
- toast.dismiss();
+ act(() => {
+ toastManager.add({ title: 'Closable toast' });
+ });
+
+ const closeBtn = await screen.findByLabelText('Close toast');
+ expect(closeBtn).toBeInTheDocument();
+
+ await user.click(closeBtn);
await waitFor(() => {
- expect(screen.queryByText('First toast')).not.toBeInTheDocument();
- expect(screen.queryByText('Second toast')).not.toBeInTheDocument();
+ expect(screen.queryByText('Closable toast')).not.toBeInTheDocument();
});
});
+ });
- it('handles multiple toasts simultaneously', async () => {
- toast('First toast');
- toast('Second toast');
- toast('Third toast');
-
- expect(await screen.findByText('First toast')).toBeInTheDocument();
- expect(await screen.findByText('Second toast')).toBeInTheDocument();
- expect(await screen.findByText('Third toast')).toBeInTheDocument();
+ describe('Toast action button', () => {
+ beforeEach(() => {
+ renderWithProvider();
});
- it('supports custom action buttons', async () => {
- toast('Toast with action', {
- action: {
- label: 'Undo',
- onClick: () => console.log('Undo clicked')
- }
+ it('renders action button from actionProps', async () => {
+ const onClick = vi.fn();
+
+ act(() => {
+ toastManager.add({
+ title: 'With Action',
+ actionProps: { children: 'Undo', onClick }
+ });
});
- expect(await screen.findByText('Toast with action')).toBeInTheDocument();
expect(await screen.findByText('Undo')).toBeInTheDocument();
});
+ });
- it('supports custom duration', async () => {
- const shortDuration = 100;
- toast('Short duration toast', { duration: shortDuration });
-
- expect(
- await screen.findByText('Short duration toast')
- ).toBeInTheDocument();
-
- await waitFor(
- () => {
- expect(
- screen.queryByText('Short duration toast')
- ).not.toBeInTheDocument();
- },
- { timeout: 500 }
- );
+ describe('Multiple toasts', () => {
+ beforeEach(() => {
+ renderWithProvider();
});
- it('supports custom className', async () => {
- const customClass = 'custom-toast-class';
- toast('Custom class toast', { className: customClass });
+ it('shows multiple toasts simultaneously', async () => {
+ act(() => {
+ toastManager.add({ title: 'First toast' });
+ toastManager.add({ title: 'Second toast' });
+ toastManager.add({ title: 'Third toast' });
+ });
- expect(await screen.findByText('Custom class toast')).toBeInTheDocument();
+ expect(await screen.findByText('First toast')).toBeInTheDocument();
+ expect(await screen.findByText('Second toast')).toBeInTheDocument();
+ expect(await screen.findByText('Third toast')).toBeInTheDocument();
});
+ });
- it('supports custom style', async () => {
- const customStyle = { backgroundColor: 'red' };
- toast('Custom style toast', { style: customStyle });
-
- expect(await screen.findByText('Custom style toast')).toBeInTheDocument();
+ describe('onClose callback', () => {
+ beforeEach(() => {
+ renderWithProvider();
});
- it('supports onDismiss callback', async () => {
- const onDismiss = vi.fn();
- toast('Callback toast', { onDismiss });
+ it('fires onClose when toast is closed', async () => {
+ const onClose = vi.fn();
+ let id: string;
- expect(await screen.findByText('Callback toast')).toBeInTheDocument();
+ act(() => {
+ id = toastManager.add({ title: 'Callback toast', onClose });
+ });
- // Dismiss the toast
- toast.dismiss();
+ expect(await screen.findByText('Callback toast')).toBeInTheDocument();
- await waitFor(() => {
- expect(onDismiss).toHaveBeenCalled();
+ act(() => {
+ toastManager.close(id!);
});
- });
- it('supports onAutoClose callback', async () => {
- const onAutoClose = vi.fn();
- toast('Auto close toast', {
- duration: 100,
- onAutoClose
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
});
-
- expect(await screen.findByText('Auto close toast')).toBeInTheDocument();
-
- await waitFor(
- () => {
- expect(onAutoClose).toHaveBeenCalled();
- },
- { timeout: 200 }
- );
});
});
});
diff --git a/packages/raystack/components/toast/index.tsx b/packages/raystack/components/toast/index.tsx
index c79ef750e..e1dea40a3 100644
--- a/packages/raystack/components/toast/index.tsx
+++ b/packages/raystack/components/toast/index.tsx
@@ -1 +1,3 @@
-export { toast,ToastContainer } from "./toast";
+export { Toast, toastManager } from './toast';
+export type { ToastPosition, ToastProviderProps } from './toast-provider';
+export type { ToastRootProps } from './toast-root';
diff --git a/packages/raystack/components/toast/toast-manager.ts b/packages/raystack/components/toast/toast-manager.ts
new file mode 100644
index 000000000..42678f1a7
--- /dev/null
+++ b/packages/raystack/components/toast/toast-manager.ts
@@ -0,0 +1,5 @@
+'use client';
+
+import { Toast as ToastPrimitive } from '@base-ui/react';
+
+export const toastManager = ToastPrimitive.createToastManager();
diff --git a/packages/raystack/components/toast/toast-provider.tsx b/packages/raystack/components/toast/toast-provider.tsx
new file mode 100644
index 000000000..f5bc78c7b
--- /dev/null
+++ b/packages/raystack/components/toast/toast-provider.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { Toast as ToastPrimitive } from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import { type ElementRef, forwardRef } from 'react';
+import styles from './toast.module.css';
+import { toastManager } from './toast-manager';
+import { ToastRoot } from './toast-root';
+
+export type ToastPosition =
+ | 'top-left'
+ | 'top-center'
+ | 'top-right'
+ | 'bottom-left'
+ | 'bottom-center'
+ | 'bottom-right';
+
+export interface ToastProviderProps
+ extends Omit {
+ /**
+ * Position of the toast viewport on screen.
+ * @default "bottom-right"
+ */
+ position?: ToastPosition;
+}
+
+function ToastList({ position }: { position: ToastPosition }) {
+ const { toasts } = ToastPrimitive.useToastManager();
+ return toasts.map(toast => (
+
+ ));
+}
+
+export const ToastProvider = forwardRef<
+ ElementRef,
+ ToastProviderProps
+>(({ position = 'bottom-right', children, ...props }, ref) => {
+ return (
+
+ {children}
+
+
+
+
+
+
+ );
+});
+
+ToastProvider.displayName = 'Toast.Provider';
diff --git a/packages/raystack/components/toast/toast-root.tsx b/packages/raystack/components/toast/toast-root.tsx
new file mode 100644
index 000000000..d38536458
--- /dev/null
+++ b/packages/raystack/components/toast/toast-root.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { Toast as ToastPrimitive } from '@base-ui/react';
+import { Cross1Icon } from '@radix-ui/react-icons';
+import { cx } from 'class-variance-authority';
+import { type ElementRef, forwardRef } from 'react';
+import { Button } from '../button';
+import { Flex } from '../flex';
+import { IconButton } from '../icon-button';
+import styles from './toast.module.css';
+import type { ToastPosition } from './toast-provider';
+
+type SwipeDirection = 'up' | 'down' | 'left' | 'right';
+
+function getSwipeDirection(position: ToastPosition): SwipeDirection[] {
+ const verticalDirection: SwipeDirection = position.startsWith('top')
+ ? 'up'
+ : 'down';
+
+ if (position.includes('center')) {
+ return [verticalDirection];
+ }
+
+ if (position.includes('left')) {
+ return ['left', verticalDirection];
+ }
+
+ return ['right', verticalDirection];
+}
+
+export interface ToastRootProps
+ extends Omit {
+ toast: ToastPrimitive.Root.Props['toast'];
+ position?: ToastPosition;
+}
+
+export const ToastRoot = forwardRef<
+ ElementRef,
+ ToastRootProps
+>(({ toast, className, position = 'bottom-right', ...props }, ref) => {
+ const swipeDirection = getSwipeDirection(position);
+ const hasDescription = !!toast.description;
+
+ return (
+
+
+
+ {toast.title && (
+
+ {toast.title}
+
+ )}
+ {hasDescription && (
+
+ {toast.description}
+
+ )}
+
+
+ {toast.actionProps && (
+ }
+ />
+ )}
+ }
+ >
+
+
+
+
+
+ );
+});
+
+ToastRoot.displayName = 'Toast';
diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css
index 06bcc2e58..81d9e4f7e 100644
--- a/packages/raystack/components/toast/toast.module.css
+++ b/packages/raystack/components/toast/toast.module.css
@@ -1,3 +1,296 @@
-.toast-wrapper {
- margin-right: var(--rs-space-3);
+/* ===== Viewport ===== */
+.viewport {
+ --gap: 0.75rem;
+ position: fixed;
+ z-index: var(--rs-z-index-portal);
+ width: 360px;
+ max-width: calc(100vw - var(--rs-space-10));
+ outline: none;
+}
+
+/* Position variants */
+.viewport-top-left {
+ top: var(--rs-space-5);
+ left: var(--rs-space-5);
+}
+
+.viewport-top-center {
+ top: var(--rs-space-5);
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.viewport-top-right {
+ top: var(--rs-space-5);
+ right: var(--rs-space-5);
+}
+
+.viewport-bottom-left {
+ bottom: var(--rs-space-5);
+ left: var(--rs-space-5);
+}
+
+.viewport-bottom-center {
+ bottom: var(--rs-space-5);
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.viewport-bottom-right {
+ bottom: var(--rs-space-5);
+ right: var(--rs-space-5);
+}
+
+/* ===== Toast Root (stacking) ===== */
+.root {
+ --peek: 0.75rem;
+ --scale: calc(max(0, 1 - (var(--toast-index) * 0.1)));
+ --shrink: calc(1 - var(--scale));
+ --height: var(--toast-frontmost-height, var(--toast-height));
+
+ position: absolute;
+ box-sizing: border-box;
+ width: 100%;
+ height: var(--height);
+
+ background: var(--rs-color-background-base-primary);
+ color: var(--rs-color-foreground-base-primary);
+ border: 0.5px solid var(--rs-color-border-base-primary);
+ border-radius: var(--rs-radius-2);
+ box-shadow: var(--rs-shadow-lifted);
+ padding: var(--rs-space-3);
+ background-clip: padding-box;
+ overflow: clip;
+
+ user-select: none;
+ cursor: default;
+ z-index: calc(1000 - var(--toast-index));
+
+ transition:
+ transform 500ms cubic-bezier(0.22, 1, 0.36, 1),
+ opacity 500ms,
+ height 150ms;
+}
+
+/* ===== Vertical position: bottom ===== */
+.root[data-position*="bottom"] {
+ --offset-y: calc(
+ var(--toast-offset-y) *
+ -1 +
+ (var(--toast-index) * var(--gap) * -1) +
+ var(--toast-swipe-movement-y)
+ );
+
+ bottom: 0;
+ top: auto;
+ transform-origin: bottom center;
+
+ transform: translateX(var(--toast-swipe-movement-x))
+ translateY(
+ calc(
+ var(--toast-swipe-movement-y) -
+ (var(--toast-index) * var(--peek)) -
+ (var(--shrink) * var(--height))
+ )
+ )
+ scale(var(--scale));
+}
+
+/* ===== Vertical position: top ===== */
+.root[data-position*="top"] {
+ --offset-y: calc(
+ var(--toast-offset-y) +
+ (var(--toast-index) * var(--gap)) +
+ var(--toast-swipe-movement-y)
+ );
+
+ top: 0;
+ bottom: auto;
+ transform-origin: top center;
+
+ transform: translateX(var(--toast-swipe-movement-x))
+ translateY(
+ calc(
+ var(--toast-swipe-movement-y) +
+ (var(--toast-index) * var(--peek)) +
+ (var(--shrink) * var(--height))
+ )
+ )
+ scale(var(--scale));
+}
+
+/* ===== Horizontal alignment ===== */
+.root[data-position*="right"] {
+ right: 0;
+ left: auto;
+}
+
+.root[data-position*="left"] {
+ left: 0;
+ right: auto;
+}
+
+.root[data-position*="center"] {
+ left: 0;
+ right: 0;
+}
+
+/* ===== Expanded state (on hover) ===== */
+.root[data-expanded] {
+ transform: translateX(var(--toast-swipe-movement-x))
+ translateY(var(--offset-y));
+ height: var(--toast-height);
+}
+
+/* ===== Gap area for hover detection between toasts ===== */
+.root[data-position*="bottom"]::after {
+ content: "";
+ position: absolute;
+ bottom: 100%;
+ width: 100%;
+ left: 0;
+ height: calc(var(--gap) + 1px);
+}
+
+.root[data-position*="top"]::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ width: 100%;
+ left: 0;
+ height: calc(var(--gap) + 1px);
+}
+
+/* ===== Enter animations ===== */
+.root[data-position*="bottom"][data-starting-style] {
+ transform: translateY(calc(100% + var(--rs-space-5)));
+}
+
+.root[data-position*="top"][data-starting-style] {
+ transform: translateY(calc(-100% - var(--rs-space-5)));
+}
+
+/* ===== Limited state ===== */
+.root[data-limited] {
+ opacity: 0;
+}
+
+/* ===== Exit animations ===== */
+.root[data-ending-style] {
+ opacity: 0;
+}
+
+/* Default exit (no swipe) */
+.root[data-position*="bottom"][data-ending-style]:not([data-swipe-direction]) {
+ transform: translateY(calc(100% + var(--rs-space-5)));
+}
+
+.root[data-position*="top"][data-ending-style]:not([data-swipe-direction]) {
+ transform: translateY(calc(-100% - var(--rs-space-5)));
+}
+
+/* Swipe exit directions */
+.root[data-ending-style][data-swipe-direction="up"] {
+ transform: translateY(calc(var(--toast-swipe-movement-y) - 150%));
+}
+
+.root[data-ending-style][data-swipe-direction="down"] {
+ transform: translateY(calc(var(--toast-swipe-movement-y) + 150%));
+}
+
+.root[data-ending-style][data-swipe-direction="left"] {
+ transform: translateX(calc(var(--toast-swipe-movement-x) - 150%))
+ translateY(var(--offset-y));
+}
+
+.root[data-ending-style][data-swipe-direction="right"] {
+ transform: translateX(calc(var(--toast-swipe-movement-x) + 150%))
+ translateY(var(--offset-y));
+}
+
+/* ===== Type-based styling ===== */
+.root[data-type="success"] {
+ background: var(--rs-color-background-success-primary);
+ border-color: var(--rs-color-border-success-primary);
+ color: var(--rs-color-foreground-success-primary);
+}
+
+.root[data-type="error"] {
+ background: var(--rs-color-background-danger-primary);
+ border-color: var(--rs-color-border-danger-primary);
+ color: var(--rs-color-foreground-danger-primary);
+}
+
+.root[data-type="warning"] {
+ background: var(--rs-color-background-attention-primary);
+ border-color: var(--rs-color-border-attention-primary);
+ color: var(--rs-color-foreground-attention-primary);
+}
+
+.root[data-type="info"] {
+ background: var(--rs-color-background-accent-primary);
+ border-color: var(--rs-color-border-accent-primary);
+ color: var(--rs-color-foreground-accent-primary);
+}
+
+/* ===== Content (stacking visibility) ===== */
+.content {
+ display: flex;
+ align-items: start;
+ gap: var(--rs-space-5);
+ transition: opacity 250ms;
+}
+
+.content[data-behind] {
+ opacity: 0;
+}
+
+.content[data-expanded] {
+ opacity: 1;
+}
+
+/* ===== Text container (title + description) ===== */
+.textContainer {
+ flex: 1;
+ min-width: 0;
+}
+
+/* ===== Title ===== */
+.title {
+ color: inherit;
+ font-size: var(--rs-font-size-regular);
+ font-weight: var(--rs-font-weight-medium);
+ line-height: var(--rs-line-height-regular);
+ letter-spacing: var(--rs-letter-spacing-regular);
+ margin: 0;
+}
+
+/* ===== Description ===== */
+.description {
+ color: inherit;
+ font-size: var(--rs-font-size-small);
+ font-weight: var(--rs-font-weight-regular);
+ line-height: var(--rs-line-height-small);
+ letter-spacing: var(--rs-letter-spacing-small);
+ margin: 0;
+}
+
+/* Override description color for typed toasts */
+.root[data-type="success"] .description,
+.root[data-type="error"] .description,
+.root[data-type="warning"] .description,
+.root[data-type="info"] .description {
+ opacity: 0.85;
+}
+
+/* ===== Reduced motion ===== */
+@media (prefers-reduced-motion: reduce) {
+ .root {
+ transition: none;
+ }
+
+ .root[data-starting-style],
+ .root[data-ending-style] {
+ transform: none;
+ }
}
diff --git a/packages/raystack/components/toast/toast.tsx b/packages/raystack/components/toast/toast.tsx
index ff6c21851..ff22ccafa 100644
--- a/packages/raystack/components/toast/toast.tsx
+++ b/packages/raystack/components/toast/toast.tsx
@@ -1,47 +1,11 @@
-'use client';
+import { Toast as ToastPrimitive } from '@base-ui/react';
+import { ToastProvider } from './toast-provider';
+import { ToastRoot } from './toast-root';
-import { ReactNode } from 'react';
-import { toast as sonnerToast, Toaster, type ToasterProps } from 'sonner';
+export const Toast = Object.assign(ToastRoot, {
+ Provider: ToastProvider,
+ createToastManager: ToastPrimitive.createToastManager,
+ useToastManager: ToastPrimitive.useToastManager
+});
-import { useTheme } from '../theme-provider';
-import { UseThemeProps } from '../theme-provider/types';
-import styles from './toast.module.css';
-
-interface ToastContainerProps extends ToasterProps {}
-
-const ToastContainer = (props: ToastContainerProps) => {
- const { resolvedTheme } = useTheme();
-
- return (
-
- );
-};
-
-const toast: typeof sonnerToast = Object.assign(
- (message: string | ReactNode, options?: ToasterProps) => {
- sonnerToast(
- {message}
,
- options
- );
- },
- sonnerToast
-);
-
-(toast as typeof toast & { displayName: string }).displayName = 'toast';
-
-ToastContainer.displayName = 'ToastContainer';
-
-export { toast, ToastContainer };
+export { toastManager } from './toast-manager';
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index 03d3e7308..1be7dd578 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -66,5 +66,5 @@ export {
ThemeSwitcher,
useTheme
} from './components/theme-provider';
-export { ToastContainer, toast } from './components/toast';
+export { Toast, toastManager } from './components/toast';
export { Tooltip } from './components/tooltip';
diff --git a/packages/raystack/package.json b/packages/raystack/package.json
index 9548bd01a..16a86cff5 100644
--- a/packages/raystack/package.json
+++ b/packages/raystack/package.json
@@ -126,8 +126,7 @@
"dayjs": "^1.11.11",
"prism-react-renderer": "^2.4.1",
"radix-ui": "^1.4.2",
- "react-day-picker": "^9.6.7",
- "sonner": "^2.0.6"
+ "react-day-picker": "^9.6.7"
},
"peerDependencies": {
"@types/react": "^18 || ^19",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 261a5c9b4..445c87798 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -214,9 +214,6 @@ importers:
react-day-picker:
specifier: ^9.6.7
version: 9.6.7(react@19.2.1)
- sonner:
- specifier: ^2.0.6
- version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
devDependencies:
'@figma/code-connect':
specifier: ^1.3.5
@@ -8322,12 +8319,6 @@ packages:
react: ^18.0.0
react-dom: ^18.0.0
- sonner@2.0.7:
- resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
- peerDependencies:
- react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
- react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
-
source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}
@@ -11755,8 +11746,6 @@ snapshots:
'@parcel/types': 2.12.0(@parcel/core@2.12.0)
'@parcel/utils': 2.12.0
nullthrows: 1.1.1
- transitivePeerDependencies:
- - '@swc/helpers'
'@parcel/workers@2.9.2(@parcel/core@2.12.0)':
dependencies:
@@ -19433,11 +19422,6 @@ snapshots:
react: 18.3.1
react-dom: 19.2.1(react@18.3.1)
- sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
- dependencies:
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
-
source-map-js@1.2.0: {}
source-map-js@1.2.1: {}