Tutorial: Stripe.com's main navigation

9 min read. Learn about performant web animations using CSS transforms.

↓ Skip to the finished result

Welcome! This is a post in my Dev series, where I attempt to explain and recreate interesting front-end dev techiniques I run across on the web.

In this post, we'll recreate the main navigation (see movie above) from Stripe.com. What's unique about this nav is that the popover container morphs to fit the content. There is an elegance to this transition versus the traditional behavior of opening and closing a new popover entirely.

Let's start by tackling what is probably going to be the hardest part of the exercise, animating the box while simultaneously switching the content.

Part 1: Animating the box

Using width and height transitions

One way to animate the box would be to use CSS transitions to smoothly move between width, height, and left values. In the live example below, we're toggling the .next class on and off the box every second.

.box {
  left: 20px;
  width: 390px;
  height: 240px;
  transition: all 0.4s;

.box.next {
  left: 140px;
  width: 240px;
  height: 180px;

See the Pen Stripe - Main Navigation 1.0 - Animating width and height by Lokesh Dhakar (@lokesh) on CodePen.

Beware of jank. This looks like what we want on the surface. But there is one problem. We're making the browser do extra work on the main thread, and this puts us at risk of creating slowdown in our animation and having the browser drop below 60fps and feel sluggish.

So what's the culprit? Animating width, height, or any of the position values, like top or left, forces the browser to run it's Recalculate Style process, and this CPU-expensive process needs to run on every frame where the value changes.

Thankfully, there is a more performant way to do this animation...

Using transforms

The rule of thumb for creating performant animations on the web is to use the transform and opacity CSS properties. The browser is able to animate both of these properties cheaply. It does this by offloading the work from the main thread to the GPU. We'll see what this looks like under the hood later.

Let's look at transform a little closer. It's particularly handy as it can replace multiple other CSS properties that are less performant in animations:

width  -> transform: scaleX()
height -> transform: scaleY()
top    -> transform: translateY()
left   -> transform: translateX()

Let's update our previous demo to use transform:

.box {
  width: 390px;
  height: 240px;
  transform-origin: 0 0;
  transition: transform 0.4s;

.box.next {
    scaleX(calc(240 / 390))  /* scaleX(0.61) */
    scaleY(calc(180 / 240)); /* scaleY(0.75) */

It's a little more math, but not too scary. The scaleX() and scaleY() functions take a number that represents the scaling factor from the original value. So if the width of an element is 200px and you want to scale it to be 300px, you would use transform: scaleX(1.5).

It's important to note that changing the scale is not the exact same thing as changing the width and the height as it affects the element's content. We'll come back to this later.

See the Pen Stripe - Main Navigation 1.1 - Using transforms by Lokesh Dhakar (@lokesh) on CodePen.

Was the refactor worth it? We can open Chrome Devtools and do a performance audit to see how the two versions compare.

Here is a frame from our first version where we were animating the width and height properties:

Source code snippet for how to use Stripe Terminal in card that is tilted and floating with shadow.

Note the Recalcalculate Style process in the activity list and the other cascading processes it triggered. Now let's look at a frame from our updated version with the transform property being animated:

Source code snippet for how to use Stripe Terminal in card that is tilted and floating with shadow.

Zero work on the main thread. 👌

Part 2: Adding the content

Scaling issues

We had mentioned that adding a scale transform isn't exactly the same as changing the width. This is because all the child elements also get scaled. Let's add content to the box and see what this behavior looks like:

See the Pen Stripe - Main Navigation 2.0 - Scaling content by Lokesh Dhakar (@lokesh) on CodePen.

That's not going to work. Though we did get the performance win with the switch to transforms, we are going to have to rework out HTML structure.

Redoing the markup

Let's move the content outside of the box element which is being scaled. We'll create a new sibling element for the content.


<div class="box"> <!-- Has scale transform -->
  Content in here


<div class="popover">
  <div class="content">Content in here</div>
  <div class="background"></div> <!-- Has scale transform -->

Now the scale transform applied on the background element will not affect the content. Both the content and background elements have been absolutely positioned and given the same dimensions, so when layered, they fit together nicely.

Adding the other content

We'll be transitioning between three different sets of content. In this step we add the markup for each.

<div class="content">
  <div class="section section-products active">
  <div class="section section-developer">
  <div class="section section-company">

We absolutely position each of the navs so they overlap. We do this to set ourselves up for cross-fading between them. I've made all the navs visible below so you can see the positioning:

See the Pen Stripe - Main Navigation 2.1 - Content markup by Lokesh Dhakar (@lokesh) on CodePen.

Part 3: Animating between content

Cross-fading the content

To fade between content we apply an active class on the section we want to show and transition the opacity from 0 to 1.

See the Pen Stripe - Main Navigation 2.2 - Cross-fade content by Lokesh Dhakar (@lokesh) on CodePen.

Animating and positioning

To keep our demo simple, we're going to hardcode the size and position of the different nav sections, but you could get these values dynamically on page load.

// Hardcoded size and positions for each nav section
const dimensions = {
  products: { width: 490, height: 280, x: 0 },
  developers: { width: 390, height: 266, x: 100 },
  company: { width: 260, height: 296, x: 200 }

We'll use what we learned earlier about transforms and apply them to the background box and the content elements.

  • For the background, we'll use translateX() to move horizontally and scaleX() and scaleY() for adjusting the width and height.
  • For the content, we'll use translateX() to move horizontally and opacity for the fades.
// Resize and position background
backgroundEl.style.transform = `
  translateX(${ dimensions[section].x }px)
  scaleX(${ dimensions[section].width / originalWidth })
  scaleY(${ dimensions[section].height / originalHeight })

// Position content
contentEl.style.transform = `translateX(${ dimensions[section].x }px)`

// Cross-fade content by toggling `active` class
sectionEls.forEach(el => el.classList.remove('active'));

See the Pen Stripe - Main Navigation 2.3 - Resizing the box by Lokesh Dhakar (@lokesh) on CodePen.

Part 4: Adding the top nav

The hard part is done! 😅

Now let's add a top nav and wire it up to show off our animations.

<nav class="nav">
  <button class="nav-link" data-nav="products">Products</button>
  <button class="nav-link" data-nav="developers">Developers</button>
  <button class="nav-link" data-nav="company">Company</button>
navLinkEls.forEach((navLink) => {
  navLink.addEventListener('mouseenter', (event) => {
    let targetPopover = event.target.getAttribute('data-nav');
    showSection(targetPopover); // Runs the resize, position, and fade code from the prev section

headerEl.addEventListener('mouseleave', () => {

See the Pen Stripe - Main Navigation 4.0 - Add nav bar by Lokesh Dhakar (@lokesh) on CodePen.

Part 5: UI polish

Adding a 3d swing when opening

/* Set `perspective` property to enable  3d space. Header is the parent element. */
.header {
  perspective: 2000px;
.popover {
  transform-origin: center -100px; /* Axis moved up to create a larger arc of movement. */
  transform: rotateX(-15deg);
  transition: transform 0.3s;
.popover.open {
  transform: rotateX(0);

The version on the right introduces the 3d rotation on open:

Adding an arrow on top

To create the arrow, we take a square, rotate it 45 degrees and place it behind the background layer.

With the arrow added and the 3d swing effect in place, we get our final result...

🏁 Stripe.com morphing navigation

See the Pen Stripe - Main Navigation 5.1 - Add arrow by Lokesh Dhakar (@lokesh) on CodePen.

I hope you enjoyed this post and learned something new. If you did enjoy it, check out the full listing of posts for more from the Dev series.

And send me your thoughts while they are fresh in your mind. What parts did you like? What parts were confusing? What would you like to learn about next?

And lastly, follow me on Twitter to find out when the next post is up.