@lattice-ui/react-toastStable directionimport Toastdepends on runtime, layer, motionToast 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
import { React } from "@lattice-ui/react-runtime";import { mergeGuiProps, Text, useTheme } from "@lattice-ui/react-style";import { Toast, useToast } from "@lattice-ui/react-toast";import { buttonRecipe } from "../../../playground/src/client/theme/recipes";
function ToastExampleContent() { const { theme } = useTheme(); const toast = useToast();
return ( <frame BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)}> <textbutton {...(mergeGuiProps(buttonRecipe({ intent: "surface", size: "md" }, theme), { AnchorPoint: new Vector2(0.5, 0), Event: { Activated: () => { toast.enqueue({ title: "Event created", description: "Friday, July 18 at 5:57 PM", }); }, }, Position: UDim2.fromScale(0.5, 0), Size: UDim2.fromOffset(140, 40), Text: "Show toast", TextSize: theme.typography.labelSm.textSize, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> </textbutton>
<Toast.Viewport asChild> <frame BackgroundTransparency={1} Position={UDim2.fromOffset(0, 56)} Size={UDim2.fromOffset(340, 220)}> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[8])} SortOrder={Enum.SortOrder.LayoutOrder} /> {toast.visibleToasts.map((record) => ( <Toast.Root asChild key={record.id} onExitComplete={() => toast.finalize(record.id)} visible={!record.exiting} > <frame BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Size={UDim2.fromOffset(340, 64)} > <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <frame AnchorPoint={new Vector2(0, 0.5)} BackgroundColor3={theme.colors.accent} BorderSizePixel={0} Position={new UDim2(0, 12, 0.5, 0)} Size={UDim2.fromOffset(3, 36)} > <uicorner CornerRadius={new UDim(1, 0)} /> </frame> <Toast.Title asChild> <Text BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Position={UDim2.fromOffset(28, 14)} Size={UDim2.fromOffset(266, 18)} Text={record.title ?? ""} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </Toast.Title> <Toast.Description asChild> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(28, 35)} Size={UDim2.fromOffset(266, 16)} Text={record.description ?? ""} TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </Toast.Description> <Toast.Close asChild onClose={() => toast.remove(record.id)}> <textbutton AutoButtonColor={false} BackgroundTransparency={1} BorderSizePixel={0} Position={UDim2.fromOffset(306, 14)} Size={UDim2.fromOffset(18, 18)} Text="✕" TextColor3={theme.colors.textSecondary} TextSize={12} /> </Toast.Close> </frame> </Toast.Root> ))} </frame> </Toast.Viewport> </frame> );}
function ToastExample() { return ( <Toast.Provider defaultDurationMs={5000} maxVisible={3}> <ToastExampleContent /> </Toast.Provider> );}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.Viewport renders its children and nothing else, so a bare viewport shows nothing. Map useToast().visibleToasts onto Toast.Root yourself.
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, 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.
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:
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> );}There is no open/onOpenChange on a toast — you enqueue and remove, and the provider decides what is visible. Treat the queue as the source of truth.
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.
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.
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.
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.
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.
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— aFrameholding your children. No layout, no queue markup.Toast.Root— aFramewhoseVisibleis presence-driven. Everything else is yours.Toast.TitleandToast.Description—TextLabels with no copy of their own. They render children, so auipaddingworks directly on them.Toast.ActionandToast.Close—TextButtons mergingActive,Selectableand theActivatedhandler. Neither touches the queue.
Mount Toast.Provider above everything that calls useToast, which reads it through context and throws outside it. The Viewport only needs to be somewhere inside the same provider.
onAction and onClose are plain callbacks, and activating the button does not touch the queue. Call remove(id) in both, or the toast lingers until its duration expires. A sticky one never leaves.
ToastOptions has no action or handler field. Keep the payload in your own state keyed by the id enqueue returns, and look it up from the record’s id when rendering.
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 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
| 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. |