Every animatable Lattice part takes a typed transition prop — Dialog.Content, Popover.Content, Checkbox.Indicator, Progress.Indicator, and friends all expose one. You almost never hand-author the config that goes into it: @lattice-ui/react-motion ships recipes, small factory functions that return a tuned config with the correct timing and target contract baked in. This page is the practical 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 that follows a computed value is the exception, because there the motion is the behavior: Progress.Indicator still settles its fill over 0.12s by default, and the slider thumb, slider range and switch thumb follow their values with recipes you cannot replace. For those, transition overrides a default; everywhere else it supplies one.
For the concepts behind all of this — presence vs response vs feedback, target contracts, the hooks themselves — read Presence and motion first. 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/exit motion for parts that mount and unmount. It hasinitial,reveal, andexitsteps.Dialog.Content,Popover.Content,Tooltip.Content,Menu.Content,Tabs.Content,Accordion.Content,Checkbox.Indicator, andRadioGroup.Indicatorall take one.ResponseMotionConfig— settling motion for parts that stay mounted and ease between states. It has a singlesettleintent.Progress.IndicatorandToast.Roottake one.
The rule of thumb: if the part appears and disappears, it wants a presence config; if it slides or recolors between values while staying on screen, it wants a response config. Passing the wrong kind is a type error, so you cannot mix them up silently.
Some parts own presence timing without exposing a transition prop. Dialog.Overlay is the one you are most likely to hit: an unstyled overlay has nothing to fade, so it takes no transition at all. To animate a dim, style an element inside the overlay (or pass asChild) and animate that element yourself.
Recipe catalog
Presence recipes return a PresenceMotionConfig; 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 — createOverlayFadeRecipe uses an appearance target, the reveal and popper recipes use an offset-wrapper, createIndicatorRevealRecipe a size-wrapper. That is a large part of why you should prefer a recipe over a hand-written config: the ownership boundary comes for free.
Dialog.Content is the one part with no recipe to reach for by default. 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 for it and fade the overlay or your own panel elements separately, or pass asChild with a canvasgroup and use createCanvasGroupRevealRecipe(). See Fading a dialog.
Giving a component a transition
Pass a recipe’s result straight to transition. Called with no arguments a recipe uses the house tokens — an 8-pixel rise over a 0.12s reveal, with the exit at 0.8x that. Call the same factory with arguments for a longer, larger reveal:
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 you pass as-is. Nothing is merged underneath it, so a partial config animates only the steps it defines — and where a part does have a default (Progress.Indicator), your config replaces it wholesale rather than layering over it. To vary one step of a recipe, spread it and replace that step; see Tuning beyond the factory arguments below.
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 — an offset or placement distance in pixels, a duration in seconds, a reveal size. The tempo ("instant" | "swift" | "steady" | "gentle") and tone ("calm" | "responsive" | "expressive") of each step are baked in; they are 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
CanvasGroupany more, so the canvas-group recipes only fit acanvasgroupyou supply yourself throughasChild; everything else fadesBackgroundTransparency, which reaches one instance’s own background rather than its children. On a part whose host spans the layer, likeDialog.Content, neither fade does what you want. - Stay inside the target contract. If you add
Positionvalues to a recipe whose target isappearance, the write is rejected and reported as a diagnostic. Keep the recipe’stargetwhen you keep its properties; see target contracts.
Reduced motion is automatic
You do not opt recipes into reduced motion — every hook that consumes them reads the ambient MotionPolicy. When motion is disabled, configs collapse to instant property writes: the final values still land, exits still fire onExitComplete, and response settling still reaches its target. Your overridden transitions inherit this for free.
The policy disables motion in two cases: you set it explicitly, or the player has Roblox’s system reduced-motion setting enabled. MotionProvider respects the system setting by default (respectSystemReducedMotion defaults to true), and useSystemReducedMotion / useMotionPolicy let you read the resolved state yourself:
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 — disableAllMotion is true whenever the provider disables motion or respectSystemReducedMotion is on and the player’s system setting is enabled. 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