# Toast

An accessible notification system with a compact pill, connected detail morph, queued stack, and imperative async API.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
"use client";

import { ToastProvider, Toaster, toast } from "@/components/ui/toast";

// Mount the provider/toaster once above routes in a real application.
export function ToastExample() {
  return (
    <ToastProvider>
      <Toaster />
      <button type="button" onClick={() => { toast.success("Changes saved", { description: "Your project settings are up to date." }); }}>Show notification</button>
    </ToastProvider>
  );
}
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/toast.json
```

The default installation target is components/ui/toast.tsx. Review the CLI's overwrite prompt before replacing local customizations.

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install @base-ui/react@^1.8.0 cn lucide-react motion@^12.38.0
```

## Usage [#usage]

Install the component and mount one provider and toaster above route-owned content. The provider survives client-side navigation; a full reload clears notifications.

```tsx
"use client";

import { ToastProvider, Toaster } from "@/components/ui/toast";

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <ToastProvider>
      {children}
      <Toaster />
    </ToastProvider>
  );
}
```

Trigger a notification from any client event handler:

```tsx
import { toast } from "@/components/ui/toast";

toast.success("Changes saved");
toast.error("Could not save", { description: "Try again in a moment." });
```

The imperative API also includes `toast.warning`, `toast.info`, `toast.loading`, `toast.action`, `toast.promise`, `toast.update(id, options)`, and `toast.dismiss(id)` / `toast.close(id)`. New toast calls return an ID. All non-loading statuses expire after 3 seconds for a title or 4 seconds with detail/action. Loading waits until updated or dismissed; `duration: 0` explicitly opts into persistence.

## Examples [#examples]

### Compact and detailed [#compact-and-detailed]

Title-only notifications remain pills. A description opens a connected body that stays open until dismissed.
On first arrival, the pill/title appears first, then the body opens after a brief 220ms lead with a soft fade. Actions reveal with the description. Replacements inside an existing shell do not repeat this entrance; reduced motion shows details immediately.

<ComponentPreview name="toast-demo" title="toast-demo.tsx">
  <ToastDemo />
</ComponentPreview>

### Action [#action]

Actions are separate keyboard-accessible controls, do not nest inside close, and default to 4 seconds. Hover/focus pauses expiry while they are used.

<ComponentPreview name="toast-action-demo" title="toast-action-demo.tsx">
  <ToastActionDemo />
</ComponentPreview>

### Async result in place [#async-result-in-place]

`toast.promise` returns a new promise that preserves the operation's result/rejection and updates the loading notification in place. Handle rejections with `try/catch` or `.catch()`. Explicit dismissal prevents later promise settlement from recreating it.

<ComponentPreview name="toast-promise-demo" title="toast-promise-demo.tsx">
  <ToastPromiseDemo />
</ComponentPreview>

### Stack, placement, and overlays [#stack-placement-and-overlays]

Only one notification is presented at a time. Older notifications wait without running timers, including async results. The stack icon/count in the free header area indicates waiting messages; its adjacent close-all button dismisses the entire queue. New arrivals and dismissal with waiting messages replace content immediately inside the existing silhouette. The shell, close button and queue control nodes stay mounted. Hover and focus pause expiry without expanding the layout. No unseen message expires; a notification receives its full duration when presented again.

First arrival enters the whole shell; only the final dismissal exits it. Content replacements crossfade and the silhouette morphs in place. New arrivals use a small content shift and one badge pulse; advancing after dismissal has no pulse. With waiting messages, swipe advances content without moving the shell. The last toast follows the swipe direction.

Dragging the last notification moves its entire surface, content and controls together. Center placements keep the pill and queue controls in one compact centered group, rather than pinning the controls to opposite viewport edges.

A thin countdown line sits at the bottom of the pill or body, inset from the corners. It shrinks toward the placement side, or symmetrically for center placement. Hover/focus, dragging and window blur pause it alongside Base UI's timer. Loading and `duration: 0` have no line. A newly presented notification or explicit content update resets progress. Reduced motion disables transforms used for transitions; the informational countdown remains.

The viewport supports `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`, and `bottom-right`. Right/left placement mirrors the stepped shoulder; bottom placement opens upward. The default top-right placement moves to top-center below the existing `sm` breakpoint. Oversized descriptions remain scrollable within the active body using touch, wheel or keyboard, without visible scrollbars or horizontal overflow.

<ComponentPreview name="toast-stack-demo" title="toast-stack-demo.tsx">
  <ToastStackDemo />
</ComponentPreview>

### Client-side navigation [#client-side-navigation]

This link starts an operation and navigates while it is pending. The root provider remains mounted; the same toast resolves on the next page. This example uses Next.js Link; use your router's client-side link in other frameworks.

<ComponentPreview name="toast-navigation-demo" title="toast-navigation-demo.tsx">
  <ToastNavigationDemo />
</ComponentPreview>

### Dialog and Drawer [#dialog-and-drawer]

The root provider portals notifications into Vandor's shared overlay host so they can layer above Vandor Dialog and Drawer without disabling modal focus containment. This integration requires the matching current Vandor registry Dialog/Drawer sources; if you already installed those components before this Toast release, update them from the registry so they add the shared modal toast host. Third-party modal systems, native dialog top layers, and browser popovers are not covered by this guarantee. Keep the provider mounted across route changes.

Create a notification before opening either modal, or trigger one inside. Both examples keep closed portals mounted to exercise inactive-host handling.

<ComponentPreview name="toast-overlay-demo" title="toast-overlay-demo.tsx">
  <ToastOverlayDemo />
</ComponentPreview>

