Latticeguides

Motion recipes

The ready-made motion configs, and how to override `transition` on any animatable part.

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.

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, with initial, reveal and exit steps. Every content and indicator part takes one.
  • ResponseMotionConfig — settling motion for parts that stay mounted and ease between states, with a single settle intent. Progress.Indicator and Toast.Root take 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.

Recipe catalog

Presence recipes return a PresenceMotionConfig and response recipes return a ResponseMotionConfig. Feedback recipes return a FeedbackEffectConfig for useFeedbackEffect in custom components.

RecipeAnimatesPair it with
createSurfaceRevealRecipe(offsetY?, duration?)Upward slide + BackgroundTransparency fadeTabs.Content, Accordion.Content
createCanvasGroupRevealRecipe(offsetY?, duration?)Upward slide + GroupTransparency fadeYour own canvasgroup, through asChild or usePresenceMotion
createOverlayFadeRecipe(duration?)Backdrop fade to half transparencyYour own element inside Dialog.Overlay
createPopperEntranceRecipe(placement?, distance?, duration?)Directional slide-in from the anchor side + fadeTooltip.Content, Menu.Content, ContextMenu.Content, Combobox.Content, Popover.Content, Select.Content
createCanvasGroupPopperEntranceRecipe(placement?, distance?, duration?)Same, via GroupTransparencyYour own canvasgroup, supplied to an anchored part through asChild
createIndicatorRevealRecipe(size, duration?)Grow from zero to size + fadeCheckbox.Indicator, RadioGroup.Indicator
createIndicatorSettleRecipe(duration?)Size settling on an indicator wrapperCustom selection indicators
createSliderThumbResponseRecipe(isDragging, duration?)Thumb position — instant while dragging, eased on releaseSlider thumb
createToggleResponseRecipe(duration?)Layout settling for on/off thumbsSwitch-style toggles
createSelectionResponseRecipe(duration?)Color/appearance settling on selectionCheckbox, Radio visuals
createFieldResponseRecipe(duration?)Calm appearance settling for form fieldsText fields
createProgressResponseRecipe(duration?)Fill size settlingProgress.Indicator
createToastResponseRecipe(duration?)Appearance settling between toast statesToast.Root
createPressFeedbackEffect(duration?)Expressive accent on press, calm recoverButtons, pressables
createFocusAccentEffect(duration?)Slower accent on focus, quick recoverFocus 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:

ShopTabs.tsx
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:

LargeCheckbox.tsx
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:

SlowProgress.tsx
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:

Snappy exit, recipe entrance
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 through asChild. Everything else fades BackgroundTransparency, which reaches an instance’s own background rather than its children.
  • Stay inside the target contract. Adding Position values to a recipe whose target is appearance is rejected and reported as a diagnostic. Keep the recipe’s target when 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:

App.tsx
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.