Latticeguides

Presence and motion

How lattice-ui animates GuiObjects — presence reveal/exit, response settling, feedback effects, and the target contracts that keep motion from fighting layout.

UI motion on Roblox is just property tweening over time — BackgroundTransparency easing to 0, a Position sliding into place. The hard part is not the tween; it is coordination. A surface that fades out has to stay mounted until the fade finishes. A property the layout system owns must not also be driven by an animation, or they overwrite each other every frame. And motion should collapse cleanly to an instant state change when a player has motion disabled.

@lattice-ui/react-motion is the foundation that solves this. It splits animation into three intents, each with a hook and a set of recipes:

  • Presence — reveal and exit motion tied to whether a node should exist. Keeps the node mounted through its exit.
  • Response — state-driven settling between two visual states (selected/unselected, on/off). Eases toward the active state.
  • Feedback — short one-shot accents for input (press, focus). Accent on, recover off.

Underpinning all three is the target contract, which declares which properties motion is allowed to own on a given instance — the mechanism that stops motion and layout from stepping on each other.

Import

import {
usePresenceMotion,
useResponseMotion,
useFeedbackEffect,
motionTargets,
createSelectionResponseRecipe,
createCanvasGroupRevealRecipe,
createPressFeedbackEffect,
type PresenceMotionConfig,
type ResponseMotionConfig,
type FeedbackEffectConfig,
} from "@lattice-ui/react-motion";

Like @lattice-ui/react-focus, you rarely import this when composing primitives — Dialog, Popover, and friends animate themselves. Reach for it when you build a custom surface or want motion on your own visuals.

Target contracts: who owns the property

Every motion hook animates a single Instance and, optionally, a MotionTargetContract describing the ownership boundary for that instance. The contract’s role decides which properties motion may write:

RoleMotion may ownUse when
appearanceVisual props only — BackgroundTransparency, *Color3, *Transparency, Rotation, GroupTransparencyColor/opacity changes on a node whose geometry is laid out elsewhere.
offset-wrapperPosition (plus appearance)An isolated wrapper whose Position is motion-owned — slide-in surfaces.
size-wrapperSize (plus appearance)An isolated wrapper whose Size is motion-owned — indicators that grow.
layoutAnchorPoint, Position, Size (plus appearance)The component has deliberately handed geometry to motion.
customOnly the keys listed in allowPropertiesYou need an exact, hand-picked set.

Build a contract with the motionTargets helpers (or createMotionTargetContract for custom):

import { motionTargets } from "@lattice-ui/react-motion";
const appearance = motionTargets.appearance("tile");
const slide = motionTargets.offsetWrapper("drawer");
const custom = motionTargets.custom({
label: "ring",
allowProperties: ["ImageTransparency"],
});

Presence: reveal and exit

usePresenceMotion(present, config, onExitComplete?) returns a ref you attach to the animated instance. When present flips to false, the node is not torn down immediately — the exit motion runs first, and onExitComplete fires when it finishes. This is what lets you delay unmount until the animation is done.

RevealCard.tsx
import { React } from "@rbxts/react";
import { usePresenceMotion, createCanvasGroupRevealRecipe } from "@lattice-ui/react-motion";
export function RevealCard(props: { open: boolean }) {
const [mounted, setMounted] = React.useState(props.open);
React.useEffect(() => {
if (props.open) setMounted(true);
}, [props.open]);
const ref = usePresenceMotion<CanvasGroup>(
props.open,
createCanvasGroupRevealRecipe(),
() => setMounted(false), // unmount only after the exit finishes
);
if (!mounted) return undefined;
return (
<canvasgroup
ref={ref}
AnchorPoint={new Vector2(0.5, 0.5)}
Position={UDim2.fromScale(0.5, 0.5)}
Size={UDim2.fromOffset(320, 180)}
/>
);
}

A PresenceMotionConfig has up to four parts. initial is the snapshot applied before reveal; reveal and exit are timed steps; target is the contract:

PropTypeDescription
targetMotionTargetContractOwnership boundary for the animated instance. Recipes set this for you.
initialMotionPropertiesProperty snapshot applied before the reveal runs (the hidden starting state).
revealMotionStepTimed step toward the visible state. Omit to appear instantly.
exitMotionStepTimed step toward the hidden state. Omit to disappear instantly (onExitComplete still fires).

