# Dispatch — Animated UI Component for React

> An imperatively-fired toast system: call toast() anywhere and one Toaster piles the results into a receding 3D stack — hover fans it to full rows, and each dismisses on timeout, click, or swipe-throw while the survivors exhale up one slot. Promise toasts morph the icon in place; a feed mode streams a masked column. role=status live regions, Escape-dismiss, reduced-motion-safe.

- Page: https://crucibleui.com/components/utility/dispatch
- Category: [utility](https://crucibleui.com/components/utility)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/dispatch
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/dispatch`.

## Props (`ToasterProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `position?` | `DispatchPosition` | `"bottom-right"` | Corner the stack/feed anchors to. |
| `mode?` | `"stack" \| "feed"` | `"stack"` | `"stack"` piles toasts into a receding 3D stack; `"feed"` streams a masked column. |
| `channel?` | `string` | `"default"` | Namespace — pair with `toast(msg, { channel })` to drive multiple Toasters. |
| `maxVisible?` | `number` | `3` | How many cards are visible in the collapsed pile before the rest hide behind. |
| `stackOffset?` | `number` | `14` | Vertical peek between collapsed cards, in px. |
| `scaleStep?` | `number` | `0.05` | Scale removed per card going back in the collapsed pile. |
| `duration?` | `number` | `4000` | Auto-dismiss timeout in ms (loading toasts ignore it). |
| `gap?` | `number` | `14` | Row gap when the stack fans out or in feed mode, in px. |
| `swipeDistance?` | `number` | `90` | Horizontal throw distance that dismisses a toast, in px. |
| `swipeVelocity?` | `number` | `480` | Horizontal throw velocity that dismisses regardless of distance, in px/s. |
| `fanOnHover?` | `boolean` | `true` | Fan the collapsed pile into full rows on hover/focus. |
| `pauseOnHover?` | `boolean` | `true` | Freeze auto-dismiss timers while the pointer is over the stack. |
| `closeButton?` | `boolean` | `true` | Show a per-toast close button. |
| `width?` | `number` | `356` | Card + feed width in px (clamped to the viewport). |
| `container?` | `HTMLElement \| null` | ``document.body` (fixed)` | Portal target. When provided (must be positioned) the Toaster is contained inside it — ideal for previews. |
| `className?` | `string` | — | — |

## Usage example

```tsx
"use client";

import * as React from "react";

import { Toaster, toast } from "@/components/crucible/dispatch";

function DemoButton({
  children,
  onClick,
}: {
  children: React.ReactNode;
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      className="rounded-lg border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-medium text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] transition-colors hover:bg-white/[0.1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
    >
      {children}
    </button>
  );
}

const FEED_ITEMS: Array<{ title: string; description: string }> = [
  { title: "Nadia commented on “Q3 roadmap”", description: "“Can we pull the launch a week?”" },
  { title: "Build #482 passed", description: "12 checks · 2m 41s" },
  { title: "New signup from Berlin", description: "team plan · 8 seats" },
  { title: "Deploy promoted to production", description: "web · rev a1f9c2" },
  { title: "Invoice paid", description: "Acme Co · $1,240.00" },
];

export default function DispatchDemo() {
  const stackRef = React.useRef<HTMLDivElement>(null);
  const feedRef = React.useRef<HTMLDivElement>(null);
  const [stackEl, setStackEl] = React.useState<HTMLElement | null>(null);
  const [feedEl, setFeedEl] = React.useState<HTMLElement | null>(null);

  React.useEffect(() => {
    setStackEl(stackRef.current);
    setFeedEl(feedRef.current);
  }, []);

  // Feed panel streams items in on a delay.
  React.useEffect(() => {
    if (!feedEl) return;
    let i = 0;
    const tick = () => {
      const item = FEED_ITEMS[i % FEED_ITEMS.length];
      i += 1;
      toast(item.title, {
        channel: "demo-feed",
        description: item.description,
        duration: 7000,
      });
    };
    tick();
    const timer = window.setInterval(tick, 2400);
    return () => window.clearInterval(timer);
  }, [feedEl]);

  const fireSuccess = () =>
    toast.success("Changes saved", {
      channel: "demo-stack",
      description: "Your workspace is up to date.",
    });

  const fireError = () =>
    toast.error("Upload failed", {
      channel: "demo-stack",
      description: "The file exceeds the 25 MB limit.",
    });

  const firePromise = () =>
    toast.promise(
      new Promise((resolve) => window.setTimeout(resolve, 2200)),
      {
        channel: "demo-stack",
        loading: "Syncing your library…",
        success: "Library synced",
        error: "Sync failed",
      }
    );

  const fireAction = () =>
    toast("Message archived", {
      channel: "demo-stack",
      description: "Moved to the archive folder.",
      action: {
        label: "Undo",
        onClick: () => {
          toast.success("Restored to inbox", { channel: "demo-stack" });
        },
      },
    });

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full flex-col gap-3 overflow-hidden rounded-xl bg-neutral-950 p-3 sm:flex-row">
      {/* Interactive stack panel */}
      <div
        ref={stackRef}
        className="relative flex min-h-[300px] flex-1 flex-col items-center justify-start gap-4 overflow-hidden rounded-lg border border-white/[0.06] bg-neutral-900/30 p-5"
      >
        <p className="text-xs font-medium tracking-wide text-neutral-400 uppercase">
          Stack
        </p>
        <div className="flex flex-wrap justify-center gap-2">
          <DemoButton onClick={fireSuccess}>Success</DemoButton>
          <DemoButton onClick={fireError}>Error</DemoButton>
          <DemoButton onClick={firePromise}>Promise</DemoButton>
          <DemoButton onClick={fireAction}>With action</DemoButton>
        </div>
        <p className="max-w-[16rem] text-center text-[13px] leading-relaxed text-neutral-500">
          Fire a few, then hover the pile to fan it out. Swipe a card sideways to
          throw it away — the rest exhale up one slot.
        </p>
        <Toaster
          channel="demo-stack"
          container={stackEl}
          position="bottom-right"
          mode="stack"
        />
      </div>

      {/* Streaming feed panel */}
      <div
        ref={feedRef}
        className="relative flex min-h-[300px] flex-1 flex-col overflow-hidden rounded-lg border border-white/[0.06] bg-neutral-900/30 p-5"
      >
        <p className="text-xs font-medium tracking-wide text-neutral-400 uppercase">
          Feed
        </p>
        <p className="mt-3 max-w-[18rem] text-[13px] leading-relaxed text-neutral-500">
          A live notification feed — items stream in on a delay and fade under a
          gradient mask as they scroll away.
        </p>
        <Toaster
          channel="demo-feed"
          container={feedEl}
          position="top-left"
          mode="feed"
          maxVisible={4}
          duration={7000}
          closeButton={false}
          width={300}
        />
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.
