Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 15.4Advanced ~20 min · shot 4 of 7

Layout Animations

In one line

Add layout and Motion animates an element between two layouts using only transforms; add layoutId and an element can travel between two places. Transforms stretch content, so children and radii need the fixes in this topic.

0/7 · 0%

Think of it like this

Moving house with a photo of each room. Before the move you photograph the living room; after, you photograph it again. You do not slide the furniture one inch at a time. You place it where it was in the first photo, then let it drift to where it is in the second. layout does this: measure, invert, play (Phase 10's FLIP, done for you).

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

layout
A prop that makes an element animate to its new size and position whenever React re-renders it, using transforms.
layoutId
A name that links two elements, so one animates from where the other was. The two should not exist at the same time.
scale distortion
The stretching of an element's children, text and rounded corners when the parent is animated with scale.
shared element
One visual thing that seems to travel between two places, such as a tab underline.
LayoutGroup
A wrapper that makes layout animations in its children coordinate, and that can namespace layoutIds with an id.
layoutScroll
A prop for scrollable parents, so layout animations inside them account for the scroll offset.

Step by step

01Resize the chip

Run the first example and press Resize with the Timeline paused. The chip is an orange pill that grows from 70 to 220 px. Slow it to 0.1× and look at the chip's first frames: its transform starts at scale(0.32, 1) (its old size), and the word Volt is squeezed to 8 px wide.

02Counter-scale the child

Press Child gets layout so it says on, then Resize again. The word keeps its width of 25 px during the whole move. Its own transform is the inverse of the chip's.

03Glide a shared element

Run the second example. Bar A and B wrap their tabs in LayoutGroup; bars C and D do not. The page presses the second tab of B and D. Slow the clock to 0.1× to catch D's underline arriving from C's bar above.

04Keep ids apart

Wrap each tab bar in <LayoutGroup id="..."> with a different id, as A and B do. The underline now stays in its own bar.

Try it yourself

  1. 1

    Move the radius out of style

    Delete style={{ borderRadius: 22 }} and add .chip { border-radius: 22px; } in the CSS. Resize with the clock paused and step: the pill's ends are squashed ellipses during the move, because Motion cannot correct a radius it does not know about. Measured: style.borderRadius stays empty.

  2. 2

    Compare the child fixes

    Set the child's layout prop to true, "position" and false in turn. Measured widths of Volt at 20 ms into the move: 25 px, 25 px and 8 px. position also keeps the word's own size changes from animating.

Code & diagrams

Scale distortion and its fix

Resize runs a layout animation. With the child's layout switch off, the word is squashed during the move; switch it on and it keeps its width. Look at the pill's rounded ends: its radius is in `style`, so Motion corrects it.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.
A shared underline, with and without LayoutGroup

The page presses the second tab on bars B and D. B (in a LayoutGroup) glides within its bar; D, sharing the global id with C, slides in from C's bar.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Share one layoutId between two tab bars

In the second example remove <LayoutGroup id="two"> around bar B and press the second tab on both B and D.

terminal
$ Slow the clock to 0.1× and watch the underlines
── what you'll see ──
D's underline now starts 58 px above its place (from B's bar) instead of 29 px (from C's), and B's own underline no longer stays put (measured). Three bars share one global id.

Myth vs fact

Myth

layout animates width and height.

Fact

It animates a transform between the old and new box. The element is already at its final size; the transform makes it look like it is not, so content can be stretched (distortion).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Layout animations are the one place where Motion's projection tree matters: each layout element joins a tree with its motion ancestors, and a parent's transform is divided out of each child's. That is how layout on the child counter-scales: the child projects against a parent that is itself being scaled.

  • ▸

    A layout animation continues from the box the element is at now. Press Resize twice, 60 ms apart: measured, the chip was 76 px wide at 259 ms and 76 px at 261 ms, just after the reversal, with no jump.

  • ▸

    layoutId also crossfades the two elements (the old fades out while the new fades in as the box moves), which suits shared elements with different content, such as a thumbnail and a large image.

Remember this

  1. 1

    The problem this solves: an element that changes width, justify-content or its place in a list jumps to its new layout in one frame. Animating width or top every frame costs layout and paint (Phase 6). Phase 10 built FLIP by hand with getBoundingClientRect; layout measures before and after every render for you, then animates a transform from the old box to the new one. The React course (Topic 11.6, /react) shows the basic call; here is how it behaves.

  2. 2

    What runs. Motion writes the transform into the element's style each frame from JavaScript. There is no WAAPI animation, so the Timeline shows no track (measured: the style attribute held translate3d(-74.9px, 0px, 0px) scale(0.319, 1) 30 ms after a 220 px chip shrank to 70 px, and 70 / 220 is 0.318). Interrupting continues from the current box.

  3. 3

    Scale distortion. The chip above is animated with scale, so its content scales too. Text in a child with no layout is squashed: measured width of the word Volt was 8 px at the start of the move (its natural width is 25 px). Fix one: give the child layout too, and Motion counter-scales it (25 px, constant, measured). Fix two: layout="position" on the child animates only its position, never its size (also 25 px, measured). Fix three, for borders: set borderRadius and boxShadow in style or animate, not in CSS. Motion corrects those two (measured: the radius in style went from 31.3% to 22.3% during the move). A radius written in a stylesheet is not corrected: measured with border-radius: 22px in CSS, style.borderRadius stayed empty while the chip was squashed to 0.32 wide.

  4. 4

    **Shared elements with layoutId.** Give two elements the same layoutId, and render only one at a time. When one disappears and the other appears, Motion animates the new one from the old one's box, with a crossfade. A tab underline is the classic case: {selected && <motion.span layoutId="underline" />} in every tab. The configurator uses it for the colour ring.

  5. 5

    **LayoutGroup.** Layout ids are global to the page. Two separate tab bars that both use layoutId="underline" collide: pressing the second tab of the second bar started its underline 29 px above, flying in from the other bar (measured, on bars 29 px apart), while the bar wrapped in <LayoutGroup id="two"> did not move from its own place. LayoutGroup id namespaces the ids. Without an id, LayoutGroup makes siblings re-measure together: a layout change in one accordion makes its neighbours animate to their new place, too.

  6. 6

    Edge cases. A scrollable ancestor shifts what the browser measures; Motion's source subtracts the scroll of ancestors marked layoutScroll, so put that prop on scroll containers (and layoutRoot on a fixed-position one). Every layout element is measured whenever its tree renders, so a very large list gets slow; layoutDependency limits measuring to when a value you name changes. Under reduced motion layout animations become instant (Topic 15.6).

Explain it without notes

01

Why does layout stretch an element's text, and what are three fixes?

02

What is layoutId, and when do you need LayoutGroup?

Practice

01

Make a card grid where clicking a card toggles it between small and large, with its title not stretching.

02

Add a highlight behind the active item of a vertical menu that glides between items.

Done when you can

  • You can add layout and say what runs underneath

  • You can name three fixes for scale distortion and apply them

  • You can build a shared element with layoutId and keep ids apart with LayoutGroup

  • You know that scroll containers need layoutScroll