# Toast

> Queued notification primitive that owns scheduling, visibility limits, and exit timing while you own the toast surface and its parts.

Source: https://docs.astra-void.xyz/lattice-ui/components/toast/

`@lattice-ui/react-toast` · Stable direction · import `Toast` · depends on `runtime`, `layer`, `motion`

Toast is the primitive for transient, non-blocking notifications: saved-changes confirmations, reward pop-ups, connection warnings, and inline status messages. A `Provider` owns the queue — enqueuing, the visible-count limit, per-toast duration, and exit timing — so your component only renders the surface for each toast and reacts to actions.

Reach for Toast when messages should **stack and expire on their own**, stay **capped to a few at a time**, and **animate out** without you tracking timers by hand. You drive it imperatively with the `useToast` hook from anywhere inside the provider.

## Preview

The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive.

_Interactive preview._

## Import

```ts
import { Toast, useToast } from "@lattice-ui/react-toast";
```

## Anatomy

`Provider` and `Viewport` form the minimum system: the provider owns the queue and the viewport is where you render it.

> **You render the queue**
>
> Before 0.7.0 a bare `<Toast.Viewport />` rendered a surface for every visible toast. It no longer does — it renders its children and nothing else, so a bare viewport shows nothing. Map `useToast().visibleToasts` onto `Toast.Root` yourself, which is what the `asChild` path always required. `Root`, `Title`, `Description`, `Action`, and `Close` are how you build each surface.

```tsx title="Toast anatomy"
<Toast.Provider>
  <Toast.Viewport>
    <Toast.Root>
      <Toast.Title />
      <Toast.Description />
      <Toast.Action />
      <Toast.Close />
    </Toast.Root>
  </Toast.Viewport>
</Toast.Provider>
```

| Part | Required | Responsibility |
| --- | --- | --- |
| `Toast.Provider` | yes | Owns the queue, default duration, visible cap, and exit timing; exposes `useToast`. |
| `Toast.Viewport` | yes | The container the stack lives in. Renders your children — supply the layout and the queue. |
| `Toast.Root` | no | A single toast surface that animates between visible and hidden. |
| `Toast.Title` | no | The toast heading. |
| `Toast.Description` | no | The toast supporting text. |
| `Toast.Action` | no | A button that runs `onAction` when activated. |
| `Toast.Close` | no | A button that runs `onClose` when activated. |

## Examples

### Basic setup

A provider at the top of the UI, a button that enqueues toasts, and a viewport that renders the queue. `enqueue` returns the new toast's id in case you want to remove it early.

The viewport is where the work is: map `visibleToasts`, give each record a `Toast.Root`, and wire `onExitComplete` to `finalize` so a toast is only dropped from the queue once its exit has finished. `visible={!record.exiting}` is what starts that exit.

```tsx title="ToastStack.tsx"
import { Toast, useToast } from "@lattice-ui/react-toast";

export function ToastStack() {
  const toast = useToast();

  return (
    <Toast.Viewport Size={UDim2.fromOffset(340, 320)}>
      <uilistlayout
        Padding={new UDim(0, 8)}
        SortOrder={Enum.SortOrder.LayoutOrder}
      />

      {toast.visibleToasts.map((record) => (
        <Toast.Root
          BackgroundColor3={Color3.fromRGB(28, 31, 40)}
          key={record.id}
          onExitComplete={() => toast.finalize(record.id)}
          Size={UDim2.fromOffset(340, 64)}
          visible={!record.exiting}
        >
          <uicorner CornerRadius={new UDim(0, 8)} />
          <uipadding
            PaddingLeft={new UDim(0, 12)}
            PaddingTop={new UDim(0, 10)}
          />

          <Toast.Title
            Size={UDim2.fromOffset(300, 18)}
            Text={record.title ?? ""}
            TextColor3={Color3.fromRGB(236, 241, 249)}
            TextXAlignment={Enum.TextXAlignment.Left}
          />
          <Toast.Description
            Position={UDim2.fromOffset(0, 22)}
            Size={UDim2.fromOffset(300, 16)}
            Text={record.description ?? ""}
            TextColor3={Color3.fromRGB(150, 158, 176)}
            TextXAlignment={Enum.TextXAlignment.Left}
          />
        </Toast.Root>
      ))}
    </Toast.Viewport>
  );
}
```