### Optional TanStack Query v5 adapter [#optional-tanstack-query-v5-adapter]

Install the adapter separately with `npx shadcn@latest add https://vandor-ui.vercel.app/r/toast-tanstack-query.json` after installing Toast and `@tanstack/react-query`. Mount it beside the root toaster under the long-lived `QueryClientProvider`:

```tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ToastQueryAdapter } from "@/components/ui/toast-tanstack-query";
import { ToastProvider, Toaster } from "@/components/ui/toast";

const queryClient = new QueryClient();

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <ToastProvider>
        {children}
        <Toaster />
        <ToastQueryAdapter />
      </ToastProvider>
    </QueryClientProvider>
  );
}
```

Opt in per operation through metadata; query notifications only fire after configured queries reach terminal error state, after retries:

```tsx
useMutation({
  mutationFn: saveChanges,
  meta: {
    vandorToast: {
      loading: "Saving…",
      success: "Changes saved",
      error: "Could not save changes",
    },
  },
});

useQuery({
  queryKey: ["report"],
  queryFn: getReport,
  meta: { vandorToast: { error: "Report unavailable" } },
});
```

The adapter subscribes to the existing QueryClient caches and does not replace application or per-operation callbacks. Mutation instances are tracked independently. Raw errors are never shown by default; provide an explicit safe message or mapper. Background refetches and unconfigured operations remain silent.

<ComponentPreview name="toast-query-demo" title="toast-query-demo.tsx">
  <ToastQueryDemo />
</ComponentPreview>

* Mutation retries keep one loading notification until the final result. Result/error mappers that throw use safe fallback text. Overlapping operations have different IDs. Dismissing loading is final; settlement does not recreate it.
* The adapter does not override Toast durations. Loading has no countdown; its title-only success/error result defaults to 3 seconds, with the progress bar starting when presented. If you update that toast to include description/action, the core default is 4 seconds. Adapter metadata configures messages only, not duration overrides.
* Query failures notify only in idle terminal error state after retries. Deduplication uses the query's error-update counter, so repeated cache events do not duplicate feedback, but a later failed fetch can notify even if it throws the same Error object.
* Adapter instances for one QueryClient share a reference-counted subscription. Remount reconciles pending mutations and tracked results without creating duplicate loading toasts. Keep ToastProvider and the adapter mounted at the root; removing the final adapter stops feedback until remount.
* The optional `subscribeToastQueryClient(client)` function offers the same subscription for non-component integration; call its returned cleanup when releasing the client. Do not use it alongside a different toast lifecycle owner.

## Behavior & Accessibility [#behavior--accessibility]

* Default durations are 3 seconds for a title and 4 seconds with description/action, including error. Loading remains until updated or explicitly dismissed. Pass `duration` to override; `0` is persistent.
* Hover and focus pause timers. Every notification has a named close control and supports swipe dismissal.
* Success/error updates retain the loading notification identity. Status is communicated with text and an icon, not color alone.
* Reduced-motion preferences disable transition motion, including the loading spinner animation.
* The compact stack count button is keyboard operable and announces the number of queued items.
* The renderer mounts one active notification; closing unseen records drain in bounded batches. Waiting cards are not mounted. Queue state remains in Base UI, without a custom notification store or timer engine.

### Updates and callbacks [#updates-and-callbacks]

`toast.update(id, options)` is a partial update: omitted title, status, description, action and explicit duration override are preserved. Pass `description: null` or `actionLabel: ""` to clear those contents. Once opened, the body does not automatically collapse. A loading-to-result update without an explicit override uses the result state's default duration. `toast.update` returns the supplied ID; unknown or closing IDs are ignored. `toast.dismiss()` with no ID closes all notifications.

`onClose` fires when dismissal starts, including explicit dismissal, swipe, and timeout. `onRemove` fires after the closing transition and removal. `actionOnClick` runs when the independent action button is activated; the action does not automatically dismiss the notification. Call `toast.dismiss(id)` in your handler when appropriate. Async action work should use a separate loading toast or `toast.promise` and handle its rejection.

Mount one ToastProvider/Toaster pair per application. The exported manager is shared by the imperative API; multiple providers are not isolated scopes. The API is client-side only, not a server notification channel. F6 focuses notifications; Escape dismisses the focused toast. Low-priority feedback is polite and errors are urgent by default.

## Stories [#stories]

Optional: first install Toast, then run `npx shadcn@latest add https://vandor-ui.vercel.app/r/toast-stories.json`. The stories file is placed in your configured UI directory and does not modify Storybook configuration or install Storybook packages. Use the same Storybook package version as your project. The stories inherit your theme and global styles; no Storybook server is provided by this repository.

Stories are typed against Storybook React 10.6.1. Include `**/*.stories.tsx` in your existing story discovery, load your Tailwind stylesheet globally, and supply your normal `.dark` theme decorator. No optional addon is required. Each story owns its provider; use one canvas at a time because the imperative manager is shared. Storybook UI has not been verified: this repository has no Storybook server.

The distributed `toast.tsx` imports `toast-utils.ts` beside it. Install through the registry CLI so both files are added. Manual copying requires both files, plus `@base-ui/react`, `cn`, `lucide-react`, and `motion`; use React 18.3+ and a Tailwind CSS 4 setup with the documented theme-mode `.dark` class behavior.

## Props [#props]

