diff --git a/.changeset/tough-experts-glow.md b/.changeset/tough-experts-glow.md
new file mode 100644
index 0000000000..5a10a1ef14
--- /dev/null
+++ b/.changeset/tough-experts-glow.md
@@ -0,0 +1,5 @@
+---
+"@cloudflare/kumo": minor
+---
+
+Added a configurable toast positioning that default to bottom-right with per-toast overrides
diff --git a/packages/kumo-docs-astro/src/components/demos/ToastDemo.tsx b/packages/kumo-docs-astro/src/components/demos/ToastDemo.tsx
index 0b6cd5f11a..43d1928658 100644
--- a/packages/kumo-docs-astro/src/components/demos/ToastDemo.tsx
+++ b/packages/kumo-docs-astro/src/components/demos/ToastDemo.tsx
@@ -304,3 +304,28 @@ export function ToastPromiseDemo() {
);
}
+
+function ToastPositionButton() {
+ const toastManager = useKumoToastManager();
+
+ return (
+
+ toastManager.add({
+ title: "Toast positioned",
+ description: "This toast uses the Toasty provider position.",
+ })
+ }
+ >
+ Show top-right toast
+
+ );
+}
+
+export function ToastPositionDemo() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/kumo-docs-astro/src/pages/components/toast.mdx b/packages/kumo-docs-astro/src/pages/components/toast.mdx
index ebf1a427d4..71a90ed185 100644
--- a/packages/kumo-docs-astro/src/pages/components/toast.mdx
+++ b/packages/kumo-docs-astro/src/pages/components/toast.mdx
@@ -14,6 +14,7 @@ import {
ToastBasicDemo,
ToastTitleOnlyDemo,
ToastDescriptionOnlyDemo,
+ ToastPositionDemo,
ToastSuccessDemo,
ToastMultipleDemo,
ToastErrorDemo,
@@ -160,6 +161,30 @@ description: "Your changes have been saved successfully."
+ Position
+
+ Use the `position` prop on `Toasty` to control where toast notifications appear by default. The default value is `bottom-right`.
+
+
+
+ toastManager.add({
+ title: "Toast positioned",
+ description: "This toast uses the Toasty provider position.",
+ })
+ }
+ >
+ Show top-right toast
+
+`}
+ >
+
+
+
+ Supported values are `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`, and `bottom-right`. Individual toasts can also override the provider default by passing `position` to `toastManager.add()`, so different toasts can render in different lanes at the same time.
+
+
Success Action
Toasts work well for confirming user actions.
@@ -280,6 +305,9 @@ error: (err) => ({ title: "Failed", description: err.message, variant: "error" }
Toasty
The provider component that wraps your app and manages the toast system.
+
+ Toasty also accepts a position prop to control the default toast viewport placement. Supported values are "top-left", "top-center", "top-right", "bottom-left", "bottom-center", and "bottom-right". The default is "bottom-right". Individual toasts can override that default with their own position value.
+
useKumoToastManager()
diff --git a/packages/kumo/src/components/toast/index.ts b/packages/kumo/src/components/toast/index.ts
index b7735126cd..add2a9c727 100644
--- a/packages/kumo/src/components/toast/index.ts
+++ b/packages/kumo/src/components/toast/index.ts
@@ -1,4 +1,9 @@
export { Toasty, ToastProvider } from "./toast";
export { Toast } from "@base-ui/react/toast";
export { useKumoToastManager, createKumoToastManager } from "./toast";
-export type { KumoToastOptions, KumoToastManagerAddOptions } from "./toast";
+export type {
+ KumoToastOptions,
+ KumoToastManagerAddOptions,
+ KumoToastPosition,
+ ToastyProps,
+} from "./toast";
diff --git a/packages/kumo/src/components/toast/toast.test.tsx b/packages/kumo/src/components/toast/toast.test.tsx
new file mode 100644
index 0000000000..1f145f02ff
--- /dev/null
+++ b/packages/kumo/src/components/toast/toast.test.tsx
@@ -0,0 +1,140 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Toasty, type KumoToastPosition, useKumoToastManager } from "./toast";
+
+const EXPECTED_VIEWPORT_ANCHOR_CLASSES: Record = {
+ "top-left": ["top-4", "left-4"],
+ "top-center": ["top-4", "left-4", "right-4"],
+ "top-right": ["top-4", "right-4"],
+ "bottom-left": ["bottom-4", "left-4"],
+ "bottom-center": ["bottom-4", "left-4", "right-4"],
+ "bottom-right": ["bottom-4", "right-4"],
+};
+
+function TriggerToastButton({
+ label,
+ title,
+ description,
+ position,
+}: {
+ label: string;
+ title: string;
+ description: string;
+ position?: KumoToastPosition;
+}) {
+ const toastManager = useKumoToastManager();
+
+ return (
+ toastManager.add({ title, description, position })}
+ >
+ {label}
+
+ );
+}
+
+function findViewport(position: KumoToastPosition) {
+ return document.querySelector(
+ `[data-kumo-toast-position="${position}"]`,
+ ) as HTMLElement | null;
+}
+
+describe("Toasty position", () => {
+ it("uses bottom-right by default", async () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Show toast" }));
+ await screen.findByText("Toast title");
+
+ const viewport = findViewport("bottom-right");
+ expect(viewport).toBeTruthy();
+ expect(viewport?.dataset.kumoToastViewport).toBe("true");
+ expect(viewport?.className).toContain("bottom-4");
+ expect(viewport?.className).toContain("right-4");
+ });
+
+ it.each(
+ Object.entries(EXPECTED_VIEWPORT_ANCHOR_CLASSES) as Array<
+ [KumoToastPosition, string[]]
+ >,
+ )("uses provider position %s", async (position, expectedClasses) => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Show positioned toast" }),
+ );
+ await screen.findByText(`Toast in ${position}`);
+
+ const viewport = findViewport(position);
+ expect(viewport).toBeTruthy();
+
+ for (const className of expectedClasses) {
+ expect(viewport?.className).toContain(className);
+ }
+ });
+
+ it("allows per-toast position override", async () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Show top-left toast" }));
+ await screen.findByText("Override position toast");
+
+ expect(findViewport("top-left")).toBeTruthy();
+ expect(findViewport("bottom-right")).toBeNull();
+ });
+
+ it("renders toasts in multiple lanes", async () => {
+ render(
+
+
+
+ ,
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Show default lane toast" }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Show top lane toast" }));
+
+ await screen.findByText("Default lane toast");
+ await screen.findByText("Top lane toast");
+
+ expect(findViewport("bottom-right")).toBeTruthy();
+ expect(findViewport("top-left")).toBeTruthy();
+ });
+});
diff --git a/packages/kumo/src/components/toast/toast.tsx b/packages/kumo/src/components/toast/toast.tsx
index cf4c1d9609..c4736ed2ae 100644
--- a/packages/kumo/src/components/toast/toast.tsx
+++ b/packages/kumo/src/components/toast/toast.tsx
@@ -96,6 +96,74 @@ export const KUMO_TOAST_STYLING = {
// Derived types from KUMO_TOAST_VARIANTS
export type KumoToastVariant = keyof typeof KUMO_TOAST_VARIANTS.variant;
+export type KumoToastPosition =
+ | "top-left"
+ | "top-center"
+ | "top-right"
+ | "bottom-left"
+ | "bottom-center"
+ | "bottom-right";
+
+const KUMO_TOAST_POSITIONS = [
+ "top-left",
+ "top-center",
+ "top-right",
+ "bottom-left",
+ "bottom-center",
+ "bottom-right",
+] as const satisfies ReadonlyArray;
+
+const TOAST_VIEWPORT_BASE_CLASSES =
+ "fixed z-1 flex w-[calc(100%-2rem)] sm:w-[340px]";
+
+const TOAST_VIEWPORT_POSITION_CLASSES: Record = {
+ "top-left": "top-4 left-4 sm:top-8 sm:left-8",
+ "top-center":
+ "top-4 right-4 left-4 mx-auto sm:top-8 sm:right-auto sm:left-1/2 sm:-translate-x-1/2",
+ "top-right": "top-4 right-4 sm:top-8 sm:right-8",
+ "bottom-left": "bottom-4 left-4 sm:bottom-8 sm:left-8",
+ "bottom-center":
+ "right-4 bottom-4 left-4 mx-auto sm:right-auto sm:bottom-8 sm:left-1/2 sm:-translate-x-1/2",
+ "bottom-right": "right-4 bottom-4 sm:right-8 sm:bottom-8",
+};
+
+const TOAST_ROOT_POSITION_CLASSES: Record = {
+ "top-left": "left-0 right-auto top-0 bottom-auto origin-top",
+ "top-center": "left-0 right-0 top-0 bottom-auto mx-auto origin-top",
+ "top-right": "right-0 left-auto top-0 bottom-auto origin-top",
+ "bottom-left": "left-0 right-auto bottom-0 top-auto origin-bottom",
+ "bottom-center": "left-0 right-0 bottom-0 top-auto mx-auto origin-bottom",
+ "bottom-right": "right-0 left-auto bottom-0 top-auto origin-bottom",
+};
+
+type ToastVerticalEdge = "top" | "bottom";
+type AnyKumoToast = KumoToastOptions;
+type ToastLanes = Record>;
+
+function getToastVerticalEdge(position: KumoToastPosition): ToastVerticalEdge {
+ return position.startsWith("top") ? "top" : "bottom";
+}
+
+function groupToastsByPosition(
+ toasts: Array,
+ defaultPosition: KumoToastPosition,
+): ToastLanes {
+ const lanes: ToastLanes = {
+ "top-left": [],
+ "top-center": [],
+ "top-right": [],
+ "bottom-left": [],
+ "bottom-center": [],
+ "bottom-right": [],
+ };
+
+ for (const toast of toasts) {
+ const resolvedPosition = toast.position ?? defaultPosition;
+ lanes[resolvedPosition].push(toast);
+ }
+
+ return lanes;
+}
export interface KumoToastVariantsProps {
variant?: KumoToastVariant;
@@ -133,10 +201,14 @@ export function toastVariants({
export interface ToastyProps extends KumoToastVariantsProps {
/** Application content. Toasts render via a portal above this. */
children: React.ReactNode;
+ /** Default position used for toasts unless overridden per toast. @default "bottom-right" */
+ position?: KumoToastPosition;
}
type KumoToastOptionsBase = {
variant?: KumoToastVariant;
+ /** Optional per-toast placement override. */
+ position?: KumoToastPosition;
content?: React.ReactNode;
actions?: Array;
bump?: boolean;
@@ -250,7 +322,7 @@ export const createKumoToastManager = () => {
/**
* Toasty — toast notification provider and viewport.
*
- * Renders a `Toast.Provider` with a fixed-position viewport in the bottom-right corner.
+ * Renders a `Toast.Provider` with fixed-position viewport lanes.
* Toasts stack with smooth enter/exit animations, swipe-to-dismiss, and expand-on-hover.
*
* Built on `@base-ui/react/toast`.
@@ -262,14 +334,12 @@ export const createKumoToastManager = () => {
*
* ```
*/
-export function Toasty({ children }: ToastyProps) {
+export function Toasty({ children, position = "bottom-right" }: ToastyProps) {
return (
{children}
-
-
-
+
);
@@ -278,24 +348,78 @@ export function Toasty({ children }: ToastyProps) {
/** Alias for Toasty — provided for discoverability when migrating from other libraries */
export const ToastProvider = Toasty;
-function ToastList() {
+function ToastViewports({
+ defaultPosition,
+}: {
+ defaultPosition: KumoToastPosition;
+}) {
const { toasts } = useKumoToastManager();
+ const lanes = groupToastsByPosition(toasts, defaultPosition);
+
+ return KUMO_TOAST_POSITIONS.map((position) => {
+ return (
+
+ );
+ });
+}
+
+function ToastViewportLane({
+ position,
+ toasts,
+}: {
+ position: KumoToastPosition;
+ toasts: Array;
+}) {
+ if (toasts.length === 0) return null;
+
+ return (
+
+
+
+ );
+}
+
+function ToastList({
+ position,
+ toasts,
+}: {
+ position: KumoToastPosition;
+ toasts: Array;
+}) {
+ const verticalEdge = getToastVerticalEdge(position);
+
return toasts.map((toast) => (