Latticeguides

Motion recipes

A practical catalog of lattice-ui's ready-made motion configs and how to override the transition prop on any animatable component part.

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.

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 has initial, reveal, and exit steps. Dialog.Content, Popover.Content, Tooltip.Content, Menu.Content, Tabs.Content, Accordion.Content, Checkbox.Indicator, and RadioGroup.Indicator all take one.
  • ResponseMotionConfig — settling motion for parts that stay mounted and ease between states. It has a single settle intent. Progress.Indicator and Toast.Root take 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.

Recipe catalog

Presence recipes return a PresenceMotionConfig; 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 contractcreateOverlayFadeRecipe 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:

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 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:

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 — 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:

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 any more, so the canvas-group recipes only fit a canvasgroup you supply yourself through asChild; everything else fades BackgroundTransparency, which reaches one instance’s own background rather than its children. On a part whose host spans the layer, like Dialog.Content, neither fade does what you want.
  • Stay inside the target contract. If you add Position values to a recipe whose target is appearance, the write is rejected and reported as a diagnostic. Keep the recipe’s target when 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:

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 — 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.