Every animatable Lattice part takes a typed transition prop. You almost never hand-author the
config that goes into it. @lattice-ui/react-motion ships recipes: factory functions returning
a tuned config, with the correct timing and target contract baked in. This page is the catalog —
which recipes exist, which parts they suit, and how to tune one.
Since 0.7.0 no primitive ships default presence motion. A Content or Indicator part with no transition still mounts, unmounts and holds its content until an exit finishes — it just does so instantly.
Response motion following a computed value is the exception, because there the motion is the behavior. Progress.Indicator settles its fill by default, and the slider and switch thumbs follow their values with recipes you cannot replace. For those, transition overrides a default. Everywhere else it supplies one.
Read Presence and motion first for the concepts behind all of this: presence vs response vs feedback, target contracts, and the hooks themselves. Full signatures live in the Motion reference.
Which config does a part take?
The transition prop’s type tells you what kind of motion the part runs:
PresenceMotionConfig— reveal and exit motion for parts that mount and unmount, withinitial,revealandexitsteps. Every content and indicator part takes one.ResponseMotionConfig— settling motion for parts that stay mounted and ease between states, with a singlesettleintent.Progress.IndicatorandToast.Roottake one.
A part that appears and disappears wants a presence config. One that slides or recolours while staying on screen wants a response config. Passing the wrong kind is a type error.
Some parts own presence timing without exposing a transition. Dialog.Overlay is the one you are most likely to hit — an unstyled overlay has nothing to fade. Animate a dim by putting an element inside it.
Recipe catalog
Presence recipes return a PresenceMotionConfig and response recipes return a
ResponseMotionConfig. Feedback recipes return a FeedbackEffectConfig for
useFeedbackEffect in custom components.
| Recipe | Animates | Pair it with |
|---|---|---|
createSurfaceRevealRecipe(offsetY?, duration?) | Upward slide + BackgroundTransparency fade | Tabs.Content, Accordion.Content |
createCanvasGroupRevealRecipe(offsetY?, duration?) | Upward slide + GroupTransparency fade | Your own canvasgroup, through asChild or usePresenceMotion |
createOverlayFadeRecipe(duration?) | Backdrop fade to half transparency | Your own element inside Dialog.Overlay |
createPopperEntranceRecipe(placement?, distance?, duration?) | Directional slide-in from the anchor side + fade | Tooltip.Content, Menu.Content, ContextMenu.Content, Combobox.Content, Popover.Content, Select.Content |
createCanvasGroupPopperEntranceRecipe(placement?, distance?, duration?) | Same, via GroupTransparency | Your own canvasgroup, supplied to an anchored part through asChild |
createIndicatorRevealRecipe(size, duration?) | Grow from zero to size + fade | Checkbox.Indicator, RadioGroup.Indicator |
createIndicatorSettleRecipe(duration?) | Size settling on an indicator wrapper | Custom selection indicators |
createSliderThumbResponseRecipe(isDragging, duration?) | Thumb position — instant while dragging, eased on release | Slider thumb |
createToggleResponseRecipe(duration?) | Layout settling for on/off thumbs | Switch-style toggles |
createSelectionResponseRecipe(duration?) | Color/appearance settling on selection | Checkbox, Radio visuals |
createFieldResponseRecipe(duration?) | Calm appearance settling for form fields | Text fields |
createProgressResponseRecipe(duration?) | Fill size settling | Progress.Indicator |
createToastResponseRecipe(duration?) | Appearance settling between toast states | Toast.Root |
createPressFeedbackEffect(duration?) | Expressive accent on press, calm recover | Buttons, pressables |
createFocusAccentEffect(duration?) | Slower accent on focus, quick recover | Focus rings |
Every recipe also sets the right target contract: appearance for the overlay fade,
offset-wrapper for the reveal and popper recipes, size-wrapper for the indicator. That is much
of why a recipe beats a hand-written config — the ownership boundary comes for free.
Dialog.Content is the one part with no default recipe. It renders a full-screen Frame, so a
transition can slide it but has no property that fades its descendants. Write a Position-only
config and fade the overlay or your own elements, or pass asChild with a canvasgroup. See
Fading a dialog.
Giving a component a transition
Pass a recipe’s result straight to transition. With no arguments it uses the house tokens — an
8-pixel rise over a 0.12s reveal, exiting at 0.8x that. Call the same factory with arguments for
more:
import { createSurfaceRevealRecipe } from "@lattice-ui/react-motion";import { Tabs } from "@lattice-ui/react-tabs";
const PANEL_REVEAL = createSurfaceRevealRecipe(16, 0.45);
export function ShopTabs() { return ( <Tabs.Root defaultValue="weapons"> <Tabs.List> <Tabs.Trigger value="weapons" /> <Tabs.Trigger value="armor" /> </Tabs.List>
<Tabs.Content value="weapons" transition={PANEL_REVEAL} asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(420, 220)} /> </Tabs.Content> <Tabs.Content value="armor" transition={PANEL_REVEAL} asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(420, 220)} /> </Tabs.Content> </Tabs.Root> );}Every part uses the config as-is, with nothing merged underneath. A partial config animates only the steps it defines, and where a part has a default, yours replaces it wholesale. To vary one step, spread the recipe and replace that step.
The same pattern works for indicators. createIndicatorRevealRecipe takes the size the indicator should settle at, so it has to match the box you are rendering:
import { Checkbox } from "@lattice-ui/react-checkbox";import { createIndicatorRevealRecipe } from "@lattice-ui/react-motion";
export function LargeCheckbox(props: { checked: boolean; onCheckedChange: (checked: boolean) => void }) { return ( <Checkbox.Root checked={props.checked} onCheckedChange={props.onCheckedChange}> <Checkbox.Indicator transition={createIndicatorRevealRecipe(UDim2.fromOffset(18, 18), 0.14)} /> </Checkbox.Root> );}Response-driven parts work the same way, just with a response recipe:
import { Progress } from "@lattice-ui/react-progress";import { createProgressResponseRecipe } from "@lattice-ui/react-motion";
export function SlowProgress(props: { value: number }) { return ( <Progress.Root value={props.value}> <Progress.Indicator transition={createProgressResponseRecipe(0.25)} /> </Progress.Root> );}Tuning beyond the factory arguments
Recipe factories expose the knobs that are safe to turn — a distance in pixels, a duration in
seconds, a reveal size. Each step’s tempo and tone are baked in as the house style.
When a recipe is almost right, spread its result and override just the step you care about instead of authoring a config from scratch. The recipe’s target contract and remaining steps carry over:
import { createSurfaceRevealRecipe, type PresenceMotionConfig } from "@lattice-ui/react-motion";
const base = createSurfaceRevealRecipe(8, 0.3);
const snappyExit: PresenceMotionConfig = { ...base, exit: { values: base.exit!.values, intent: { duration: 0.08, tempo: "swift", tone: "calm" }, },};
<Tabs.Content value="weapons" transition={snappyExit} />;Two constraints to keep in mind when editing steps:
- Match the transparency property to the instance. No primitive renders a
CanvasGroup, so the canvas-group recipes only fit one you supply throughasChild. Everything else fadesBackgroundTransparency, which reaches an instance’s own background rather than its children. - Stay inside the target contract. Adding
Positionvalues to a recipe whose target isappearanceis rejected and reported as a diagnostic. Keep the recipe’stargetwhen you keep its properties.
Reduced motion is automatic
You do not opt recipes into reduced motion — every hook reads the ambient MotionPolicy. When
motion is disabled, configs collapse to instant property writes: the final values still land, exits
still fire onExitComplete, and settling still reaches its target.
The policy disables motion when you set it explicitly, or when the player has Roblox’s system
reduced-motion setting on — which MotionProvider respects by default. useSystemReducedMotion and
useMotionPolicy read the resolved state:
import { MotionProvider, useMotionPolicy } from "@lattice-ui/react-motion";
export function App(props: { forceReducedMotion: boolean; children: React.ReactNode }) { return ( <MotionProvider disableAllMotion={props.forceReducedMotion}> {props.children} </MotionProvider> );}
function DebugMotionState() { const policy = useMotionPolicy(); return <textlabel Text={policy.disableAllMotion ? "motion off" : "motion on"} Size={UDim2.fromOffset(120, 20)} />;}useMotionPolicy folds the system setting into its result, so there is no reduced-motion branch to
write in your components. Build the animated path and the instant path falls out of the policy.
Related
- Presence and motion — the concepts: hooks, phases, target contracts
- Motion reference — full signatures for every recipe, hook, and type
- Dialog
- Checkbox
- Progress