Skip to content

Working With Groups

Video Summary

In this video, we explore how to implement the following shared layout animation:

Below, you'll find a sandbox that includes a fully-implemented but not-yet-animated component. I'd encourage you to spend a few moments seeing if you can figure it out!

The first thing we tried was to use a standard layout animation:

<div className="inbox">
{range(numOfUnprocessedWidgets).map(
(itemNum) => {
return (
<motion.div
layout={true}
key={itemNum}
className="widget"
/>
);
}
)}
</div>
<div className="outbox">
{range(numOfProcessedWidgets).map((itemNum) => {
return (
<motion.div
layout={true}
key={itemNum}
className="widget"
/>
);
})}
</div>

This adds some motion, but not the kind we're after:

The trouble is that we need to tell Framer Motion that the element disappearing from the top box is semantically the same element as the one appearing in the bottom box. We do that with the layoutId prop.

Well, we already have a unique identifier, itemNum, which we're using for the key. Maybe we can use that?

<div className="inbox">
{range(numOfUnprocessedWidgets).map(
(itemNum) => {
return (
<motion.div
layoutId={itemNum}
key={itemNum}
className="widget"
/>
);
}
)}
</div>
<div className="outbox">
{range(numOfProcessedWidgets).map((itemNum) => {
return (
<motion.div
layoutId={itemNum}
key={itemNum}
className="widget"
/>
);
})}
</div>

Unfortunately, this doesn't quite work:

The problem is that while the key is a unique identifier within the context of both arrays, it isn't globally unique. We can see this if we include the itemNum as a child to our motion div:

Widgets 0 through 7 in the first box, and 0 through 3 in the second

Instead, what we need to do is give each of the 12 boxes a consistent ID, like this:

Widgets 0 through 7 in the first box, and 8 through 11 in the second

There are several ways to do this, but I think the cleanest way is to use our range utility:

<div className="inbox">
{range(numOfUnprocessedWidgets).map(
(itemNum) => {
return (
<motion.div
layoutId={itemNum}
key={itemNum}
className="widget"
/>
);
}
)}
</div>
<div className="outbox">
{range(numOfUnprocessedWidgets, total).map((itemNum) => {
return (
<motion.div
layoutId={itemNum}
key={itemNum}
className="widget"
/>
);
})}
</div>

As a quick reminder, the range utility produces an array with numbers between a start and end position. If we only include 1 number, we start from 0:

range(5); // [0, 1, 2, 3, 4]
range(5, 10); // [5, 6, 7, 8, 9]

For more information, check out the “Range Utility” lesson from earlier in the course.

Now, this implementation mostly works, but there are several quirks to be aware of / that you might run into.

First, we notice that the very first item isn't animating:

This is because the layoutId for that very first element is 0. Framer Motion ignores any falsy values passed to layoutId, and 0 is falsy, so it doesn't actually get set.

We want our layoutId values to be globally unique. They should never be as simple as plain numbers from 0 to 11! If we had two WidgetProcessor components, they'd interfere with each other. And with such a generic layoutId, I can imagine totally different components winding up with the same values, and producing some seriously janky results.

Instead, let's use the useId hook to come up with a unique ID, and combine it with the individual element number:

const id = React.useId();
range(numOfUnprocessedWidgets).map(
(itemNum) => {
const layoutId = `${id}-${itemNum}`;
return (
<motion.div
layoutId={layoutId}
key={layoutId}
className="widget"
/>
);
}
)

The layoutId will now be a value like :r1:-0 instead of 0, which is both a truthy value, and one guaranteed by React to be unique!

Another important issue to be aware of: we want to use the same value for layoutId and key. Even though the key doesn't have to be globally unique, strange things happen when the key and layoutId don't match.

Finally, if we want to move multiple items at once, we'll notice something kinda strange:

This happens because there's an implicit dependency between the 4 items that get transported. We need Framer Motion to calculate them as a group, rather than individually.

We can fix this by wrapping everything up in a LayoutGroup element:

import {
LayoutGroup,
motion,
} from 'framer-motion';
function WidgetProcessor({ total }) {
return (
<LayoutGroup>
{/* All the same stuff in here */}
</LayoutGroup>
);
}
export default WidgetProcessor;