Then place that stack wherever it belongs. The viewport takes no position props of its own, so an anchored wrapper decides the corner:

```tsx title="SaveStatusToasts.tsx"
import { Toast, useToast } from "@lattice-ui/react-toast";
import { ToastStack } from "./ToastStack";

function SaveButton() {
  const toast = useToast();

  return (
    <textbutton
      Size={UDim2.fromOffset(140, 38)}
      Text="Save layout"
      Event={{
        Activated: () =>
          toast.enqueue({
            title: "Layout saved",
            description: "Your changes are live for everyone.",
            durationMs: 3000,
          }),
      }}
    />
  );
}

export function SaveStatusToasts() {
  return (
    <Toast.Provider defaultDurationMs={4000} maxVisible={3}>
      <SaveButton />

      <frame
        AnchorPoint={new Vector2(1, 1)}
        BackgroundTransparency={1}
        Position={UDim2.new(1, -16, 1, -16)}
        Size={UDim2.fromOffset(340, 320)}
      >
        <ToastStack />
      </frame>
    </Toast.Provider>
  );
}
```

> **Imperative, not declarative**
>
> There is no `open`/`onOpenChange` on a toast. You add toasts by calling `enqueue` and remove them with `remove`; the provider decides what is visible and when each one leaves. Treat the queue as the source of truth, not local component state.

### Undo action toast

`ToastOptions` carries only data — `id`, `title`, `description`, `durationMs` — never callbacks. To build an undo toast, key the pending undo by the id `enqueue` returns, then wire `Toast.Action` to the stored payload while mapping `visibleToasts`. This example uses `Viewport asChild` because it wants a `frame` it fully controls; mapping the queue is the same either way.

Both `onAction` and `onClose` are plain callbacks; neither removes the toast for you, so call `remove(record.id)` in each handler.

```tsx title="UndoDeleteToasts.tsx"
import { useRef } from "@rbxts/react";
import { Toast, useToast } from "@lattice-ui/react-toast";

function DeleteButton(props: { pendingUndos: Map<string, string> }) {
  const toast = useToast();

  return (
    <textbutton
      Size={UDim2.fromOffset(160, 36)}
      Text="Delete Sword"
      Event={{
        Activated: () => {
          // remove "Sword" from your data model here, then offer the undo
          const id = toast.enqueue({ title: "Sword deleted", durationMs: 6000 });
          props.pendingUndos.set(id, "Sword");
        },
      }}
    />
  );
}

function UndoViewport(props: {
  pendingUndos: Map<string, string>;
  onUndo: (itemName: string) => void;
}) {
  const toast = useToast();

  return (
    <Toast.Viewport asChild>
      <frame BackgroundTransparency={1} Size={UDim2.fromOffset(340, 320)}>
        <uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />

        {toast.visibleToasts.map((record) => (
          <Toast.Root key={record.id} visible={!record.exiting}>
            <Toast.Title asChild>
              <textlabel
                BackgroundTransparency={1}
                Size={UDim2.fromOffset(264, 20)}
                Text={record.title ?? ""}
                TextColor3={Color3.fromRGB(235, 240, 248)}
                TextXAlignment={Enum.TextXAlignment.Left}
              />
            </Toast.Title>

            <Toast.Action
              onAction={() => {
                const itemName = props.pendingUndos.get(record.id);
                if (itemName !== undefined) {
                  props.onUndo(itemName);
                  props.pendingUndos.delete(record.id);
                }
                toast.remove(record.id);
              }}
              asChild
            >
              <textbutton
                AutoButtonColor={false}
                BackgroundColor3={Color3.fromRGB(58, 66, 84)}
                Position={UDim2.fromOffset(0, 28)}
                Size={UDim2.fromOffset(72, 24)}
                Text="Undo"
                TextColor3={Color3.fromRGB(235, 240, 248)}
              />
            </Toast.Action>

            <Toast.Close onClose={() => toast.remove(record.id)} asChild>
              <textbutton
                AutoButtonColor={false}
                BackgroundTransparency={1}
                Position={UDim2.fromOffset(276, 0)}
                Size={UDim2.fromOffset(24, 20)}
                Text="X"
                TextColor3={Color3.fromRGB(172, 180, 196)}
              />
            </Toast.Close>
          </Toast.Root>
        ))}
      </frame>
    </Toast.Viewport>
  );
}

export function UndoDeleteToasts(props: { onUndo: (itemName: string) => void }) {
  const pendingUndos = useRef(new Map<string, string>()).current;

  return (
    <Toast.Provider>
      <DeleteButton pendingUndos={pendingUndos} />

      <frame
        AnchorPoint={new Vector2(1, 1)}
        BackgroundTransparency={1}
        Position={UDim2.new(1, -16, 1, -16)}
        Size={UDim2.fromOffset(340, 320)}
      >
        <UndoViewport pendingUndos={pendingUndos} onUndo={props.onUndo} />
      </frame>
    </Toast.Provider>
  );
}
```

