Latticecomponents

Toast

A notification queue owning scheduling, visibility limits and exit timing.

@lattice-ui/react-toastStable directionimport Toastdepends on runtime, layer, motion

Toast is the primitive for transient, non-blocking notifications. A Provider owns the queue: enqueuing, the visible-count limit, per-toast duration and exit timing. Your component renders each surface and reacts to actions. You drive it imperatively with useToast from anywhere inside the provider.

Preview

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, and a viewport that renders the queue. enqueue returns the new toast’s id.

The viewport is where the work is. Map visibleToasts, give each record a Toast.Root, and wire onExitComplete to finalize so a toast drops only once its exit finishes. visible={!record.exiting} 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 and durationMs. Never callbacks. Key the pending undo by the id enqueue returns, then wire Toast.Action to the stored payload.

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, falling back to the provider’s defaultDurationMs. A durationMs of 0 or less makes it sticky until something calls remove or clear. Pass your own id so another code path can clear it later.

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 render at once, and the rest wait in order. Here six pickups arrive at once with maxVisible={2}.

A toast’s clock starts at enqueue, but expiry is only evaluated inside the visible window. One that waited longer than its durationMs exits almost immediately on surfacing. Under bursts, lengthen the duration, summarise, or go sticky.

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 takes no position props of its own, so put it inside an anchored wrapper frame. The stack direction and gap come from the layout inside it, and Viewport asChild gives you a different instance class than the frame it renders.

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() animates BackgroundTransparency between 0 and 1 over 0.14s.

Keep the duration at or under about 0.16s — the provider drops exiting toasts after a fixed ~160ms window regardless of your transition.

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

enqueue(options) appends a toast and returns its id, auto-generated as toast-N unless you pass one. remove(id) starts its exit, or drops it instantly if it is still beyond the visible cap. It is a no-op on one already exiting. clear() empties the queue, skipping exit motion.

While any toasts exist the provider runs a Heartbeat connection pruning expired and finished toasts each frame, disconnecting once the queue drains.

Visibility limit

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

Duration and expiry

Each toast expires after its own durationMs, falling back to defaultDurationMs (default 4000). A duration of 0 or less makes it sticky — removable only through remove, a wired-up Toast.Close, or clear.

Expiry is measured from createdAtMs but only checked inside the visible window. Toasts beyond the cap never expire in the queue, yet one that waited longer than its duration exits on nearly its first visible frame.

Exit timing and motion

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

Toast.Root runs no motion unless you pass a transition. createToastResponseRecipe() (0.14s, appearance target) animates BackgroundTransparency between 0 while visible and 1 while hidden. The 160ms drop window is not derived from your transition, so slower exits get truncated. With asChild, the motion ref and Visible binding move onto your 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 whose Visible is presence-driven. Everything else is yours.
  • Toast.Title and Toast.Description — TextLabels with no copy of their own. They render children, so a uipadding works directly on them.
  • Toast.Action and Toast.Close — TextButtons merging Active, Selectable and the Activated handler. Neither touches the queue.

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 and 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. They 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.