diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md
index 62c2ce03b..d83ab88d7 100644
--- a/.design-sync/NOTES.md
+++ b/.design-sync/NOTES.md
@@ -14,7 +14,7 @@ Syncs to Claude Design project **Reactive Resume** (`3c0f6556-050a-41e5-9886-c3f
## Card scope
- The package exports **202 symbols** (39 primary components + 163 compound sub-parts). User chose **~40 primary cards**: `cfg.componentSrcMap` nulls the 163 sub-parts. All 202 stay importable from `window.RRUI` (the bundle exports everything regardless of the card list), so previews compose sub-parts (`RRUI.DialogContent`, etc.) freely.
-- Multi-primary files represented by one card: `combobox.tsx`→ComboboxRoot, `form.tsx`→FormItem, `resizable.tsx`→ResizableGroup, `sonner.tsx`→Toaster.
+- Multi-primary files represented by one card: `combobox.tsx`→ComboboxRoot, `form.tsx`→FormItem, `resizable.tsx`→ResizableGroup, `toast.tsx`→Toaster.
## Preview authoring conventions (calibrated on Button / Alert / Dialog)
diff --git a/.design-sync/previews/Toaster.tsx b/.design-sync/previews/Toaster.tsx
index 8d26aaee4..528013762 100644
--- a/.design-sync/previews/Toaster.tsx
+++ b/.design-sync/previews/Toaster.tsx
@@ -1,19 +1,20 @@
import { useEffect } from "react";
-import { toast } from "sonner";
-import { Toaster } from "@reactive-resume/ui/components/sonner";
+import { Toaster, toast } from "@reactive-resume/ui/components/toast";
// Toaster is the toast host. Fire a persistent toast on mount so the card
// shows a real notification instead of an empty portal.
export const Notification = () => {
useEffect(() => {
- toast.success("Resume published", {
+ toast.add({
+ type: "success",
+ title: "Resume published",
description: "“Software Engineer” is now live at rxresume.me/jane-doe.",
- duration: Number.POSITIVE_INFINITY,
+ timeout: 0,
});
}, []);
return (
-
+
);
};
diff --git a/packages/ui/src/components/toast.test.tsx b/packages/ui/src/components/toast.test.tsx
new file mode 100644
index 000000000..7747eb146
--- /dev/null
+++ b/packages/ui/src/components/toast.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Toaster, toast } from "./toast";
+
+describe("Toaster", () => {
+ it("renders a toast added through the manager", async () => {
+ render();
+
+ toast.add({ type: "success", title: "Resume published", description: "It is now live." });
+
+ await waitFor(() => expect(screen.getByText("Resume published")).toBeInTheDocument());
+ expect(screen.getByText("It is now live.")).toBeInTheDocument();
+ });
+
+ it("removes a toast when closed by id", async () => {
+ render();
+
+ const id = toast.add({ description: "Temporary", timeout: 0 });
+ await waitFor(() => expect(screen.getByText("Temporary")).toBeInTheDocument());
+
+ toast.close(id);
+ await waitFor(() => expect(screen.queryByText("Temporary")).not.toBeInTheDocument());
+ });
+
+ it("upserts a toast reusing the same id", async () => {
+ render();
+
+ const id = toast.add({ id: "upsert", description: "Saving…", timeout: 0 });
+ toast.add({ id, type: "success", description: "Saved." });
+
+ await waitFor(() => expect(screen.getByText("Saved.")).toBeInTheDocument());
+ expect(screen.queryByText("Saving…")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx
new file mode 100644
index 000000000..12e92ec56
--- /dev/null
+++ b/packages/ui/src/components/toast.tsx
@@ -0,0 +1,182 @@
+import type * as React from "react";
+import { Toast as ToastPrimitive } from "@base-ui/react/toast";
+import { CheckCircleIcon, InfoIcon, SpinnerIcon, WarningIcon, XCircleIcon, XIcon } from "@phosphor-icons/react";
+import { cn } from "@reactive-resume/utils/style";
+import { Button } from "./button";
+
+const toast = ToastPrimitive.createToastManager();
+
+function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
+ return ;
+}
+
+function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
+ return ;
+}
+
+function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
+ return (
+
+ );
+}
+
+function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
+ return (
+
+ );
+}
+
+function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
+ return (
+
+ );
+}
+
+function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
+ return ;
+}
+
+function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) {
+ return (
+
+ );
+}
+
+function ToastAction({
+ className,
+ render = ,
+ ...props
+}: ToastPrimitive.Action.Props) {
+ return (
+
+ );
+}
+
+function ToastClose({
+ className,
+ children,
+ render = ,
+ ...props
+}: ToastPrimitive.Close.Props) {
+ return (
+
+ {children ?? }
+
+ );
+}
+
+function ToastIcon({ type }: { type: string | undefined }) {
+ let icon: React.ReactNode = null;
+
+ if (type === "success") icon = ;
+ if (type === "info") icon = ;
+ if (type === "warning") icon = ;
+ if (type === "error") icon = ;
+ if (type === "loading") icon = ;
+
+ if (!icon) return null;
+
+ return (
+
+ {icon}
+
+ );
+}
+
+function ToastList() {
+ const { toasts } = ToastPrimitive.useToastManager();
+
+ return toasts.map((toastItem) => (
+
+
+
+
+
+
+
+
+
+
+
+ ));
+}
+
+function Toaster({ children, toastManager = toast, ...props }: ToastPrimitive.Provider.Props) {
+ return (
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+const createToastManager = ToastPrimitive.createToastManager;
+const useToastManager = ToastPrimitive.useToastManager;
+
+export {
+ createToastManager,
+ Toast,
+ ToastAction,
+ ToastClose,
+ ToastContent,
+ ToastDescription,
+ Toaster,
+ ToastPortal,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+ toast,
+ useToastManager,
+};