Carousel: Part 2
Carousel: Part 2

Watch everything for $29/month.

Join Build UI Pro

Carousel: Part 2

Use an animated aspect ratio to create a navigation bar inspired by iOS Photos.

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