I know I've spent a lot of time in these past couple of lessons talking about quirks and gotchas and edge-cases. I hope I'm not giving you the impression that Framer Motion is a constant struggle! Once you get the hang of it, it's delightful to use.

Ultimately, we're going through this stuff very quickly, because I want to show you the really cool things we can do with it. But this stuff is advanced, and it'll take some time for you to build up an intuition.

Because this isn't a Framer Motion course, I'm more focused on inspiring you / giving you a sense of what's possible. Hopefully, that serves as motivation for you to keep learning on your own!

In this video, we make good use out of our range utility function. As a reminder, you can learn more about it in the “Range Utility” lesson from earlier in the course.

Here's the sandbox that holds the initial, un-animated version:

Code Playground

import React from 'react';
import { motion } from 'framer-motion';
import { ChevronDown, ChevronUp } from 'react-feather';
import range from 'lodash.range';

import VisuallyHidden from './VisuallyHidden';

function WidgetProcessor({ total }) {
const [numOfProcessedWidgets, setNumOfProcessedWidgets] = React.useState(0);

const numOfUnprocessedWidgets = total - numOfProcessedWidgets;

function handleProcessWidget() {
if (numOfProcessedWidgets < total) {
setNumOfProcessedWidgets(numOfProcessedWidgets + 1);
}
}

function handleRevertWidget() {
if (numOfProcessedWidgets > 0) {
setNumOfProcessedWidgets(numOfProcessedWidgets - 1);
}
}

return (
<div className="wrapper">
<div className="inbox">
{range(numOfUnprocessedWidgets).map((itemNum) => {
return (
<div
key={itemNum}
className="widget"
/>
);
})}
</div>

<div className="actions">
<button onClick={handleProcessWidget}>
<VisuallyHidden>Process widget</VisuallyHidden>
<ChevronDown />
</button>
<button onClick={handleRevertWidget}>
<ChevronUp />
<VisuallyHidden>Revert widget</VisuallyHidden>
</button>
</div>

<div className="outbox">
{range(numOfProcessedWidgets).map((itemNum) => {
return (
<div
key={itemNum}
className="widget"
/>
);
})}
</div>
</div>
);
}

export default WidgetProcessor;

And here's a sandbox with the final fully-animated version:

Code Playground

import React from 'react';
import { LayoutGroup, motion } from 'framer-motion';
import { ChevronDown, ChevronUp } from 'react-feather';
import range from 'lodash.range';

import VisuallyHidden from './VisuallyHidden';

