Lattice guides

Portals and layers

Render overlays into the right ScreenGui, order them predictably with DisplayOrder, and coordinate outside-press dismissal — without hard-coding layout math.

An overlay has a layout problem the moment it is born inside another component. A tooltip rendered inside a scrolling list gets clipped by the list. A dialog rendered deep in a panel inherits that panel’s ZIndex band and ends up fighting siblings for stacking order. The content needs to escape its parent and render at the top of the screen tree — while its trigger stays where it logically belongs.

@lattice-ui/layer provides the three pieces that make this work: Portal moves content into the right BasePlayerGui, DismissableLayer gives each surface its own ordered ScreenGui and coordinates outside-press dismissal, and Presence keeps a node mounted long enough to animate out. Every overlay primitive — Dialog, Popover, Menu, Select, Tooltip — is built on these.

Install

The shipped components depend on this package already. Install it directly only when building a custom layered surface.

Install with pnpm
pnpm add @lattice-ui/layer

Portals: escaping the local tree

createPortal (re-exported through Portal) renders children into a target Instance instead of the position where the element appears in the tree. The trigger stays in your component; the content renders elsewhere.

Portal escapes clipping and stacking
import { Popover } from "@lattice-ui/popover";
export function HelpPopover() {
return (
<Popover.Root>
{/* This trigger can live inside a clipped, scrolling list... */}
<Popover.Trigger asChild>
<textbutton Text="?" Size={UDim2.fromOffset(28, 28)} />
</Popover.Trigger>
{/* ...but the content portals out to the top of the screen tree. */}
<Popover.Portal>
<Popover.Content>
<frame Size={UDim2.fromOffset(220, 120)} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

The portal target resolves from the nearest PortalProvider, or from an explicit container prop on the portal part. Portaling is a behavior decision, not a styling one: you portal because the content must escape clipping or stacking, not to change how it looks.

Setting the portal target with PortalProvider

PortalProvider supplies the default container and displayOrderBase for every layer beneath it through context. Mount it once near the root of your UI with the player’s PlayerGui.

App root
import { Players } from "@rbxts/services";
import { PortalProvider } from "@lattice-ui/layer";
const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui") as PlayerGui;
export function App() {
return (
<PortalProvider container={playerGui} displayOrderBase={1000}>
{/* All overlays below portal into playerGui and order from 1000 up. */}
</PortalProvider>
);
}
Prop Type Description
container BasePlayerGui The PlayerGui (or PluginGui) every layer below portals into by default.
displayOrderBase number Base DisplayOrder for generated ScreenGuis. Defaults to 1000.
children React.ReactNode Your app tree.

Layers: ordering and outside dismissal

DismissableLayer is what each overlay’s content actually mounts inside. Every layer creates its own ScreenGui and:

  • Assigns a DisplayOrder of displayOrderBase + mountOrder, so layers stack in the order they opened — the most recently opened surface sits on top.
  • Tracks a layer stack so dismissal is ordered: an outside press dismisses the topmost layer, not all of them at once. Nested overlays close from the inside out.
  • Optionally blocks interaction behind it when modal (or disableOutsidePointerEvents) is set, by rendering a full-screen Modal textbutton behind the content.
  • Reports outside interactions so you can observe — or veto — them before the surface dismisses.
A custom dismissable surface
import React from "@rbxts/react";
import { DismissableLayer } from "@lattice-ui/layer";
function QuickPanel(props: { open: boolean; onClose: () => void }) {
const boundaryRef = React.useRef<GuiObject>();
if (!props.open) {
return undefined;
}
return (
<DismissableLayer
modal={true}
contentBoundaryRef={boundaryRef}
onDismiss={props.onClose}
onPointerDownOutside={(event) => {
// Veto dismissal for a specific region if needed.
// event.preventDefault();
print("pressed outside", event.originalEvent.UserInputType);
}}
>
<frame
AnchorPoint={new Vector2(0.5, 0.5)}
Position={UDim2.fromScale(0.5, 0.5)}
Size={UDim2.fromOffset(320, 200)}
ref={boundaryRef}
/>
</DismissableLayer>
);
}
Prop Type Description
enabled boolean Whether the layer participates in the stack and dismissal. Defaults to true.
modal boolean Blocks pointer interaction behind the layer and enables outside-press dismissal.
disableOutsidePointerEvents boolean Blocks outside pointer events without the full modal semantics.
contentBoundaryRef React.MutableRefObject<GuiObject | undefined> Marks the node whose bounds define 'inside'. Presses inside it are not outside presses.
insideRefs Array<MutableRefObject<GuiObject | TextBox | undefined>> Extra nodes (e.g. the trigger) that also count as inside.
onPointerDownOutside (event: LayerInteractEvent) => void Pointer press outside the boundary, before dismissal. Call event.preventDefault() to veto.
onInteractOutside (event: LayerInteractEvent) => void Any other outside interaction, before dismissal.
onDismiss () => void Called when the layer should dismiss.

Nested overlays

When overlays stack — a menu opened from inside a dialog, a select inside a popover — the layer stack keeps them honest:

  • Each new layer mounts with a higher DisplayOrder, so the innermost surface is always on top.
  • An outside press dismisses only the topmost active layer, so closing a menu does not also close the dialog behind it.
  • The contentBoundaryRef and insideRefs of an inner layer keep presses on its own content (and trigger) from being treated as “outside”.

You generally get this for free by composing Lattice primitives. When building custom layers, register the trigger as an inside ref so clicking the trigger to toggle the surface is not misread as an outside dismiss.

Presence: surviving exit animation

When an overlay closes, unmounting it immediately would cut off any exit animation. Presence bridges the gap: it keeps the node mounted after present flips to false, exposes an isPresent flag to drive the exit, and only unmounts once the exit completes (or a fallback timeout elapses).

Presence keeps the node mounted to animate out
import { Presence } from "@lattice-ui/layer";
<Presence
present={open}
render={(state) => (
<Panel
visible={state.isPresent}
onExitComplete={state.onExitComplete}
/>
)}
/>;

render receives { isPresent, onExitComplete }. Animate based on isPresent, then call onExitComplete() when the exit finishes so Presence can drop the node. If you never call it, a fallback timer unmounts the node anyway so it can never get stuck mounted. Most components also accept forceMount to keep content mounted regardless, which is useful when you drive motion yourself.

When to portal and layer

  • The content must escape clipping or local stacking — anything floating above the regular layout.
  • Several surfaces can be open at once and need predictable stacking and dismissal.
  • The surface needs outside-press dismissal or modal blocking behind it.

When not to

  • The content is inline and belongs in the normal layout flow — do not portal it just to reorder it.
  • A single fixed-position element with no dismissal needs none of this; a plain ScreenGui with a set DisplayOrder is enough.