| Prop          | Type                                                                                            | Default                                                                              | Description                                                                                                        |
| ------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| actionLabel   | string                                                                                          | undefined                                                                            | Toast option that renders a separately focusable action button with a 4-second default timeout.                    |
| actionOnClick | () => void                                                                                      | undefined                                                                            | Toast option callback invoked by the action button. Use actionLabel to show the button.                            |
| description   | ReactNode                                                                                       | undefined                                                                            | Toast option rendered in the connected detail body.                                                                |
| duration      | number                                                                                          | 3,000 ms title-only; 4,000 ms with description/action; loading waits without a timer | Toast option. Overrides the default auto-dismiss duration; 0 persists until explicitly dismissed.                  |
| id            | string                                                                                          | Generated for new toasts; required by toast.update                                   | Toast ID. New toast methods return this ID; use it with toast.update or toast.dismiss to target that notification. |
| onClose       | () => void                                                                                      | undefined                                                                            | Called once when dismissal starts, whether explicit, swipe, or timeout.                                            |
| onRemove      | () => void                                                                                      | undefined                                                                            | Called after closing animations finish and Base UI removes the record.                                             |
| position      | "top-left" \| "top-center" \| "top-right" \| "bottom-left" \| "bottom-center" \| "bottom-right" | top-right                                                                            | Optional Toaster prop. The default top-right position becomes top-center on narrow screens.                        |
| priority      | "low" \| "high"                                                                                 | low; error toasts default to high                                                    | Announcement priority. High priority is announced urgently; this does not change duration.                         |
| title         | ReactNode                                                                                       | Required by toast methods; preserved when omitted from updates                       | Visible notification title. Each toast method takes the title as its first argument.                               |
| type          | "action" \| "error" \| "info" \| "loading" \| "success" \| "warning"                            | Preserved for toast.update; determined by the selected toast method otherwise        | Toast status: action, error, info, loading, success, or warning.                                                   |

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

## Source [#source]