### Duration tuning and sticky toasts

Each toast lives for its own `durationMs`; when you omit it, the provider's `defaultDurationMs` applies. A `durationMs` of `0` (or less) makes the toast sticky — it never expires and stays until something calls `remove` or `clear`. Passing your own `id` lets a different code path remove a sticky toast later, like clearing a "connection lost" warning on reconnect.

```tsx title="ConnectionToasts.tsx"
import { Toast, useToast } from "@lattice-ui/react-toast";

function ConnectionStatus() {
  const toast = useToast();

  return (
    <frame BackgroundTransparency={1} Size={UDim2.fromOffset(180, 170)}>
      <uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />

      <textbutton
        Size={UDim2.fromOffset(180, 34)}
        Text="Quick ping"
        Event={{
          Activated: () => toast.enqueue({ title: "Pong", durationMs: 1500 }),
        }}
      />

      <textbutton
        Size={UDim2.fromOffset(180, 34)}
        Text="Settings synced"
        Event={{
          // no durationMs: falls back to defaultDurationMs (6000 here)
          Activated: () => toast.enqueue({ title: "Settings synced" }),
        }}
      />

      <textbutton
        Size={UDim2.fromOffset(180, 34)}
        Text="Drop connection"
        Event={{
          Activated: () =>
            toast.enqueue({
              id: "connection-lost",
              title: "Connection lost",
              description: "Reconnecting...",
              durationMs: 0, // sticky: never expires on its own
            }),
        }}
      />

      <textbutton
        Size={UDim2.fromOffset(180, 34)}
        Text="Reconnect"
        Event={{
          Activated: () => {
            toast.remove("connection-lost");
            toast.enqueue({ title: "Reconnected", durationMs: 2000 });
          },
        }}
      />
    </frame>
  );
}

export function ConnectionToasts() {
  return (
    <Toast.Provider defaultDurationMs={6000}>
      <ConnectionStatus />

      <frame
        AnchorPoint={new Vector2(1, 1)}
        BackgroundTransparency={1}
        Position={UDim2.new(1, -16, 1, -16)}
        Size={UDim2.fromOffset(340, 320)}
      >
        <ToastStack />
      </frame>
    </Toast.Provider>
  );
}
```

### Queue behavior under burst

`maxVisible` caps how many toasts render at once; everything past the cap waits in order and surfaces as earlier toasts leave. Here six pickups arrive at once with `maxVisible={2}`: the first two show, the other four queue.