function WidgetProcessor({ total }) {
const id = React.useId();

const [
numOfProcessedWidgets,
setNumOfProcessedWidgets,
] = React.useState(0);

const numOfUnprocessedWidgets =
total - numOfProcessedWidgets;

function handleProcessWidget() {
if (numOfProcessedWidgets < total) {
setNumOfProcessedWidgets(
numOfProcessedWidgets + 4
);
}
}

function handleRevertWidget() {
if (numOfProcessedWidgets > 0) {
setNumOfProcessedWidgets(
numOfProcessedWidgets - 4
);
}
}

return (
<LayoutGroup>
<div className="wrapper">
<div className="inbox">
{range(numOfUnprocessedWidgets).map(
(itemNum) => {
const layoutId = `${id}-${itemNum}`;
return (
<motion.div
layoutId={layoutId}
key={layoutId}
className="widget"
/>
);
}
)}
</div>

<div className="actions">
<button onClick={handleProcessWidget}>
<VisuallyHidden>
Process widget
</VisuallyHidden>
<ChevronDown />
</button>
<button onClick={handleRevertWidget}>
<ChevronUp />
<VisuallyHidden>
Revert widget
</VisuallyHidden>

Two different approaches: fungible and non-fungible

Video Summary

In my years using Framer Motion, I've come to realize that there are two broad approaches we can take when building shared layout animations like this: fungible and non-fungible.

First, I should say right off the bat, this isn't related to NFTs or crypto-currency 😅 the term “fungible” means “interchangeable”.

In our WidgetProcessor component above, the widgets themselves are fungible. We can't edit the properties of a specific widget. In fact, when we think about it, we don't really have widgets at all. The only data we have is:

  • The total number of widgets (the total prop).
  • The number that have been processed (the numOfProcessedWidgets state variable).

We're deriving the elements themselves from these two values. In a very real sense, our implementation is nothing more than a fancy number visualizer. It's like how an hourglass uses grains of sand to show the current time. The grains of sand themselves aren't individually meaningful.

Now, this approach has limitations. For example, what if we wanted to allow users to click a specific widget, to process it?

Well, we can't really do this with our approach. Because the widgets are fungible, there's not really any difference, in terms of our data model, between the first one being processed or the last one being processed. We only know the number that has been processed.

If we want to be able to manipulate individual widgets, we'll need to move to a non-fungible approach.

First, we need to store data about each individual widget. I've created a widgets array, in the parent App component:

const [widgets, setWidgets] = React.useState(() => {
return range(12).map(() => {
return {
id: crypto.randomUUID(),
status: 'unprocessed',
};
});
});

Each widget is an object with a globally-unique ID and a status.

When I click on a specific widget, I invoke a function which updates the state, copying over the 11 unmodified widgets, and producing a replacement widget with the updated status:

function processWidget(id, status) {
const nextWidgets = widgets.map((widget) => {
if (widget.id !== id) {
return widget;
}
return {
...widget,
status,
};
});
setWidgets(nextWidgets);
}

Inside WidgetProcessor, I transform the big widgets array into two smaller arrays, one for each status:

const unprocessedWidgets = widgets.filter(
(widget) => widget.status === 'unprocessed'
);
const processedWidgets = widgets.filter(
(widget) => widget.status === 'processed'
);

And then, I map over those items, similar to how we were doing before:

<div className="inbox">
{unprocessedWidgets.map((widget) => {
return (
<motion.button
layoutId={widget.id}
key={widget.id}
className="widget"
onClick={() =>
processWidget(widget.id, 'processed')
}
/>
);
})}
</div>
<div className="outbox">
{processedWidgets.map((widget) => {
return (
<motion.button
layoutId={widget.id}
key={widget.id}
className="widget"
onClick={() =>
processWidget(widget.id, 'unprocessed')
}
/>
);
})}
</div>

As a nice bonus, we don't need to use React.useId anymore, since each widget is given a globally-unique ID when it's created.

In this particular situation, I do feel like this approach works a bit better, but I want to be clear: these are two equally useful approaches. In some situations, it would be a lot more trouble to grant each element a unique identity. They're different tools for different circumstances.

Here's the sandbox from the video above. Feel free to tinker with it, to get a sense of the new implementation.

NOTE: The up/down arrows haven't been implemented; this is something you'll tackle in an exercise shortly!

Code Playground

import React from 'react';
import { LayoutGroup, motion } from 'framer-motion';
import { ChevronDown, ChevronUp } from 'react-feather';
import range from 'lodash.range';

import VisuallyHidden from './VisuallyHidden';

function WidgetProcessor({ widgets, processWidget }) {
const unprocessedWidgets = widgets.filter(
(widget) => widget.status === 'unprocessed'
);
const processedWidgets = widgets.filter(
(widget) => widget.status === 'processed'
);

return (
<LayoutGroup>
<div className="wrapper">
<div className="inbox">
{unprocessedWidgets.map((widget) => {
return (
<motion.button
layoutId={widget.id}
key={widget.id}
className="widget"
onClick={() =>
processWidget(widget.id, 'processed')
}
/>
);
})}
</div>

<div className="actions">
<button>
<VisuallyHidden>
Process widget
</VisuallyHidden>
<ChevronDown />
</button>
<button>
<ChevronUp />
<VisuallyHidden>
Revert widget
</VisuallyHidden>
</button>
</div>

<div className="outbox">
{processedWidgets.map((widget) => {
return (
<motion.button
layoutId={widget.id}
key={widget.id}
className="widget"
onClick={() =>
processWidget(widget.id, 'unprocessed')
}
/>
);
})}
</div>
</div>
</LayoutGroup>
  1. TypeError: Failed to fetch at https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:214137 at Generator.next (<anonymous>) at https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:213948 at new Promise (<anonymous>) at o (https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:213693) at i (https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:213987) at v.jsdelivr.<anonymous> (https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:210630) at Generator.next (<anonymous>) at https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:208738 at new Promise (<anonymous>) at d (https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:208483) at v.jsdelivr.meta (https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:210419) at j (https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:204526) at https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:206962 at Generator.next (<anonymous>) at i (https://sandpack-bundler.vercel.app/static/js/sandbox.0997091ea.js:1:203961)