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:
| Role | Motion may own | Use when |
|---|---|---|
appearance | Visual props only — BackgroundTransparency, *Color3, *Transparency, Rotation, GroupTransparency | Color/opacity changes on a node whose geometry is laid out elsewhere. |
offset-wrapper | Position (plus appearance) | An isolated wrapper whose Position is motion-owned — slide-in surfaces. |
size-wrapper | Size (plus appearance) | An isolated wrapper whose Size is motion-owned — indicators that grow. |
layout | AnchorPoint, Position, Size (plus appearance) | The component has deliberately handed geometry to motion. |
custom | Only the keys listed in allowProperties | You 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"],});AbsolutePosition, AbsoluteSize, AutomaticSize, LayoutOrder, Parent, Visible and ZIndex are reserved for layout ownership and rejected regardless of role. Animating one is dropped and reported as a diagnostic, rather than silently fighting your layout. If motion needs to own geometry, animate a dedicated wrapper instead of the laid-out node.
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.
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:
| Prop | Type | Description |
|---|---|---|
| target | MotionTargetContract | Ownership boundary for the animated instance. Recipes set this for you. |
| initial | MotionProperties | Property snapshot applied before the reveal runs (the hidden starting state). |
| reveal | MotionStep | Timed step toward the visible state. Omit to appear instantly. |
| exit | MotionStep | Timed 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 throughGroupTransparency, for acanvasgroupyou own.createOverlayFadeRecipe()— backdrop fade (appearancetarget).createPopperEntranceRecipe(placement)— directional entrance for anchored content, withcreateCanvasGroupPopperEntranceRecipe(placement)as thecanvasgroupvariant.createIndicatorRevealRecipe(size)— grow-from-zero for indicators (size-wrapper).
The canvas-group recipes animate GroupTransparency, which only exists on a CanvasGroup. No primitive renders one, so they are for a canvasgroup you attach the ref to yourself. The plain surface recipes animate BackgroundTransparency, which fades one instance’s own background. Match the recipe to the instance, or the write is rejected by the target contract.
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.
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.
useResponseMotion writes whichever set matches the current state, so both objects should describe the same properties. Otherwise a colour set only in active never returns when the state flips back.
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.
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:
import { MotionProvider } from "@lattice-ui/react-motion";
export function App(props: { reduceMotion: boolean; children: React.ReactNode }) { return ( <MotionProvider disableAllMotion={props.reduceMotion}> {props.children} </MotionProvider> );}Because disabled motion still applies the final values and fires onExitComplete, you never special-case reduced motion in component logic. Build the animated path. The instant one falls out.
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.