```tsx
"use client";

import { Toast as ToastPrimitive } from "@base-ui/react/toast";
import { cn } from "cn";
import {
  AlertCircleIcon,
  CheckIcon,
  InfoIcon,
  LoaderCircleIcon,
  TriangleAlertIcon,
  LayersIcon,
  XIcon,
} from "lucide-react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type {
  ComponentProps,
  CSSProperties,
  ReactNode,
  RefObject,
} from "react";

import { getToastDuration, getToastSurfacePath } from "./toast-utils";

const toastManager = ToastPrimitive.createToastManager<ToastData>();
let notificationRevision = 0;

export type ToastPosition =
  | "bottom-center"
  | "bottom-left"
  | "bottom-right"
  | "top-center"
  | "top-left"
  | "top-right";

export interface ToastData {
  actionLabel?: string;
  actionOnClick?: () => void;
  description?: ReactNode;
  duration?: number;
  /** Internal presentation metadata; Base UI remains the lifecycle owner. */
  durationOverride?: number;
  presented?: boolean;
  bodyOpened?: boolean;
  timerRevision?: number;
  type?: "action" | "error" | "info" | "loading" | "success" | "warning";
}

export interface ToastOptions extends Omit<
  ToastData,
  "presented" | "durationOverride" | "bodyOpened" | "timerRevision"
> {
  id?: string;
  priority?: "high" | "low";
  title?: ReactNode;
  onClose?: () => void;
  onRemove?: () => void;
}

export interface ToastProviderProps extends Omit<
  ComponentProps<typeof ToastPrimitive.Provider>,
  "limit" | "timeout" | "toastManager"
> {
  children: ReactNode;
}

export const ToastProvider = ({ children, ...props }: ToastProviderProps) => (
  <ToastPrimitive.Provider
    {...props}
    limit={Number.POSITIVE_INFINITY}
    timeout={3000}
    toastManager={toastManager}
  >
    <span data-vandor-overlay-host="" style={{ display: "contents" }}>
      {children}
    </span>
  </ToastPrimitive.Provider>
);

const normalizeOptions = (
  type: ToastData["type"],
  title: ReactNode,
  options: ToastOptions = {},
  queued = false
) => {
  notificationRevision += 1;
  const duration = getToastDuration({
    action: Boolean(options.actionLabel),
    description: options.description,
    duration: options.duration,
    type,
  });
  const data: ToastData = {
    ...options,
    duration,
    durationOverride: options.duration,
    presented: false,
    timerRevision: notificationRevision,
    type,
  };
  return {
    ...options,
    actionProps: options.actionLabel
      ? {
          children: options.actionLabel,
          className:
            "mt-3 inline-flex min-h-8 max-w-full cursor-pointer items-center rounded-lg bg-white/10 px-3 py-1.5 text-xs font-medium text-white [overflow-wrap:anywhere] hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white dark:bg-black/5 dark:text-neutral-950 dark:hover:bg-black/10 dark:focus-visible:outline-neutral-950",
          onClick: options.actionOnClick,
        }
      : undefined,
    data,
    description: options.description,
    priority: options.priority ?? (type === "error" ? "high" : "low"),
    // All new records begin queued. The viewport starts the timer only when the
    // toast is readable at the front, so unseen notifications persist.
    timeout: queued ? 0 : duration,
    title: options.title ?? title,
    type,
  };
};

const addToast = (
  type: ToastData["type"],
  title: ReactNode,
  options?: ToastOptions
) => toastManager.add(normalizeOptions(type, title, options, true));

const updateToast = (
  id: string,
  type: ToastData["type"],
  title: ReactNode,
  options: ToastOptions = {}
) =>
  toastManager.update(id, (previous) => {
    const merged = {
      ...previous.data,
      ...options,
      description: Object.hasOwn(options, "description")
        ? options.description
        : previous.description,
      duration: options.duration ?? previous.data?.durationOverride,
      priority:
        options.priority ??
        (previous.data as ToastOptions | undefined)?.priority,
      title: title ?? previous.title,
    };
    const normalized = normalizeOptions(
      type ?? previous.data?.type ?? "info",
      title ?? previous.title,
      merged
    );
    return {
      ...normalized,
      data: {
        ...normalized.data,
        bodyOpened: previous.data?.bodyOpened,
        presented: previous.data?.presented ?? false,
      },
      timeout: previous.data?.presented ? normalized.data.duration : 0,
    };
  });

const resolveMessage = <Value,>(
  message: ReactNode | ((value: Value) => ReactNode),
  value: Value,
  fallback: ReactNode
) => {
  if (typeof message !== "function") {
    return message;
  }
  try {
    return message(value);
  } catch {
    return fallback;
  }
};

export const toast = {
  action: (title: ReactNode, options?: ToastOptions) =>
    addToast("action", title, options),
  close: (id?: string) => toastManager.close(id),
  dismiss: (id?: string) => toastManager.close(id),
  error: (title: ReactNode, options?: ToastOptions) =>
    addToast("error", title, options),
  info: (title: ReactNode, options?: ToastOptions) =>
    addToast("info", title, options),
  loading: (title: ReactNode, options?: ToastOptions) =>
    addToast("loading", title, options),
  promise: async <Value,>(
    promiseValue: Promise<Value>,
    messages: {
      error: ReactNode | ((error: unknown) => ReactNode);
      loading: ReactNode;
      success: ReactNode | ((value: Value) => ReactNode);
      errorOptions?: ToastOptions;
      loadingOptions?: ToastOptions;
      successOptions?: ToastOptions;
    }
  ): Promise<Value> => {
    const id = toast.loading(messages.loading, messages.loadingOptions);
    try {
      const value = await promiseValue;
      const title = resolveMessage(messages.success, value, "Done");
      updateToast(id, "success", title, messages.successOptions);
      return value;
    } catch (error) {
      const title = resolveMessage(
        messages.error,
        error,
        "Something went wrong. Please try again."
      );
      updateToast(
        id,
        "error",
        title ?? "Something went wrong. Please try again.",
        messages.errorOptions
      );
      throw error;
    }
  },
  success: (title: ReactNode, options?: ToastOptions) =>
    addToast("success", title, options),
  update: (id: string, options: ToastOptions) => {
    updateToast(id, options.type, options.title, options);
    return id;
  },
  warning: (title: ReactNode, options?: ToastOptions) =>
    addToast("warning", title, options),
};

const useOverlayContainer = () => {
  const [container, setContainer] = useState<HTMLElement | null>(null);

  useEffect(() => {
    const update = () => {
      const modalHosts = [
        ...document.querySelectorAll<HTMLElement>(
          '[data-vandor-modal-toast-host][data-toast-host-active="true"]'
        ),
      ];
      let newestHost: HTMLElement | undefined;
      for (const host of modalHosts) {
        if (
          !newestHost ||
          Number(host.dataset.toastHostOrder) >=
            Number(newestHost.dataset.toastHostOrder)
        ) {
          newestHost = host;
        }
      }
      const overlayHost = document.querySelector<HTMLElement>(
        "[data-vandor-overlay-host]"
      );
      setContainer(newestHost ?? overlayHost ?? document.body);
    };
    update();
    const observer = new MutationObserver(update);
    observer.observe(document.body, {
      attributeFilter: ["data-toast-host-active", "data-toast-host-order"],
      attributes: true,
      childList: true,
      subtree: true,
    });
    return () => observer.disconnect();
  }, []);

  return container;
};

const statusIcons = {
  action: InfoIcon,
  error: AlertCircleIcon,
  info: InfoIcon,
  loading: LoaderCircleIcon,
  success: CheckIcon,
  warning: TriangleAlertIcon,
} as const;

const ToastSwipeState = ({
  direction,
  onChange,
  shell,
  rootStyle,
  ending,
  queued,
}: {
  direction?: string;
  onChange: (direction?: string) => void;
  shell: RefObject<HTMLDivElement | null>;
  rootStyle?: CSSProperties;
  ending: boolean;
  queued: boolean;
}) => {
  useLayoutEffect(() => onChange(direction), [direction, onChange]);
  useLayoutEffect(() => {
    const element = shell.current;
    if (!element) {
      return;
    }
    const offsets = rootStyle as Record<string, string | undefined> | undefined;
    element.style.setProperty(
      "--toast-swipe-movement-x",
      offsets?.["--toast-swipe-movement-x"] ?? "0px"
    );
    element.style.setProperty(
      "--toast-swipe-movement-y",
      offsets?.["--toast-swipe-movement-y"] ?? "0px"
    );
    element.style.transform = queued ? "none" : (rootStyle?.transform ?? "");
    element.style.transition = queued ? "none" : (rootStyle?.transition ?? "");
    element.toggleAttribute("data-ending-style", ending && !queued);
    if (direction) {
      element.dataset.swipeDirection = direction;
    } else {
      delete element.dataset.swipeDirection;
    }
  }, [shell, rootStyle, ending, queued, direction]);
  return null;
};

/** Visual clock only. Base UI owns dismissal and pause/resume of its timer. */
const ToastProgress = ({
  duration,
  paused,
  revision,
  position,
  opened,
  size,
}: {
  duration: number;
  paused: boolean;
  revision: string;
  position: ToastPosition;
  opened: boolean;
  size: { width: number; pill: number; start: number; head: number };
}) => {
  const line = useRef<HTMLDivElement>(null);
  const clock = useRef({ elapsed: 0, last: 0 });
  const [windowPaused, setWindowPaused] = useState(false);
  useEffect(() => {
    const blur = () => setWindowPaused(true);
    const focus = () => setWindowPaused(false);
    window.addEventListener("blur", blur);
    window.addEventListener("focus", focus);
    return () => {
      window.removeEventListener("blur", blur);
      window.removeEventListener("focus", focus);
    };
  }, []);
  useLayoutEffect(() => {
    clock.current.elapsed = 0;
    if (line.current) {
      line.current.style.transform = "scaleX(1)";
    }
  }, [revision, duration]);
  useEffect(() => {
    if (!duration || paused || windowPaused) {
      return;
    }
    let frame = 0;
    clock.current.last = performance.now();
    const tick = (now: number) => {
      clock.current.elapsed += now - clock.current.last;
      clock.current.last = now;
      if (line.current) {
        line.current.style.transform = `scaleX(${Math.max(0, 1 - clock.current.elapsed / duration)})`;
      }
      if (clock.current.elapsed < duration) {
        frame = requestAnimationFrame(tick);
      }
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [duration, paused, windowPaused, revision]);
  if (!duration) {
    return null;
  }
  return (
    <div
      aria-hidden="true"
      data-toast-progress=""
      data-paused={paused || windowPaused}
      data-duration={duration}
      className="pointer-events-none absolute bottom-1 h-0.5 overflow-hidden rounded-full bg-white/10 dark:bg-black/10"
      style={{
        bottom: opened && position.startsWith("bottom") ? size.head : 4,
        left: opened ? 20 : size.start + 20,
        width: Math.max(0, (opened ? size.width : size.pill) - 40),
      }}
    >
      <div
        ref={line}
        className="size-full rounded-full bg-neutral-400 dark:bg-neutral-500"
        style={{ transformOrigin: position.split("-")[1] }}
      />
    </div>
  );
};

// Presentation branches cover independent content, measurement, and motion states.
// eslint-disable-next-line complexity
const ToastCard = ({
  toast: item,
  position,
  queuedCount,
  entry,
}: {
  toast: ToastPrimitive.Root.ToastObject<ToastData>;
  position: ToastPosition;
  queuedCount: number;
  entry: "first" | "replacement";
}) => {
  const data = item.data ?? {};
  const Icon = statusIcons[data.type ?? "info"];
  const reduceMotion = useReducedMotion();
  const hasBody = Boolean(item.description || data.actionLabel);
  const manager = ToastPrimitive.useToastManager<ToastData>();
  const [opened, setOpened] = useState(Boolean(data.bodyOpened));
  const header = useRef<HTMLDivElement>(null);
  const body = useRef<HTMLDivElement>(null);
  const [size, setSize] = useState({
    head: 48,
    height: 48,
    pill: 260,
    start: 0,
    width: 360,
  });
  const isTop = position.startsWith("top");
  const isCenter = position.endsWith("center");
  const centerInset = queuedCount > 0 ? 96 : 0;
  const swipeShell = useRef<HTMLDivElement>(null);
  const [swipeDirection, setSwipeDirection] = useState<string>();
  const previousId = useRef(item.id);
  const previousItem = useRef(item);
  const [outgoing, setOutgoing] =
    useState<ToastPrimitive.Root.ToastObject<ToastData>>();
  const [arrival, setArrival] = useState(false);
  const badgeMotion = useAnimationControls();
  useEffect(() => {
    if (arrival && !reduceMotion) {
      void badgeMotion.start({
        scale: [1, 1.08, 1],
        transition: { duration: 0.24 },
      });
    } else {
      badgeMotion.set({ scale: 1 });
    }
  }, [arrival, item.id, badgeMotion, reduceMotion]);
  useLayoutEffect(() => {
    if (previousId.current !== item.id) {
      setOutgoing(previousItem.current);
      setArrival(
        (item.data?.timerRevision ?? 0) >
          (previousItem.current.data?.timerRevision ?? 0)
      );
      setOpened(Boolean(data.bodyOpened || hasBody));
      previousId.current = item.id;
    }
    previousItem.current = item;
  }, [item, queuedCount, hasBody, data.bodyOpened]);
  useEffect(() => {
    if (!outgoing) {
      return;
    }
    const timer = window.setTimeout(
      () => setOutgoing(undefined),
      reduceMotion ? 80 : 180
    );
    return () => window.clearTimeout(timer);
  }, [outgoing, reduceMotion]);
  useLayoutEffect(() => {
    const measure = () => {
      const width = header.current?.parentElement?.clientWidth || 360;
      const pill = Math.min(width, header.current?.offsetWidth || 260);
      const head = header.current?.offsetHeight || 48;
      const start = header.current?.offsetLeft || 0;
      const height =
        head +
        (opened && hasBody ? -4 + (body.current?.offsetHeight || 24) : 0);
      setSize((previous) =>
        previous.width === width &&
        previous.pill === pill &&
        previous.height === height &&
        previous.head === head &&
        previous.start === start
          ? previous
          : { head, height, pill, start, width }
      );
    };
    measure();
    let frame = 0;
    const observer = new ResizeObserver(() => {
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(measure);
    });
    if (header.current) {
      observer.observe(header.current);
    }
    if (header.current?.parentElement) {
      observer.observe(header.current.parentElement);
    }
    if (body.current) {
      observer.observe(body.current);
    }
    return () => {
      cancelAnimationFrame(frame);
      observer.disconnect();
    };
  }, [
    opened,
    hasBody,
    position,
    queuedCount,
    item.id,
    item.title,
    item.description,
    data.actionLabel,
  ]);
  useEffect(() => {
    if (!hasBody || opened || item.transitionStatus === "ending") {
      return;
    }
    if (reduceMotion || entry !== "first") {
      setOpened(true);
      return;
    }
    // Let the pill/title entrance establish the message before opening details.
    const timer = window.setTimeout(() => setOpened(true), 220);
    return () => window.clearTimeout(timer);
  }, [hasBody, opened, reduceMotion, entry, item.id, item.transitionStatus]);
  useEffect(() => {
    if (opened && !data.bodyOpened && item.transitionStatus !== "ending") {
      manager.update(item.id, (previous) => ({
        data: { ...previous.data, bodyOpened: true },
      }));
    }
  }, [opened, data.bodyOpened, item.id, item.transitionStatus, manager]);
  const bodyVisible = hasBody && opened;
  const path = getToastSurfacePath({ ...size, opened: bodyVisible });
  const morphDuration = entry === "first" ? 0.32 : 0.2;
  const finalExitY = isTop ? -14 : 14;
  return (
    <div
      ref={swipeShell}
      data-toast-swipe-shell=""
      className="relative transform-[translate3d(var(--toast-swipe-movement-x,0px),var(--toast-swipe-movement-y,0px),0)] transition-[opacity,transform] duration-260 ease-out data-ending-style:opacity-0 data-[swipe-direction=left]:data-ending-style:transform-[translate3d(calc(var(--toast-swipe-movement-x,0px)-100vw),var(--toast-swipe-movement-y,0px),0)] data-[swipe-direction=right]:data-ending-style:transform-[translate3d(calc(var(--toast-swipe-movement-x,0px)+100vw),var(--toast-swipe-movement-y,0px),0)] data-[swipe-direction=up]:data-ending-style:transform-[translate3d(var(--toast-swipe-movement-x,0px),calc(var(--toast-swipe-movement-y,0px)-100dvh),0)] data-[swipe-direction=down]:data-ending-style:transform-[translate3d(var(--toast-swipe-movement-x,0px),calc(var(--toast-swipe-movement-y,0px)+100dvh),0)] motion-reduce:transition-none"
    >
      <motion.div
        data-toast-shell=""
        className="relative"
        initial={
          reduceMotion
            ? false
            : { opacity: 0, scale: 0.96, y: isTop ? -16 : 16 }
        }
        animate={
          item.transitionStatus === "ending" &&
          queuedCount === 0 &&
          !swipeDirection
            ? {
                opacity: 0,
                scale: reduceMotion ? 1 : 0.94,
                x: 0,
                y: reduceMotion ? 0 : finalExitY,
              }
            : { opacity: 1, scale: 1, x: 0, y: 0 }
        }
        transition={{
          duration: reduceMotion ? 0 : 0.26,
          ease: [0.22, 1, 0.36, 1],
        }}
      >
        <svg
          aria-hidden="true"
          className={cn(
            "pointer-events-none absolute inset-x-0 h-[1024px] w-full overflow-visible text-neutral-950 drop-shadow-md dark:text-neutral-50",
            isTop ? "top-0" : "bottom-0"
          )}
          viewBox={`0 0 ${size.width} 1024`}
          preserveAspectRatio="none"
        >
          <motion.path
            initial={false}
            d={path}
            animate={{ d: path }}
            transition={{
              duration: reduceMotion ? 0 : morphDuration,
              ease: [0.22, 1, 0.36, 1],
            }}
            fill="currentColor"
            transform={isTop ? undefined : "translate(0 1024) scale(1 -1)"}
          />
        </svg>
        {outgoing && (
          <motion.div
            key={`outgoing-${outgoing.id}`}
            aria-hidden="true"
            inert
            className="pointer-events-none absolute inset-0 z-10 text-neutral-50 dark:text-neutral-950"
            initial={{ opacity: 1 }}
            animate={{ opacity: 0 }}
            transition={{ duration: reduceMotion ? 0.08 : 0.18 }}
          >
            <div
              className="absolute text-sm leading-snug font-medium"
              style={{
                bottom: isTop ? undefined : 14,
                left: size.start + (position.endsWith("left") ? 86 : 50),
                right: position.endsWith("left") ? 100 : 48,
                top: isTop ? 14 : undefined,
              }}
            >
              {outgoing.title}
            </div>
            {outgoing.description && (
              <div
                className="absolute inset-x-4 text-sm leading-relaxed text-neutral-300 dark:text-neutral-600"
                style={{ top: isTop ? size.head + 12 : 16 }}
              >
                {outgoing.description}
              </div>
            )}
          </motion.div>
        )}
        <div
          data-toast-queue-controls=""
          style={
            isCenter
              ? {
                  left: `calc(50% + ${centerInset / 2 - size.pill / 2 - 8}px)`,
                  transform: "translateX(-100%)",
                }
              : {
                  left: position.endsWith("left")
                    ? size.start + size.pill + 8
                    : size.start - 8,
                  transform: position.endsWith("left")
                    ? "none"
                    : "translateX(-100%)",
                }
          }
          className={cn(
            "absolute z-10 flex h-12 items-center gap-1.5",
            isTop ? "top-0" : "bottom-0"
          )}
        >
          <motion.div
            initial={{ opacity: queuedCount > 0 ? 1 : 0 }}
            animate={{ opacity: queuedCount > 0 ? 1 : 0 }}
            className="flex items-center gap-1.5"
            inert={queuedCount === 0}
          >
            <button
              type="button"
              aria-label="Dismiss all notifications"
              onClick={() => toast.dismiss()}
              className="pointer-events-auto flex size-8 items-center justify-center rounded-full bg-neutral-950 text-neutral-300 dark:bg-neutral-50 dark:text-neutral-600 focus-visible:outline-2 focus-visible:outline-ring"
            >
              <XIcon aria-hidden="true" className="size-4" />
            </button>
            <motion.span
              data-toast-queue=""
              data-count={queuedCount}
              role="img"
              aria-label={`${queuedCount} queued notifications`}
              animate={badgeMotion}
              transition={{ duration: 0.24 }}
              className="flex h-8 min-w-8 items-center justify-center gap-1 rounded-full bg-neutral-950 px-2 text-xs text-neutral-300 tabular-nums dark:bg-neutral-50 dark:text-neutral-600"
            >
              <LayersIcon aria-hidden="true" className="size-4" />
              <span>{queuedCount}</span>
            </motion.span>
          </motion.div>
        </div>
        <button
          style={
            isCenter
              ? {
                  left: `calc(50% + ${centerInset / 2 + size.pill / 2 - 40}px)`,
                }
              : undefined
          }
          type="button"
          aria-label="Dismiss notification"
          onClick={() => toast.dismiss(item.id)}
          className={cn(
            "pointer-events-auto absolute z-20 flex size-7 items-center justify-center rounded-full text-neutral-400 hover:bg-white/10 hover:text-white dark:text-neutral-500 dark:hover:bg-black/5 dark:hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-ring",
            isTop ? "top-2.5" : "bottom-2.5",
            !isCenter && (position.endsWith("left") ? "left-3" : "right-3")
          )}
        >
          <XIcon aria-hidden="true" className="size-4" />
        </button>
        <ToastPrimitive.Root
          key={item.id}
          toast={item}
          swipeDirection={["up", "down", "left", "right"]}
          data-toast-id={item.id}
          data-toast-placement={position}
          data-toast-entry={entry}
          data-toast-exit={queuedCount > 0 ? "advance" : "last"}
          render={(props, state) => (
            <div {...props} style={{ ...props.style, transform: "none" }}>
              {props.children}
              <ToastSwipeState
                direction={state.swipeDirection}
                onChange={setSwipeDirection}
                shell={swipeShell}
                rootStyle={props.style}
                ending={state.transitionStatus === "ending"}
                queued={queuedCount > 0}
              />
              <ToastProgress
                duration={data.duration ?? 0}
                paused={
                  state.expanded ||
                  state.swiping ||
                  state.transitionStatus === "ending"
                }
                revision={`${item.id}:${data.timerRevision ?? 0}`}
                position={position}
                opened={bodyVisible}
                size={size}
              />
            </div>
          )}
          className={cn(
            "group/toast pointer-events-auto relative w-full touch-pan-y outline-none transition-opacity duration-260 ease-out focus-visible:rounded-3xl focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-ending-style:opacity-0 motion-reduce:transition-none",
            isTop ? "origin-top" : "origin-bottom",
            queuedCount > 0
              ? "transition-none data-ending-style:opacity-100"
              : "duration-260"
          )}
        >
          <motion.div
            animate={{ opacity: 1, scale: 1, y: 0 }}
            initial={
              reduceMotion || entry === "first"
                ? false
                : { opacity: 0, y: arrival ? 4 : 0 }
            }
            transition={
              reduceMotion
                ? { duration: 0 }
                : {
                    duration: entry === "first" ? 0.32 : 0.18,
                    ease: [0.22, 1, 0.36, 1],
                  }
            }
            className={cn(
              "relative flex flex-col text-neutral-50 dark:text-neutral-950",
              isTop ? "origin-top" : "origin-bottom",
              !isTop && "flex-col-reverse"
            )}
            data-type={data.type}
          >
            <div
              data-toast-header-row=""
              style={isCenter ? { paddingLeft: centerInset } : undefined}
              className={cn(
                "relative flex items-center gap-2",
                position.endsWith("right") && "justify-end",
                isCenter && "justify-center",
                position.endsWith("left") && "flex-row-reverse justify-end"
              )}
            >
              <div
                ref={header}
                className={cn(
                  "relative flex min-h-12 w-fit min-w-0 items-center gap-2.5 py-2.5",
                  isCenter ? "max-w-full" : "max-w-[calc(100%-6rem)]",
                  position.endsWith("left") ? "pr-3 pl-12" : "pr-12 pl-3"
                )}
              >
                <span
                  aria-hidden="true"
                  className={cn(
                    "flex size-7 shrink-0 items-center justify-center rounded-full bg-white/10 text-neutral-100 dark:bg-black/5 dark:text-neutral-900",
                    data.type === "success" &&
                      "text-emerald-400 dark:text-emerald-700",
                    data.type === "error" && "text-red-300 dark:text-red-700",
                    data.type === "warning" &&
                      "text-amber-300 dark:text-amber-700",
                    data.type === "info" && "text-sky-300 dark:text-sky-700"
                  )}
                >
                  <motion.span
                    key={data.type}
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    transition={{ duration: reduceMotion ? 0 : 0.15 }}
                  >
                    <Icon
                      className={cn(
                        "size-4",
                        data.type === "loading" &&
                          "animate-spin motion-reduce:animate-none"
                      )}
                    />
                  </motion.span>
                </span>
                <div className="min-w-0 flex-1">
                  <ToastPrimitive.Title className="relative text-sm leading-snug font-medium [overflow-wrap:anywhere]">
                    <motion.span
                      key={`${item.id}:${data.timerRevision ?? 0}`}
                      className="block"
                      initial={{
                        opacity: 0,
                        y: reduceMotion || !arrival ? 0 : 4,
                      }}
                      animate={{ opacity: 1, y: 0 }}
                      transition={{ duration: reduceMotion ? 0.08 : 0.18 }}
                    >
                      {item.title}
                    </motion.span>
                  </ToastPrimitive.Title>
                </div>
              </div>
            </div>
            {bodyVisible && (
              <motion.div
                ref={body}
                animate={{ opacity: 1, y: 0 }}
                initial={reduceMotion ? false : { opacity: 0, y: -4 }}
                transition={
                  reduceMotion
                    ? { duration: 0 }
                    : {
                        duration: entry === "first" ? 0.3 : 0.18,
                        ease: [0.22, 1, 0.36, 1],
                      }
                }
                className={cn(
                  "relative max-h-[min(60dvh,30rem)] overflow-y-auto overflow-x-hidden overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
                  isTop ? "-mt-1" : "-mb-1"
                )}
              >
                <div className="px-4 py-4 [overflow-wrap:anywhere]">
                  {item.description && (
                    <ToastPrimitive.Description className="text-sm leading-relaxed text-neutral-300 dark:text-neutral-600" />
                  )}
                  {data.actionLabel && <ToastPrimitive.Action />}
                </div>
              </motion.div>
            )}
          </motion.div>
        </ToastPrimitive.Root>
      </motion.div>
    </div>
  );
};

export interface ToasterProps {
  position?: ToastPosition;
}

export const Toaster = ({ position }: ToasterProps) => {
  const resolvedPosition = position ?? "top-right";
  const manager = ToastPrimitive.useToastManager<ToastData>();
  const overlayContainer = useOverlayContainer();
  const [mobile, setMobile] = useState(false);
  const [displayedId, setDisplayedId] = useState<string>();
  const [entry, setEntry] = useState<"first" | "replacement">("first");
  const hasPresented = useRef(false);
  useEffect(() => {
    const query = window.matchMedia("(max-width: 639px)");
    const update = () => setMobile(query.matches);
    update();
    query.addEventListener("change", update);
    return () => query.removeEventListener("change", update);
  }, []);
  const visualPosition = position ?? (mobile ? "top-center" : "top-right");
  const activeToasts = manager.toasts.filter(
    (item) => item.transitionStatus !== "ending"
  );
  const displayed = manager.toasts.find((item) => item.id === displayedId);
  useEffect(() => {
    if (manager.toasts.length === 0) {
      hasPresented.current = false;
    }
    if (displayed?.transitionStatus !== "ending" || activeToasts.length > 0) {
      const nextId = activeToasts[0]?.id;
      if (nextId !== displayedId) {
        setEntry(hasPresented.current ? "replacement" : "first");
        setDisplayedId(nextId);
        if (nextId) {
          hasPresented.current = true;
        }
      }
    }
  }, [displayed, activeToasts, displayedId, manager.toasts.length]);
  // Unrendered queued records still need a Root to complete Base UI removal.
  // Drain closed records in bounded batches, rather than mounting the whole queue.
  const endingToasts = manager.toasts
    .filter((item) => item.transitionStatus === "ending")
    .slice(0, 20);
  useEffect(() => {
    for (const item of activeToasts) {
      const presented = item.id === displayedId;
      const timeout = presented ? (item.data?.duration ?? 3000) : 0;
      if (item.timeout === timeout && item.data?.presented === presented) {
        continue;
      }
      manager.update(item.id, { data: { ...item.data, presented }, timeout });
    }
  }, [manager, activeToasts, displayedId]);
  const isTop = resolvedPosition.startsWith("top");
  const horizontalPlacement = {
    "bottom-center": "left-1/2 -translate-x-1/2",
    "bottom-left": "left-[max(0.75rem,env(safe-area-inset-left))]",
    "bottom-right": "right-[max(0.75rem,env(safe-area-inset-right))]",
    "top-center": "left-1/2 -translate-x-1/2",
    "top-left": "left-[max(0.75rem,env(safe-area-inset-left))]",
    "top-right": "right-[max(0.75rem,env(safe-area-inset-right))]",
  };
  return (
    <ToastPrimitive.Portal
      container={overlayContainer}
      className="pointer-events-none fixed inset-0 z-[70] overflow-hidden"
    >
      <ToastPrimitive.Viewport
        aria-label="Notifications"
        data-position={resolvedPosition}
        className={cn(
          "pointer-events-none fixed z-[70] w-[min(23rem,calc(100vw-2rem))] overflow-visible outline-none focus-visible:rounded-xl focus-visible:ring-2 focus-visible:ring-ring",
          isTop
            ? "top-[max(0.75rem,env(safe-area-inset-top))]"
            : "bottom-[max(0.75rem,env(safe-area-inset-bottom))]",
          horizontalPlacement[resolvedPosition],
          position === undefined &&
            "max-sm:right-auto max-sm:left-1/2 max-sm:-translate-x-1/2"
        )}
      >
        {displayed && (
          <ToastCard
            toast={displayed}
            position={visualPosition}
            entry={entry}
            queuedCount={
              activeToasts.filter((item) => item.id !== displayed.id).length
            }
          />
        )}
        <div className="hidden" aria-hidden="true">
          {endingToasts
            .filter((item) => item.id !== displayedId)
            .map((item) => (
              <ToastPrimitive.Root key={item.id} toast={item} />
            ))}
        </div>
      </ToastPrimitive.Viewport>
    </ToastPrimitive.Portal>
  );
};

```

## Credits [#credits]

* [Base UI](https://base-ui.com/react/components/toast): Public Toast state, lifecycle, swipe dismissal, announcement, and focus behavior.

* [Sileo](https://sileo.aayushg.com/): Visual inspiration for compact-to-expanded toast motion; no source code is copied.