One caveat to design around: a toast's clock starts at `enqueue`, but expiry is only *evaluated* while it is inside the visible window. A toast that waited in the queue longer than its `durationMs` exits almost immediately once it surfaces. Under heavy bursts, give toasts a duration long enough to cover the expected wait, collapse the burst into a single summary toast ("Picked up 6 items"), or use sticky toasts with manual removal.

```tsx title="LootBurstToasts.tsx"
import { Toast, useToast } from "@lattice-ui/react-toast";

const DROPS = ["Iron Ore", "Gold Ore", "Emerald", "Ancient Coin", "Rune Shard", "Phoenix Feather"];

function LootChest() {
  const toast = useToast();

  return (
    <textbutton
      Size={UDim2.fromOffset(160, 36)}
      Text="Open chest"
      Event={{
        Activated: () => {
          for (const drop of DROPS) {
            toast.enqueue({ title: `Picked up ${drop}`, durationMs: 4000 });
          }
        },
      }}
    />
  );
}

export function LootBurstToasts() {
  return (
    <Toast.Provider maxVisible={2}>
      <LootChest />

      <frame
        AnchorPoint={new Vector2(1, 1)}
        BackgroundTransparency={1}
        Position={UDim2.new(1, -16, 1, -16)}
        Size={UDim2.fromOffset(340, 320)}
      >
        <ToastStack />
      </frame>
    </Toast.Provider>
  );
}
```

### Placing the viewport

The viewport is app-positioned: it takes no position props of its own, so put it inside an anchored wrapper frame to choose the corner or edge it lives in — here, top-center for announcement-style toasts. The stack direction and gap come from the layout you put inside the viewport.

Reach for `Viewport asChild` when you need a different instance class than the `frame` the viewport renders. It makes no difference to the queue — you map `visibleToasts` either way.

```tsx title="TopCenterToasts.tsx"
import { Toast } from "@lattice-ui/react-toast";

export function TopCenterToasts() {
  return (
    <Toast.Provider>
      {/* ...the rest of your UI... */}

      <frame
        AnchorPoint={new Vector2(0.5, 0)}
        BackgroundTransparency={1}
        Position={UDim2.new(0.5, 0, 0, 12)}
        Size={UDim2.fromOffset(340, 320)}
      >
        <ToastStack />
      </frame>
    </Toast.Provider>
  );
}
```

### Custom toast motion

`Toast.Root` runs no motion unless you pass a `transition`. `createToastResponseRecipe()` — a 0.14s calm, steady settle on the appearance target — animates `BackgroundTransparency` between `0` (visible) and `1` (hidden), and is the recipe the root used to apply for you. Rebuild it with a new duration, or write a `ResponseMotionConfig` by hand.

The `transition` prop only exists on roots you render yourself, so custom motion implies a custom viewport. Keep the duration at or under about 0.16s: the provider drops exiting toasts after a fixed ~160ms window regardless of your transition, so a slower fade gets cut off mid-motion.

```tsx title="SnappyToasts.tsx"
import { Toast, useToast } from "@lattice-ui/react-toast";
import { createToastResponseRecipe } from "@lattice-ui/react-motion";

// equivalent hand-built config:
// const SNAPPY_FADE: ResponseMotionConfig = { settle: { duration: 0.08, tempo: "swift", tone: "responsive" } };
const SNAPPY_FADE = createToastResponseRecipe(0.08);

function SnappyViewport() {
  const toast = useToast();

  return (
    <Toast.Viewport asChild>
      <frame BackgroundTransparency={1} Size={UDim2.fromOffset(340, 320)}>
        <uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />

        {toast.visibleToasts.map((record) => (
          <Toast.Root key={record.id} visible={!record.exiting} transition={SNAPPY_FADE}>
            <Toast.Title asChild>
              <textlabel
                BackgroundTransparency={1}
                Size={UDim2.fromOffset(300, 20)}
                Text={record.title ?? ""}
                TextColor3={Color3.fromRGB(235, 240, 248)}
                TextXAlignment={Enum.TextXAlignment.Left}
              />
            </Toast.Title>
          </Toast.Root>
        ))}
      </frame>
    </Toast.Viewport>
  );
}

export function SnappyToasts() {
  return (
    <Toast.Provider>
      {/* ...UI that enqueues toasts... */}

      <frame
        AnchorPoint={new Vector2(1, 1)}
        BackgroundTransparency={1}
        Position={UDim2.new(1, -16, 1, -16)}
        Size={UDim2.fromOffset(340, 320)}
      >
        <SnappyViewport />
      </frame>
    </Toast.Provider>
  );
}
```

