


Watch everything for $29/month.
Join Build UI ProCarousel: Part 2
Use an animated aspect ratio to create a navigation bar inspired by iOS Photos.
Framer Motion Recipes
8 lessons · 3:08:10
Video resources
Summary
To start creating our navigation bar, let's render our images a second time in a div at the bottom of the screen:
<div className="absolute bottom-6 flex h-14">
{images.map((image) => (
<img
key={image}
src={image}
className="aspect-[3/2] h-full object-cover"
/>
))}
</div>
To center the nav bar, we'll add another wrapping div at a 3:2 aspect ratio to act as our viewport, and we'll center it inside an absolute container:
<div className="absolute inset-x-0 bottom-6 flex h-14 justify-center overflow-hidden">
<div className="flex aspect-[3/2]">
{images.map((image) => (
<img
key={image}
src={image}
className="aspect-[3/2] h-full object-cover"
/>
))}
</div>
</div>
Now we can use the x
property of our viewport to animate the nav bar left and right, just like we did for the main carousel:
<motion.div
animate={{ x: `-${index * 100}%` }}
className="flex aspect-[3/2]"
>
{images.map((image) => (
<img
key={image}
src={image}
className="aspect-[3/2] h-full object-cover"
/>
))}
</motion.div>
Just like that, our navbar tracks the current index
as we cycle through our photos!
Clicking the nav bar
To make the nav bar images clickable, let's wrap each image in a button. We need to set flex-shrink
to 0
since buttons without content have no intrinsic width.
<motion.div
animate={{ x: `-${index * 100}%` }}
className="flex aspect-[3/2]"
>
{images.map((image) => (
<button key={image} className="shrink-0">
<img
key={image}
src={image}
className="aspect-[3/2] h-full object-cover"
/>
</button>
))}
</motion.div>
Now we can add a click handler to set the current index to the clicked image:
<motion.div
animate={{ x: `-${index * 100}%` }}
className="flex aspect-[3/2]"
>
{images.map((image, i) => (
<button onClick={() => setIndex(i)} key={image} className="shrink-0">
<img
key={image}
src={image}
className="aspect-[3/2] h-full object-cover"
/>
</button>
))}
</motion.div>
Done! Since we have a well-structured component that derives all its UI from state, we can easily introduce new ways to change the state like this, and the rest of our output and animations render consistently.
Dynamic aspect ratio
Let's make the button's aspect ratio dynamic. The active photo will have a full 3:2 aspect ratio, and the inactive photos will have a narrower 1:3 aspect ratio. We can also drop the aspect ratio from the image so inherits the button's size.
<button
key={image}
onClick={() => setIndex(i)}
className={`${i === index ? "aspect-[3/2]" : "aspect-[1/3]"} shrink-0`}
>
<img key={image} src={image} className="h-full object-cover" />
</button>
This breaks the x
calculation on our animated viewport – we need to account for the new size of the inactive photos.
We can divide our transform by 1/3
and multiply it by 3/2
to properly scale our x
calculation:
<motion.div
animate={{ x: `-${index * 100 * (1 / 3 / (3 / 2))}%` }}
className="flex aspect-[3/2]"
/>
This fixes the center alignment of our navbar.
Since these numbers are now magic numbers that need to match the classes we used in our button, let's parameterize them:
let collapsedAspectRatio = 1 / 3;
let fullAspectRatio = 3 / 2;
Now we can use them in our JSX. We'll also use style
instead of className
on our container and buttons for the aspect ratio so that we can directly use our new variables.
<motion.div
animate={{
x: `-${index * 100 * (collapsedAspectRatio / fullAspectRatio)}%`,
}}
style={{
aspectRatio: fullAspectRatio
}}
className="flex"
/>
<button
key={image}
onClick={() => setIndex(i)}
style={{
aspectRatio: i === index ? fullAspectRatio : collapsedAspectRatio
}}
className="shrink-0"
>
{/* ... */}
</button>
Now for the fun part – let's animate the button's aspect ratio by making it a motion.button
and replacing style
with animate
:
<motion.button
key={image}
onClick={() => setIndex(i)}
animate={{
aspectRatio: i === index ? fullAspectRatio : collapsedAspectRatio,
}}
className="shrink-0"
>
<img key={image} src={image} className="h-full object-cover" />
</motion.button>
Looks fantastic!
Adding margin to the active image
Let's add some separation around the active button with some left and right margin:
<motion.button
key={image}
onClick={() => setIndex(i)}
animate={{
aspectRatio: i === index ? fullAspectRatio : collapsedAspectRatio,
marginLeft: i === index ? "12%" : 0,
marginRight: i === index ? "12%" : 0,
}}
className="shrink-0"
>
{/* ... */}
</motion.button>
This also breaks our x
caluculation. Since we're using percentages we can fix it by adding in our new margin:
<motion.div
animate={{
x: `-${index * 100 * (collapsedAspectRatio / fullAspectRatio) + 12}%`,
}}
style={{
aspectRatio: fullAspectRatio
}}
className="flex"
/>
12
is a new magic number so let's parameterize it as well:
let collapsedAspectRatio = 1 / 3;
let fullAspectRatio = 3 / 2;
let margin = 12;
...and replace it in our JSX:
<motion.button
key={image}
onClick={() => setIndex(i)}
animate={{
aspectRatio: i === index ? fullAspectRatio : collapsedAspectRatio,
marginLeft: i === index ? `${margin}%` : 0,
marginRight: i === index ? `${margin}%` : 0,
}}
className="shrink-0"
>
{/* ... */}
</motion.button>
<motion.div
animate={{
x: `-${index * 100 * (collapsedAspectRatio / fullAspectRatio) + margin}%`,
}}
style={{
aspectRatio: fullAspectRatio
}}
className="flex"
/>
Works great!
Refactoring to variants
Our button animation code is getting a little hard to read, so let's refactor the animate
prop to use variants.
We'll define an active
and inactive
variant:
<motion.button
key={image}
onClick={() => setIndex(i)}
animate={i === index ? "active" : "inactive"}
variants={{
active: {
aspectRatio: fullAspectRatio,
marginLeft: `${margin}%`,
marginRight: `${margin}%`,
},
inactive: {
aspectRatio: collapsedAspectRatio,
marginLeft: 0,
marginRight: 0,
},
}}
className="shrink-0"
>
{/* ... */}
</motion.button>
Much easier to read – and change. Let's set the opacity to dim inactive buttons:
<motion.button
animate={i === index ? "active" : "inactive"}
variants={{
active: {
aspectRatio: fullAspectRatio,
marginLeft: `${margin}%`,
marginRight: `${margin}%`,
opacity: 1,
},
inactive: {
aspectRatio: collapsedAspectRatio,
marginLeft: 0,
marginRight: 0,
opacity: 0.5,
},
}}
whileHover={{ opacity: 1 }}
/>
We can also add whileHover
to fade in the buttons on hover.
Disabling mount animations
Since we haven't specified an initial
prop, Framer Motion will use the default position in the DOM as the starting point for our nav bar elements' animations. In our case, we don't want any animations to run on mount, so let's disable them from our animated viewport and buttons by setting initial
to false
:
<motion.div
initial={false}
animate={{
x: `-${index * 100 * (collapsedAspectRatio / fullAspectRatio) + margin}%`,
}}
/>
<motion.button
initial={false}
animate={i === index ? "active" : "inactive"}
/>
Now we have a clean initial render.
Adding a gap between inactive photos
Since our nav bar is laying out our photos using flex
, we can use the gap
property to add some space in between the inactive photos.
Let's use the style
tag so we can specify it in percentage terms:
<motion.div
animate={{
x: `-${
index * 100 * (collapsedAspectRatio / fullAspectRatio) + margin
}%`,
}}
style={{
aspectRatio: fullAspectRatio,
gap: "2%",
}}
>
{/* ... */}
</motion.div>
We also need to account for the gap in our x
calculation. The fix is to add one gap for each inactive photo to the left of the center viewport. We can do this by multiplying the gap of 2
by the current index
:
<motion.div
animate={{
x: `-${
index * 100 * (collapsedAspectRatio / fullAspectRatio) +
margin +
index * 2
}%`,
}}
style={{
aspectRatio: fullAspectRatio,
gap: "2%",
}}
>
{/* ... */}
</motion.div>
Another magic number – let's parameterize it:
let collapsedAspectRatio = 1 / 3;
let fullAspectRatio = 3 / 2;
let margin = 12;
let gap = 2;
...and replace it:
<motion.div
animate={{
x: `-${
index * 100 * (collapsedAspectRatio / fullAspectRatio) +
margin +
index * gap
}%`,
}}
style={{
aspectRatio: fullAspectRatio,
gap: `${gap}%`,
}}
>
{/* ... */}
</motion.div>
Everything's lined up perfectly!
Adding keyboard navigation
Let's take advantage of our work and see how easy it is to add keyboard navigation to our new nav bar.
We'll use the react-use-keypress
library:
import useKeypress from "react-use-keypress";
export default function Page() {
let [index, setIndex] = useState(0);
useKeypress("ArrowRight", () => {
if (index < images.length - 1) {
setIndex(index + 1);
}
});
useKeypress("ArrowLeft", () => {
if (index > 0) {
setIndex(index - 1);
}
});
return (
<MotionConfig transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}>
{/* ... */}
</MotionConfig>
);
}
Doesn't get much easier than that! Our nav bar and main carousel respond to our key presses, and everything stays in sync.
This is a great example of how powerful the React paradigm is. Moving from our initial carousel to the animated version we ended up with, we didn't have to add any new React state. Every part of our UI, including the animation, is derived from the single index
property. This is really a shining example of the power of declarative rendering in React.
Follow these principles of maintaining a single source of truth and deriving as much functionality as you can from it in your own work to keep your components clean and extensible, and your UI consistent.
Join Build UI Pro to access this video's summary and source code.



Join Build UI Pro
Watch every video, support our work, and get exclusive perks!
Build UI is the new home for all our ideas. It will eventually have hundreds of premium videos and a thriving community, but right now it's the early days.
If you like what you see and you've ever wanted to support our work, subscribe today and start enjoying all the perks of becoming a member!
$29/month
Watch everything for $29/month. Cancel anytime.
What you'll get as a Build UI Pro member
Full access to all Build UI videos
Get full access to all of our premium video content, updated monthly.
Private Discord
Ask questions and get answers from Sam, Ryan and other pro members.
Video summaries with code snippets
Easily reference videos with text summaries and copyable code snippets.
Source code
View the source code for every video, right on GitHub.
Invoices and receipts
Get reimbursed from your employer for becoming a better coder!