"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { IconArrowRight } from "@tabler/icons-react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Toast, ToastProvider, ToastViewport, type ToastPlacement, type ToastVariant } from "@/components/ui/toast";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const Button = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost";
leftIcon?: React.ReactNode;
}
>(function Button({ variant = "primary", leftIcon, className, children, ...props }, ref) {
return (
<button
ref={ref}
type="button"
className={cn(
"inline-flex h-9 items-center justify-center gap-2 rounded-[10px] px-4 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] [&_svg]:size-4",
variant === "primary" && "bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
variant === "secondary" && "border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] hover:bg-[var(--muted)]",
variant === "ghost" && "text-[var(--foreground)] hover:bg-[var(--muted)]",
className,
)}
{...props}
>
{leftIcon}
{children}
</button>
);
});
const primitivePreviewCardSurfaceFrameClass = cn(
"relative isolate overflow-hidden rounded-2xl",
"border",
);
const primitivePreviewCardSurfaceLightClass = cn(
"border-black/[0.08] bg-[#f4f4f5]",
"[box-shadow:0_1px_2px_rgba(0,0,0,.06),0_8px_24px_-12px_rgba(0,0,0,.08)]",
);
const primitivePreviewCardSurfaceClass = cn(
primitivePreviewCardSurfaceFrameClass,
primitivePreviewCardSurfaceLightClass,
// Full static `dark:bg-[#0a0a0b]` kept for Tailwind content scan - same as
// PRIMITIVE_PREVIEW_CANVAS_DARK_BG / PRIMITIVE_PREVIEW_CANVAS_DARK.
"dark:border-white/[0.06] dark:bg-[#0a0a0b] dark:[box-shadow:0_1px_2px_rgba(0,0,0,.4),0_8px_24px_-12px_rgba(0,0,0,.6)]",
);
const cardSurface = primitivePreviewCardSurfaceClass;
function PreviewPanel({
title,
subtitle,
children,
className,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn(cardSurface, "w-full max-w-lg p-5", className)}>
<div className="mb-4 border-b border-[var(--border)] pb-3">
<p className="text-sm font-semibold tracking-[-0.015em] text-[var(--foreground)]">
{title}
</p>
{subtitle ? (
<p className="mt-1 text-[12px] text-[var(--muted-foreground)]">{subtitle}</p>
) : null}
</div>
{children}
</div>
);
}
type ToastDemoCopy = {
variant: ToastVariant;
title: string;
description: string;
actionLabel?: string;
};
type ActiveToast = ToastDemoCopy & {
id: number;
placement: ToastPlacement;
};
const previewPlacements: ToastPlacement[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
"top-center",
];
let toastId = 0;
let activeToasts: ActiveToast[] = [];
const toastListeners = new Set<() => void>();
const toastTimers = new Map<number, number>();
let hostId = 0;
let primaryHostId: number | null = null;
const mountedHostIds: number[] = [];
const hostListeners = new Set<() => void>();
function emitToastChange() {
toastListeners.forEach((listener) => listener());
}
function getToastSnapshot() {
return activeToasts;
}
function subscribeToastStore(listener: () => void) {
toastListeners.add(listener);
return () => {
toastListeners.delete(listener);
};
}
function dismissPreviewToast(id: number) {
const timer = toastTimers.get(id);
if (timer) {
window.clearTimeout(timer);
toastTimers.delete(id);
}
activeToasts = activeToasts.filter((toast) => toast.id !== id);
emitToastChange();
}
function pushPreviewToast(
toast: ToastDemoCopy,
placement: ToastPlacement = "top-right",
persist = false,
) {
const id = ++toastId;
const nextToast = { ...toast, id, placement };
const placementCounts = new Map<ToastPlacement, number>();
const nextToasts = [nextToast, ...activeToasts].filter((item) => {
const count = placementCounts.get(item.placement) ?? 0;
if (count >= 4) return false;
placementCounts.set(item.placement, count + 1);
return true;
});
const nextToastIds = new Set(nextToasts.map((item) => item.id));
activeToasts.forEach((item) => {
if (!nextToastIds.has(item.id)) {
const timer = toastTimers.get(item.id);
if (timer) {
window.clearTimeout(timer);
toastTimers.delete(item.id);
}
}
});
activeToasts = nextToasts;
emitToastChange();
if (persist) return;
const timer = window.setTimeout(() => {
dismissPreviewToast(id);
}, 4600);
toastTimers.set(id, timer);
}
function emitHostChange() {
hostListeners.forEach((listener) => listener());
}
function syncPrimaryHost() {
const nextPrimaryHostId =
primaryHostId && mountedHostIds.includes(primaryHostId)
? primaryHostId
: (mountedHostIds[0] ?? null);
if (primaryHostId !== nextPrimaryHostId) {
primaryHostId = nextPrimaryHostId;
emitHostChange();
}
}
function usePrimaryToastHost() {
const [id] = React.useState(() => ++hostId);
const [isPrimary, setIsPrimary] = React.useState(false);
React.useEffect(() => {
const update = () => setIsPrimary(primaryHostId === id);
mountedHostIds.push(id);
hostListeners.add(update);
syncPrimaryHost();
update();
return () => {
const index = mountedHostIds.indexOf(id);
if (index !== -1) {
mountedHostIds.splice(index, 1);
}
hostListeners.delete(update);
syncPrimaryHost();
};
}, [id]);
return isPrimary;
}
function ToastDemoStackHost() {
const isPrimary = usePrimaryToastHost();
const shouldReduceMotion = useReducedMotion();
const [portalRoot, setPortalRoot] = React.useState<HTMLElement | null>(null);
const toasts = React.useSyncExternalStore(
subscribeToastStore,
getToastSnapshot,
getToastSnapshot,
);
React.useEffect(() => {
setPortalRoot(document.body);
}, []);
if (!isPrimary || !portalRoot) return null;
return createPortal(
<ToastProvider>
{previewPlacements.map((placement) => (
<ToastPlacementStack
key={placement}
placement={placement}
shouldReduceMotion={shouldReduceMotion}
toasts={toasts.filter((toast) => toast.placement === placement)}
/>
))}
</ToastProvider>,
portalRoot,
);
}
function ToastPlacementStack({
placement,
shouldReduceMotion,
toasts,
}: {
placement: ToastPlacement;
shouldReduceMotion: boolean | null;
toasts: ActiveToast[];
}) {
const [expanded, setExpanded] = React.useState(false);
const isBottom = placement.startsWith("bottom");
const isLeft = placement.endsWith("left");
const origin = `${isBottom ? "bottom" : "top"} ${isLeft ? "left" : "right"}`;
if (toasts.length === 0) return null;
const collapsedHeight = 72 + Math.max(0, toasts.length - 1) * 12;
const expandedHeight = 72 + Math.max(0, toasts.length - 1) * 82;
const stackHeight = expanded || shouldReduceMotion ? expandedHeight : collapsedHeight;
return (
<ToastViewport
placement={placement}
className={cn("z-[9999] block h-[360px]", isBottom ? "bottom-6" : "top-24")}
>
<motion.div
animate={{ height: stackHeight }}
transition={{
type: "spring",
stiffness: shouldReduceMotion ? 1000 : 520,
damping: shouldReduceMotion ? 100 : 38,
}}
className={cn("pointer-events-auto absolute inset-x-0 w-full", isBottom ? "bottom-0" : "top-0")}
onHoverStart={() => setExpanded(true)}
onHoverEnd={() => setExpanded(false)}
onFocusCapture={() => setExpanded(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setExpanded(false);
}
}}
>
<AnimatePresence initial={false}>
{toasts.map((toast, index) => {
const direction = isBottom ? -1 : 1;
const collapsedY = direction * index * 12;
const expandedY = direction * index * 82;
const collapsedScale = Math.max(0.9, 1 - index * 0.035);
const isExpanded = expanded || shouldReduceMotion;
return (
<motion.div
key={toast.id}
initial={{
opacity: 0,
y: shouldReduceMotion ? 0 : -12,
scale: 0.98,
}}
animate={{
opacity: isExpanded ? 1 : Math.max(0.72, 1 - index * 0.1),
y: isExpanded ? expandedY : collapsedY,
scale: isExpanded ? 1 : collapsedScale,
}}
exit={{
opacity: 0,
y: shouldReduceMotion ? 0 : -8,
scale: 0.98,
}}
transition={{
opacity: { duration: shouldReduceMotion ? 0 : 0.16 },
y: {
type: "spring",
stiffness: shouldReduceMotion ? 1000 : 520,
damping: shouldReduceMotion ? 100 : 38,
},
scale: { duration: shouldReduceMotion ? 0 : 0.18, ease: [0.22, 1, 0.36, 1] },
}}
style={{
zIndex: toasts.length - index,
transformOrigin: origin,
}}
className={cn("absolute inset-x-0 w-full", isBottom ? "bottom-0" : "top-0")}
>
<Toast
variant={toast.variant}
title={toast.title}
description={toast.description}
action={
toast.actionLabel ? (
<Button
type="button"
size="sm"
variant="secondary"
rightIcon={<IconArrowRight />}
rightIconMotion="arrow"
>
{toast.actionLabel}
</Button>
) : null
}
/>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
</ToastViewport>
);
}
function ToastTrigger({
buttonLabel,
toast,
placement = "top-right",
}: {
buttonLabel: string;
toast: ToastDemoCopy;
placement?: ToastPlacement;
}) {
// Show one toast on mount so the preview is never empty.
React.useEffect(() => {
const timer = window.setTimeout(() => pushPreviewToast(toast, placement, true), 400);
return () => window.clearTimeout(timer);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
<ToastDemoStackHost />
<Button
type="button"
variant="secondary"
rightIcon={<IconArrowRight />}
rightIconMotion="arrow"
onClick={() => pushPreviewToast(toast, placement)}
>
{buttonLabel}
</Button>
</>
);
}
export function ToastPreview() {
return (
<PreviewPanel title="Notifications" subtitle="Transient status cards with smooth stacked delivery.">
<div className="flex flex-wrap items-center gap-2.5">
<ToastTrigger
buttonLabel="Show toast"
toast={{
variant: "success",
title: "Template archive ready",
description: "The buyer ZIP was generated and uploaded.",
}}
/>
</div>
</PreviewPanel>
);
}
export default function Demo() {
return <ToastPreview />;
}