## How it behaves

### The queue

`Toast.Provider` keeps an ordered queue of records. `enqueue(options)` appends a toast and returns its `id` (auto-generated as `toast-N` unless you pass `id`), stamping `createdAtMs` at that moment. `remove(id)` starts a toast's exit — or drops it instantly if it is still waiting beyond the visible cap — and is a no-op for a toast that is already exiting. `clear()` empties the whole queue immediately, skipping exit motion.

While any toasts exist, the provider runs a `RunService.Heartbeat` connection that prunes expired and finished-exiting toasts each frame; the connection disconnects once the queue drains, so an idle provider costs nothing per frame.

### Visibility limit

`maxVisible` (default `3`, clamped to at least `1`) caps how many toasts render at once; the rest wait in the queue in arrival order. `useToast().visibleToasts` is the capped slice the viewport renders, while `toasts` is the full queue. An exiting toast still occupies its visible slot for the length of the exit window, so the next queued toast surfaces only after the exit completes and the record is dropped.

### Duration and expiry

Each toast expires after its own `durationMs`, falling back to the provider's `defaultDurationMs` (default `4000`, clamped to `>= 0`). A duration of `0` or less makes a toast sticky — it never expires on its own and must be removed via `remove`, a wired-up `Toast.Close`, or `clear`.

Expiry is measured from `createdAtMs` — the moment of `enqueue` — but only checked while the toast is inside the visible window. Toasts waiting beyond the cap never expire in the queue, yet one that waited longer than its duration exits on nearly the first frame it becomes visible. Plan burst-heavy flows around this (longer durations, summary toasts, or sticky toasts).

### Exit timing and motion

When a visible toast's time elapses (or `remove` is called on it), the provider marks the record `exiting` and keeps it for a fixed ~160ms window so its exit motion can play before the record is dropped. Wire each root's `visible` prop to `!record.exiting` — that is what triggers the exit — and `onExitComplete` to `finalize(record.id)`.

`Toast.Root` runs no motion unless you pass a `transition`. `createToastResponseRecipe()` (0.14s, appearance target) is the recipe that used to be applied for you; it animates `BackgroundTransparency` between `0` while visible and `1` while hidden. The 160ms drop window is not derived from your transition, so exits meaningfully slower than that get truncated. With `asChild`, the motion ref and `Visible` binding move onto your single child element.

### What each part renders

Every part renders unstyled, so the whole surface is yours:

- `Toast.Viewport` — a `Frame` holding your children. No layout, no queue markup.
- `Toast.Root` — a `Frame`. Its `Visible` is presence-driven; everything else is yours.
- `Toast.Title` and `Toast.Description` — `TextLabel`s with no copy of their own. Pass `Text` from the record; they also render children, so a `uipadding` works directly on them.
- `Toast.Action` and `Toast.Close` — `TextButton`s that merge `Active`, `Selectable`, and the `Activated` handler. Neither touches the queue: call `remove(id)` yourself.

> **Provider placement**
>
> Mount `Toast.Provider` high in your UI tree, above everything that calls `useToast`. `useToast` reads the provider through context and throws if used outside it. The `Viewport` does not have to be a direct child — it only needs to be somewhere inside the same provider.

> **Action and Close do not remove the toast**
>
> `onAction` and `onClose` are plain callbacks; activating the button does not touch the queue. Call `remove(id)` inside both handlers — otherwise the toast lingers until its duration expires, and a sticky toast never leaves.