Prefer a shared recipe over hand-rolling a config — they encode tested timing and the right target contract:

  • createSurfaceRevealRecipe() — slide-and-fade for surfaces. createCanvasGroupRevealRecipe() is the same motion through GroupTransparency, for a canvasgroup you own.
  • createOverlayFadeRecipe() — backdrop fade (appearance target).
  • createPopperEntranceRecipe(placement) — directional entrance for anchored content, with createCanvasGroupPopperEntranceRecipe(placement) as the canvasgroup variant.
  • createIndicatorRevealRecipe(size) — grow-from-zero for indicators (size-wrapper).

forceMount and the lifecycle

usePresenceMotion keeps the node mounted through exit for you. If you need finer control — keeping the node alive even before it has ever entered, or reading the animation phase — use the lower-level usePresenceMotionController, which returns { ref, phase, mounted, isExiting, isVisible, ... }. The phase walks through exited → mounted → preparing → ready → visible → exiting, so you can gate other behavior (like enabling input only once isVisible).

Response: settling between states

useResponseMotion(active, properties, config?) eases an instance between an active and inactive property set whenever active changes. Unlike presence, there is no mount/unmount story — it is for nodes that stay mounted and change appearance. The first application is instant (no animation on mount); subsequent changes settle.

SelectableChip.tsx
import { useResponseMotion, createSelectionResponseRecipe } from "@lattice-ui/react-motion";
export function SelectableChip(props: { selected: boolean; label: string }) {
const ref = useResponseMotion<TextButton>(
props.selected,
{
active: { BackgroundColor3: Color3.fromRGB(88, 142, 255) },
inactive: { BackgroundColor3: Color3.fromRGB(47, 53, 68) },
},
createSelectionResponseRecipe(),
);
return (
<textbutton
ref={ref}
Selectable
Size={UDim2.fromOffset(140, 34)}
Text={props.label}
/>
);
}

This is the exact pattern lattice-ui’s own Checkbox and Radio group use. Recipes for response motion include createSelectionResponseRecipe, createToggleResponseRecipe, createSliderThumbResponseRecipe(isDragging), createFieldResponseRecipe, and createToastResponseRecipe.

Feedback: one-shot accents

useFeedbackEffect(active, properties, config?) is for short input accents — a press depression, a focus glow. It applies an accent step when active is true and a recover step when false, both as quick one-shots.

PressableButton.tsx
import { React } from "@rbxts/react";
import { useFeedbackEffect, createPressFeedbackEffect } from "@lattice-ui/react-motion";
export function PressableButton(props: { label: string }) {
const [pressed, setPressed] = React.useState(false);
const ref = useFeedbackEffect<TextButton>(
pressed,
{
active: { BackgroundTransparency: 0.2 },
inactive: { BackgroundTransparency: 0 },
},
createPressFeedbackEffect(),
);
return (
<textbutton
ref={ref}
Size={UDim2.fromOffset(120, 36)}
Text={props.label}
Event={{
InputBegan: () => setPressed(true),
InputEnded: () => setPressed(false),
}}
/>
);
}

Respecting the motion policy

All three hooks read a MotionPolicy from context. When disableAllMotion is set, every animation collapses to an instant property write — the end state is applied immediately, with no tween, and presence still completes its exit so unmount logic runs. Wrap your app (or a subtree) in MotionProvider to control it:

App.tsx
import { MotionProvider } from "@lattice-ui/react-motion";
export function App(props: { reduceMotion: boolean; children: React.ReactNode }) {
return (
<MotionProvider disableAllMotion={props.reduceMotion}>
{props.children}
</MotionProvider>
);
}

How primitives wire all of this

The layered primitives accept a transition prop that is exactly a PresenceMotionConfig, and pass forceMount through so you can keep content mounted while you drive motion yourself. Passing transition is how you give them an entrance and exit without rebuilding the lifecycle.

They ship none by default. The lifecycle above runs either way — a part with no transition still mounts, still stays mounted until its exit resolves, and still fires onExitComplete — so adding a transition later never changes when things unmount, only what happens in between. Motion recipes lists the recipe that suits each part.

import { createPopperEntranceRecipe } from "@lattice-ui/react-motion";
// Slower, larger entrance for an anchored surface:
<Popover.Content transition={createPopperEntranceRecipe("bottom", 12, 0.2)} />

Dialog.Content is the exception: its host is a full-screen Frame, so give it a Position-only config and fade the overlay or your own panel elements — or pass asChild with a canvasgroup to fade the surface as one layer. See Fading a dialog.