Latticeguides

Presence and motion

Presence reveal and exit, response settling, feedback effects, and motion target contracts.

UI motion on Roblox is property tweening over time. The hard part is not the tween but 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. And motion should collapse cleanly to an instant state change when a player has it 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, keeping it mounted through its exit.
  • Response — state-driven settling between two visual states, easing toward the active one.
  • Feedback — short one-shot accents for input: accent on, recover off.

Underpinning all three is the target contract. It declares which properties motion is allowed to own on a given instance, and it is 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 for the animated instance. When present flips to false the node is not torn down immediately: the exit runs first and onExitComplete fires when it finishes.

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, and 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

For finer control, use usePresenceMotionController, which returns { ref, phase, mounted, isExiting, isVisible, ... }. That covers keeping a node alive before it has entered, and reading the animation phase. The phase walks exited → mounted → preparing → ready → visible → exiting, so you can gate other behavior on it.

Response: settling between states

useResponseMotion(active, properties, config?) eases an instance between an active and inactive property set whenever active changes — for nodes that stay mounted and change appearance. The first application is instant. 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. With disableAllMotion set, every animation collapses to an instant property write, and presence still completes its exit so unmount logic runs. Wrap your app or a subtree in MotionProvider:

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 that is exactly a PresenceMotionConfig, and pass forceMount through so you can keep content mounted and drive motion yourself.

They ship none by default, and the lifecycle runs either way: a part with no transition still mounts, stays mounted until its exit resolves, and fires onExitComplete. 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 elements. Or pass asChild with a canvasgroup. See Fading a dialog.