> **Toast payloads are data, not callbacks**
>
> `ToastOptions` has no action or handler field. To attach behavior to a toast, keep the payload in your own state keyed by the id `enqueue` returns, and look it up from the record's `id` when rendering a custom surface.

## API reference

### Toast.Provider

| Prop | Type | Description |
| --- | --- | --- |
| `defaultDurationMs` | `number` | Fallback lifetime for toasts without their own durationMs. Clamped to >= 0; 0 makes omitted-duration toasts sticky. Defaults to 4000. |
| `maxVisible` | `number` | Maximum toasts rendered at once; the rest wait in the queue. Clamped to >= 1. Defaults to 3. |
| `children` | `React.ReactNode` | The UI tree that enqueues toasts and renders the viewport. |

### useToast

Returns the imperative API for the nearest provider. Throws when called outside a `Toast.Provider`.

| Prop | Type | Description |
| --- | --- | --- |
| `toasts` | `Array<ToastRecord>` | The full queue in arrival order, including toasts waiting beyond the visible cap. |
| `visibleToasts` | `Array<ToastRecord>` | The capped slice currently eligible to render. Map over this in custom viewports. |
| `enqueue` | `(options: ToastOptions) => string` | Appends a toast and returns its id (auto-generated as toast-N unless options.id is set). |
| `remove` | `(id: string) => void` | Starts the exit for a visible toast, or drops a still-queued toast instantly. No-op if the toast is already exiting. |
| `clear` | `() => void` | Empties the queue immediately, without exit motion. |

`ToastOptions` accepts `id`, `title`, `description`, and `durationMs` — all optional, all plain data. Each queue entry is a `ToastRecord` carrying those fields plus `createdAtMs` and, once leaving, `exiting`/`exitStartedAtMs`; custom viewports read `id`, `title`, `description`, and `exiting` from it.

The package also exports the provider's pure queue helpers — `enqueueToast`, `dequeueToast`, `getVisibleToasts`, and `pruneExpiredToasts` — which are useful for testing custom viewports against the exact scheduling rules.

### Toast.Viewport

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Render the single child element instead of the frame the part renders. You map visibleToasts to Toast.Root either way. |
| `children` | `React.ReactNode` | Extra content appended after the rendered toasts, or the single element when asChild is set. |

### Toast.Root

| Prop | Type | Description |
| --- | --- | --- |
| `visible` | `boolean` | Drives the shown/hidden motion state. Wire to !record.exiting in custom viewports. Defaults to true. |
| `transition` | `ResponseMotionConfig` | Show/hide motion. None by default; createToastResponseRecipe() gives the 0.14s settle. Exits slower than ~160ms are cut off by the provider's drop window. |
| `asChild` | `boolean` | Apply the Visible binding and motion ref to the single child element instead of the frame the part renders. |
| `children` | `React.ReactNode` | The toast contents. |

### Toast.Title

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Render the single child element instead of the textlabel the part renders. |
| `children` | `React.ReactElement` | The element to render, typically a textlabel showing record.title. Required when asChild is set. |

### Toast.Description

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Render the single child element instead of the textlabel the part renders. |
| `children` | `React.ReactElement` | The element to render, typically a textlabel showing record.description. Required when asChild is set. |

### Toast.Action

| Prop | Type | Description |
| --- | --- | --- |
| `onAction` | `() => void` | Called when the action button is activated. Run your action, then call remove(id) — activation does not remove the toast. |
| `asChild` | `boolean` | Merge Active, Selectable, and the Activated handler onto the single child element instead of the textbutton the part renders. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set. |

### Toast.Close

| Prop | Type | Description |
| --- | --- | --- |
| `onClose` | `() => void` | Called when the close button is activated. Wire this to remove(id) — activation does not remove the toast on its own. |
| `asChild` | `boolean` | Merge Active, Selectable, and the Activated handler onto the single child element instead of the textbutton the part renders. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set. |

## Related

- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
- [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md)
