feat(ui): add a Base UI toast component

Adds the toast primitive that replaces sonner, along with its design-sync card
mapping. Nothing consumes it yet; the call sites move over next.
This commit is contained in:
Amruth Pillai
2026-08-17 22:32:31 +02:00
parent ac062bbcbd
commit 170550ed59
4 changed files with 223 additions and 6 deletions
+34
View File
@@ -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(<Toaster />);
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(<Toaster />);
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(<Toaster />);
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();
});
});
+182
View File
@@ -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 <ToastPrimitive.Provider {...props} />;
}
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />;
}
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
return (
<ToastPrimitive.Viewport
data-slot="toast-viewport"
className={cn(
"pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
className,
)}
{...props}
/>
);
}
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
return (
<ToastPrimitive.Root
data-slot="toast"
className={cn(
"group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom select-none rounded-2xl border bg-popover text-popover-foreground shadow-lg outline-none will-change-transform focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
"h-(--height) [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))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
"data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
className,
)}
{...props}
/>
);
}
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
return (
<ToastPrimitive.Content
data-slot="toast-content"
className={cn(
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
className,
)}
{...props}
/>
);
}
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
return <ToastPrimitive.Title data-slot="toast-title" className={cn("font-medium text-sm", className)} {...props} />;
}
function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) {
return (
<ToastPrimitive.Description
data-slot="toast-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function ToastAction({
className,
render = <Button variant="outline" size="sm" />,
...props
}: ToastPrimitive.Action.Props) {
return (
<ToastPrimitive.Action data-slot="toast-action" render={render} className={cn("shrink-0", className)} {...props} />
);
}
function ToastClose({
className,
children,
render = <Button variant="ghost" size="icon-sm" />,
...props
}: ToastPrimitive.Close.Props) {
return (
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Close toast"
render={render}
className={cn(
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
className,
)}
{...props}
>
{children ?? <XIcon aria-hidden="true" />}
</ToastPrimitive.Close>
);
}
function ToastIcon({ type }: { type: string | undefined }) {
let icon: React.ReactNode = null;
if (type === "success") icon = <CheckCircleIcon aria-hidden="true" />;
if (type === "info") icon = <InfoIcon aria-hidden="true" />;
if (type === "warning") icon = <WarningIcon aria-hidden="true" />;
if (type === "error") icon = <XCircleIcon className="text-destructive" aria-hidden="true" />;
if (type === "loading") icon = <SpinnerIcon className="animate-spin" aria-hidden="true" />;
if (!icon) return null;
return (
<span data-slot="toast-icon" className="shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none">
{icon}
</span>
);
}
function ToastList() {
const { toasts } = ToastPrimitive.useToastManager();
return toasts.map((toastItem) => (
<Toast key={toastItem.id} toast={toastItem}>
<ToastContent>
<ToastIcon type={toastItem.type} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<ToastTitle />
<ToastDescription />
</div>
<ToastAction />
<ToastClose />
</ToastContent>
</Toast>
));
}
function Toaster({ children, toastManager = toast, ...props }: ToastPrimitive.Provider.Props) {
return (
<ToastProvider toastManager={toastManager} {...props}>
{children}
<ToastPortal>
<ToastViewport>
<ToastList />
</ToastViewport>
</ToastPortal>
</ToastProvider>
);
}
const createToastManager = ToastPrimitive.createToastManager;
const useToastManager = ToastPrimitive.useToastManager;
export {
createToastManager,
Toast,
ToastAction,
ToastClose,
ToastContent,
ToastDescription,
Toaster,
ToastPortal,
ToastProvider,
ToastTitle,
ToastViewport,
toast,
useToastManager,
};