Latticecomponents

Toast

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

@lattice-ui/react-toastStable directionimport Toastdepends 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.

Edit

Import

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.

Toast anatomy

Toast anatomy
<Toast.Provider>
<Toast.Viewport>
<Toast.Root>
<Toast.Title />
<Toast.Description />
<Toast.Action />
<Toast.Close />
</Toast.Root>
</Toast.Viewport>
</Toast.Provider>
PartRequiredResponsibility
Toast.ProvideryesOwns the queue, default duration, visible cap, and exit timing; exposes useToast.
Toast.ViewportyesThe container the stack lives in. Renders your children — supply the layout and the queue.
Toast.RootnoA single toast surface that animates between visible and hidden.
Toast.TitlenoThe toast heading.
Toast.DescriptionnoThe toast supporting text.
Toast.ActionnoA button that runs onAction when activated.
Toast.ClosenoA 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.

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:

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>
);
}

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.

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.

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.

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.

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.

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.DescriptionTextLabels 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.CloseTextButtons that merge Active, Selectable, and the Activated handler. Neither touches the queue: call remove(id) yourself.

API reference

Toast.Provider

PropTypeDescription
defaultDurationMsnumberFallback lifetime for toasts without their own durationMs. Clamped to >= 0; 0 makes omitted-duration toasts sticky. Defaults to 4000.
maxVisiblenumberMaximum toasts rendered at once; the rest wait in the queue. Clamped to >= 1. Defaults to 3.
childrenReact.ReactNodeThe 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.

PropTypeDescription
toastsArray<ToastRecord>The full queue in arrival order, including toasts waiting beyond the visible cap.
visibleToastsArray<ToastRecord>The capped slice currently eligible to render. Map over this in custom viewports.
enqueue(options: ToastOptions) => stringAppends a toast and returns its id (auto-generated as toast-N unless options.id is set).
remove(id: string) => voidStarts the exit for a visible toast, or drops a still-queued toast instantly. No-op if the toast is already exiting.
clear() => voidEmpties 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

PropTypeDescription
asChildbooleanRender the single child element instead of the frame the part renders. You map visibleToasts to Toast.Root either way.
childrenReact.ReactNodeExtra content appended after the rendered toasts, or the single element when asChild is set.

Toast.Root

PropTypeDescription
visiblebooleanDrives the shown/hidden motion state. Wire to !record.exiting in custom viewports. Defaults to true.
transitionResponseMotionConfigShow/hide motion. None by default; createToastResponseRecipe() gives the 0.14s settle. Exits slower than ~160ms are cut off by the provider's drop window.
asChildbooleanApply the Visible binding and motion ref to the single child element instead of the frame the part renders.
childrenReact.ReactNodeThe toast contents.

Toast.Title

PropTypeDescription
asChildbooleanRender the single child element instead of the textlabel the part renders.
childrenReact.ReactElementThe element to render, typically a textlabel showing record.title. Required when asChild is set.

Toast.Description

PropTypeDescription
asChildbooleanRender the single child element instead of the textlabel the part renders.
childrenReact.ReactElementThe element to render, typically a textlabel showing record.description. Required when asChild is set.

Toast.Action

PropTypeDescription
onAction() => voidCalled when the action button is activated. Run your action, then call remove(id) — activation does not remove the toast.
asChildbooleanMerge Active, Selectable, and the Activated handler onto the single child element instead of the textbutton the part renders.
childrenReact.ReactElementThe element to render. Required when asChild is set.

Toast.Close

PropTypeDescription
onClose() => voidCalled when the close button is activated. Wire this to remove(id) — activation does not remove the toast on its own.
asChildbooleanMerge Active, Selectable, and the Activated handler onto the single child element instead of the textbutton the part renders.
childrenReact.ReactElementThe element to render. Required when asChild is set.