Lessons Learned from Failed Demos: Pure CSS Nav Thumb Flip on Scroll

Ana Tudor Ana Tudor on

A recent CodePen Spark led me to discover this cool-looking demo. It’s an interesting effect, but it uses too much JavaScript for my taste, so I thought I could give it a CSS treatment. Plus, I felt the flip would look better if it were “hinged” to the top/bottom edge, depending on the direction in which we’re going.

About half an hour later, I had this:

Animated GIF. Shows the final result, with each nav item getting selected at a corresponding scroll progression. Once it's selected, its text gets darker and moves a bit to the right, while its image flips into view rotating around the top edge. Once an item is deselected, its text fades and moves back to the left, while its image flips out of view rotating around the bottom edge.
recording of my result

Let’s see how I did it… and what went wrong.

The Layout Basics

We have a <nav> element with n children. Since we’ll be needing this number n to make styling choices, we pass it to the CSS as a custom property. The same goes for the index i of each nav item. To make it easier for myself, I used Pug to generate the HTML from a data object – the result looks as follows:

<nav style="--n: 7">
  <a href="#" style="--i: 0">
    tiger
    <img src="tiger.jpg" alt="tiger drinking water" />
  </a>
  <a href="#" style="--i: 1">
    lion
    <img src="lion.jpg" alt="lion couple on a rock" />
  </a>
  <!-- the other cats -->
</nav>Code language: HTML, XML (xml)

It’s a pretty simple structure, just a nav wrapper around a items, each of these items containing text and an img child.

The sibling-index() and sibling-count() CSS functions are not yet a thing cross-browser, so we’re adding the item index and count as custom properties when we generate the HTML in order to pass them to the CSS. Because otherwise, the CSS does not know how many children an HTML element has.

Moving on to the CSS, our nav is using fixed positioning and made to cover all available viewport space (note that this excludes any scrollbars we might have).

nav {
  position: fixed;
  inset: 0;
}Code language: CSS (css)

The next step is to use a grid layout for it, limit the width of the grid’s one column, and middle-align this grid within the element:

nav {
  display: grid;
  grid-template-columns: min(100% - 1em, 25em);
  place-content: center;
  position: fixed;
  inset: 0;
}
Code language: CSS (css)

Note that we use 100% - 1em inside the min() to keep a little bit of space on the lateral sides of the grid to prevent it from kissing the viewport edges without adding a separate padding rule. Because why waste precious screen space on a non-essential declaration when we could find more important CSS to cram in there?

Screenshot. Shows the result so far, with the grid overlay highlighted from DevTools.
doesn’t look like much yet

We’re done with the important styles on the nav, so we move on to prettifying touches. We slap on a subtle background and give it a viewport-relative font, kept within reasonable limits by a clamp() – we don’t want the text to get so small it’s unreadable, nor do we want it to balloon on huge screens.

Screenshot. Shows the same grid from before, but the font got bumped up and there's a less bright background behind.
well, that makes a bit of a difference

With the nav styles settled, we turn our attention to the links, for which we use a flex layout. This allows us to middle-align the text content and the img vertically and push them to opposite ends horizontally:

nav a {
  display: flex;
  align-items: center;
  justify-content: space-between;
}Code language: CSS (css)
Screenshot.Shows the same grid as before, except now each grid item, each occupying a row of the one column grid, is now a flex container as well, with the text content pushed to the left edge and the image to the left edge. The text and the image are also middle aligned vertically. The one noticeable problem is the images have different heights, which makes the height of each row be different too, as it stretches to fit the image within.
starting to look like something

Each link receives a thin border-bottom to create the separator line and a lateral padding. These are set as custom properties, which may not make much sense right now, but I promise it’s for a good reason.

nav a {
  --pad: min(2em, 4vw);
  --l: 1px;
	
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-bottom: solid var(--l) #000;
  padding: 0 var(--pad);
}
Code language: CSS (css)

We give each link a color and strip the default underline with text‑decoration: none. These are purely cosmetic, and we’ll revisit them later in the article.

Screenshot. Pretty much the same result as below with just a few visual flourishes, such as a bottom border for each nav item, a bit of lateral spacing around a grid and less in your face test.
getting a little bit less rough

Next, we prepare the img elements for future magic by sizing them and ensuring they act like well-behaved cats – no stretching! The responsive image height and the aspect ratio are also set as custom properties next to the link padding and separator line width – the purpose of doing so will become clear shortly.

nav a {
  --pad: min(2em, 4vw);
  --l: 1px;
  --r: 3/ 2;
  --h: round(down, min(4em, 30vw, 100dvh/(var(--n) + 1)), 2px);
	
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-bottom: solid var(--l) #000;
  padding: 0 var(--pad);
}

nav img {
  height: var(--h);
  aspect-ratio: var(--r);
  object-fit: cover;
}
Code language: CSS (css)
Screenshot. Shows the same grid as above, except now all images (and consequently, the nav items containing them) have the same height.
all finally looking consistent

Since the images will flip in 3D, they also get backface-visibility: hidden, so we only see them when they’re facing us and they’re invisible when facing the back of the screen.

nav img {
  height: var(--h);
  aspect-ratio: var(--r);
  object-fit: cover;
  backface-visibility: hidden;
}
Code language: CSS (css)

This is handy when we want to make sure they’re is facing the right way. We may comment this out for a little while a bit later just to take a peek and check they’re in the right position even when facing the other way.

In order for the thumbnails to really look like they’re rotating in 3D, we add a perspective and a perspective‑origin to each img parent. The horizontal position of the origin needs to be a padding --pad plus half an img width (computed from the height --h and aspect ratio --r) to the left of the right edge (which is at 100%).

nav a {
  --pad: min(2em, 4vw);
  --l: 1px;
  --r: 3/ 2;
  --h: round(down, min(30vw, 100dvh/(var(--n) + 1)), 2px);
	
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-bottom: solid var(--l) #000;
  padding: 0 var(--pad);
  perspective-origin: 
    calc(100% - var(--pad) - .5*var(--h)*var(--r)); 
  perspective: 20em;
}
Code language: CSS (css)

This is why we needed custom properties for those values, to ensure things stay consistent without having to make changes in multiple places when we want to tweak the lateral padding for the items or use different image dimensions.

So far, this is what we have:

Screenshot. The same result as before, now without overlays.
the current visual result with no grid or flex overlays

Now let’s make it work!

The Scroll Basics

Unfortunately, scroll-snap-points got deprecated, so now we need to resort to adding this abomination of a phantom branch to the DOM tree:

<div class='snaps' aria-hidden='true'>
  <div class='snap'></div>
  <div class='snap'></div>
  <!-- as may of these as nav items -->
</div>Code language: HTML, XML (xml)

We need the nav content to remain permanently in view, so it cannot scroll. But, since just making the html tall doesn’t suffice for scroll snapping now anymore, we need to create these scrolling elements to snap to.

* { margin: 0 }

html {
  scroll-snap-type: y mandatory;
  overscroll-behavior: none
}

.snap {
  scroll-snap-align: center;
  scroll-snap-stop: always;
  height: 100dvh
}Code language: CSS (css)
Animated GIF. Shows scrolling through the snap elements, each of which has its boundary highlighted.
how the .snap elements are used here

We’ve also added overscroll-behavior to kill the rubber‑band overscroll bounce and scroll-snap-stop to stop the scroll from skipping over snap points when going quickly up or down. Though, unless I’m misunderstanding what they’re supposed to do, neither of them actually works.

The Scroll Animation

We introduce a new custom property --k to track the scroll progress. First, we register it via @property so the browser treats it as an animatable numeric value. Otherwise, it would just abruptly flip in between the animation end state values.

@property --k {
  syntax: '<number>';
  initial-value: 0;
  inherits: true
}Code language: CSS (css)

Then we drive --k to 1 from its initial-value of 0 via a keyframe animation that we tie to the scroll timeline:

nav {
  /* same as before */
  animation: k 1s linear both;
  animation-timeline: scroll();
}

@keyframes k { to { --k: 1 } }Code language: CSS (css)

We use this --k value to compute the current nav item index, which we call --j and which needs to be registered as an integer:

@property --j {
  syntax: '<integer>';
  initial-value: 0;
  inherits: true
}

nav {
  /* same as before */
  --j: round(var(--k)*(var(--n) - 1));
}Code language: CSS (css)
Animated GIF. Shows how the current item index changes as we scroll down and then back up.
scrolling down, the current item index changes

There are two things to note here.

One, we need to register --j in order for the animation to work in Chrome. I don’t really understand why, since it’s not the CSS variable being animated here, and in Safari, the animation works the same whether it’s registered or not. I registered it at first just to follow the computed values in DevTools, and then noticed the demo breaks when I try to remove its @property block. Maybe someone who knows better can chime in.

Two, animating --k directly in steps from 0 to n - 1 would have been simpler. However, at this point, Firefox still refuses to animate a custom property to a value depending on another custom property.

The Interesting Part!

We can now move on to computing the rotation and “hinge” position (set via transform-origin) based on each nav item’s index --i and the index of the current item --j.

We start by comparing each item’s own index (--i) with the scroll‑derived current index (--j). The sign of their difference tells us whether an item is ahead, behind, or exactly on target, and from that we derive a binary selection flag (--sel). When --sel is 1 the item is the one currently under the spotlight.

nav a {
  /* same as before */
  --sgn: sign(var(--i) - var(--j));
  --sel: calc(1 - abs(var(--sgn)));
}Code language: CSS (css)

Think of this selection flag as a CSS boolean, which is something I’ve written about before, in a lot of detail even.

Animated GIF. Shows how the sign and selection flag changes as we scroll down/ back up.
the sign and selection flag computations in all cases

We have three possible cases here.

  • --i is bigger than --j (the item of index --i is ahead of the current one), so the sign of their difference is 1 and the selection flag is 0 (the item of index --i is not selected)
  • --i is equal to --j (the item of index --i is the current one), so the sign of their difference is 0 and the selection flag is 1
  • --i is smaller than --j (the item of index --i is behind the current one), so the sign of their difference is -1 and the selection flag is 0 (the item of index --i is not selected)

Now we need to use these values to compute the rotation around the x-axis and the vertical position of the horizontal axis for our navigation items in all three scenarios.

In case you need a CSS 3D refresher, a rotation around the x-axis works as illustrated by the following live demo:

The x-axis we rotate around points towards the cat. From the point of view of the cat, a positive rotation is one she sees going clockwise.

Knowing all of this, we can use it as follows in our three cases:

  • i > j (ahead of the current item, when the sign is +1) – the image rotates by +180°, clockwise around a hinge that sits half a separator line thickness above the top edge of the image, a vertical position that can be expressed as -.5*l or, equivalently, 50% - +1·(50% + .5·l)
  • i = j (the current item, when the sign is 0) – the image doesn’t rotate, so we can consider that to be a rotation, or, equivalently, 0·180°; since there is no rotation, the hinge is irrelevant, so we can take its vertical position as being whatever, for example, just the default 50% or, equivalently, 50% - 0·(50% + .5·l)
  • i < j (behind the current item, when the sign is -1) – the image rotates by -180°, anti-clockwise around a hinge that sits half a separator line thickness below the bottom edge of the image, a vertical position that can be expressed as 100% + .5*l or, equivalently, 50% - -1·(50% + .5·l)
Animated GIF. Shows how the rotation angle and vertical position of hinge are computed for each item as we scroll down/ back up.
rotation-related computations

The above is a lot, but it shows the position not just for the image of the current item, but for those of the items right before and right after, rotated and with the rotation axis highlighted. They are also translated horizontally so they don’t overlap – this is just to show them side by side, we don’t have this translation in the actual demo.

Now you may be wondering why the odd equivalent forms. They are used to show how all those values satisfy the same formula depending on the sign of the difference.

The rotation is:

  • +1·180° when the sign is +1
  • 0·180° when the sign is 0
  • -1·180° when the sign is -1

Do you see a pattern? The rotation is the sign multiplied by 180°.

Similarly, the y axis position of the hinge is:

  • 50% - +1·(50% + .5·l) when the sign is +1
  • 50% - 0·(50% + .5·l) when the sign is 0
  • 50% - -1·(50% + .5·l) when the sign is -1

Again, it’s all almost the same, except for the sign.

Putting it all into CSS, we have:

nav a {
  /* same as before */
  --sgn: sign(var(--i) - var(--j));
  --sel: calc(1 - abs(var(--sgn)));
}

nav img {
  /* same as before */
  transform-origin:
    0 calc(50% - var(--sgn)*(50% + .5*var(--l))); 
  rotate: x calc(var(--sgn)*180deg);
}

Code language: CSS (css)

The final piece here is transitioning the rotation so our images don’t just appear in place when the containing item is selected. Since we also want to have a color and text-indent transition on the item text as well, we set the duration as a custom property at item level:

nav a {
  /* same as before */
  --sgn: sign(var(--i) - var(--j));
  --sel: calc(1 - abs(var(--sgn)));
  --t: .5s;
}

nav img {
  /* same as before */
  transform-origin: 
    0 calc(50% - var(--sgn)*(50% + .5*var(--l)));
  rotate: x calc(var(--sgn)*180deg);
  transition: var(--t) rotate
}
Code language: CSS (css)

Almost there, but not quite:

Animated GIF. Shows how the rotation angle and vertical position of hinge are computed for each item as we scroll down/ back up.
slowed down animation to make what’s happening more clear

Things start out well with the image of the newly unselected item rotating out around its exit hinge. However, the image of the newly selected item doesn’t rotate in as it should, around its enter hinge. Instead, it just rotates in around its middle axis.

The problem is that once an item becomes selected, the second value of the transform-origin, which gives us the y position of the horizontal axis of rotation, abruptly moves from half a line thickness above/ below the top/ bottom edge to the middle of the element. We only want this to happen after the rotation, so we want to add a delay equal to the transition-duration of the rotation.

At the same time, we want to keep the current state of things once an item becomes deselected. Once it becomes deselected, we want its transform-origin to abruptly move half a line thickness above/ below the top/ bottom edge, depending of the direction we go in.

So we want a delay in the abrupt change (0s duration) of transform-origin only when an item becomes selected (--sel has flipped to 1), but not when it becomes deselected (--sel has flipped to 0). This means we need to multiply the delay with the selection flag.

The final transition declaration therefore looks like this:

transition: 
  0s transform-origin calc(var(--sel)*var(--t)), 
  var(--t) rotateCode language: CSS (css)
Animated GIF. Shows the correct rotation animation around the correct axis.
correct hinging all the way

Refining Touches

Besides the thumb flip, we also want the text to stand out a bit more when its containing item becomes the current one, so we bump up its contrast and slide it in.

The same --sel flag that tells us whether an item is selected drives both the color and the text‑indent change. The color goes from a mid grey in the normal case to an almost black in the selected case, while the text-indent goes from 0 to 1em. Both properties get a simple transition so the shift feels smooth.

/* relevant CSS for the visual motion part only */
nav a {
  --sgn: sign(var(--i) - var(--j));
  --sel: calc(1 - abs(var(--sgn)));
  --t: .5s;
	
  color: hsl(0 0% calc(50% - var(--sel)*43%));
  text-indent: calc(var(--sel)*1em);
  transition: var(--t); 
  transition-property: color, text-indent; 
}

nav img {
  transform-origin: 
    0 calc(50% - var(--sgn)*(50% + .5*var(--l)));
  rotate: x calc(var(--sgn)*180deg);
  transition: var(--t) rotate
}
Code language: CSS (css)

Our demo now behaves like the original version, except it’s driven by scroll and the rotations are “hinged” around the separator lines. This is the version seen in the recording at the start of the article.

Issues

The final result, while looking good in Chrome, is glitchy in Epiphany, though this doesn’t seem to be as much of a problem in actual Safari, according to the responses I got when I asked on Mastodon and Bluesky. It also completely lacks any animation in Firefox. It turns out the root cause of the Firefox problem is this bug some rando filed a couple of years ago. That rando was seemingly me, though I have no recollection of it anymore.

Another issue is that, since both the nav and the snaps are using the dynamic viewport, there’s a lot of jumping around on mobile/ tablet. So it’s probably better to use the small viewport for the nav and the large one for the snaps.

.snap {
  /* same as before */
  height: 100lvh
}

nav {
  /* same as before */
  height: 100svh
}Code language: CSS (css)

However, using the small viewport for the nav means it may not cover the entire viewport in all scenarios, so we could get a white band at the bottom – the default page background contrasting with the subtle one on the nav. To fix this, we need to move the background from the nav to the html or the body.

Since our nav items are links, they should have usable :hover and :focus styles.

nav a {
  /* same as before */
  --hov: 0;
  color: 
    hsl(345 
      calc(var(--hov)*100%) 
      calc(50% - var(--sel)*(1 - var(--hov))*53%));

  &:is(:hover, :focus) { --hov: 1 }

  &:focus-visible {
    outline: dotted 4px;
    outline-offset: 2px
  }
}Code language: CSS (css)

And it’s probably best not to greet night owls with such a bright background, so we should respect user-set dark mode preferences, which means rethinking how we set the color.

html {
  /* same as before */
  color-scheme: light dark;
  background: light-dark(#dedede, #212121)
}

a {
  /* same as before */
  border-bottom: solid var(--l) light-dark(#121212, #ededed);
  color: 
    light-dark(
      color-mix(in srgb, 
        #9b2226 var(--prc-hov), 
        color-mix(in srgb, #023047 var(--prc-sel), #454545)), 
      color-mix(in srgb, 
        #ffb703 var(--prc-hov), 
        color-mix(in srgb, #8ecae6 var(--prc-sel), #ababab))
    );
}Code language: CSS (css)

Here’s that demo (and remember this is scroll-based not hover-based):

And maybe we shouldn’t have removed the underlines, though this is a navigation component, so it should be expected that what we have in there are links? Personally, I’m on the fence about this. The main reason why I decided against putting them back was the fact that I am not a designer and I was going down a deep rabbit hole unrelated to the main topic of the article just by repeatedly trying and failing to come up with a creative way of doing something aesthetically pleasing with them.

Finally, it’s often said scroll-jacking is a bad idea, don’t do it. I personally like scroll effects if they’re well done and not excessive, but I can understand others may have different preferences.

Since this is supposed to be a navigation, but the demo has no content to navigate to, maybe we should add content and make the effect happen on navigating to the corresponding section.

However, this comes with extra challenges when sections have different heights, as well as when skipping sections via the navigation. Neither of which I’m capable of solving.

Below is the best I could get. It uses JavaScript, and the animation looks bad when skipping items. It’s also not responsive, and I don’t really know what to do about it on small or very large viewports.

Lessons Learned

The most important one is probably that things don’t turn out as you expect them to.

I needlessly complicated this demo early on (setting custom properties instead of sibling-index() and sibling-count(), not animating the current item index --j directly) for the sake of wider support/avoiding bugs. And in the end, I didn’t even need to do that because it doesn’t work cross-browser anyway.

I also aimed for a pure CSS solution with a nice hinging animation, but when I tried to make it usable, I couldn’t do it without JavaScript, and I couldn’t keep the animation looking nice.

The other very important one is that anything can turn into a deep rabbit hole when you’re incompetent like me. After completing the demo quite quickly, I was still unhappy with it, so I ended up spending a ridiculous amount of time on various improvement attempts, none of which worked out, so, in the end, I took them all out.

Learn to use Canvas and WebGL

Leave a Reply

Your email address will not be published. Required fields are marked *

$966,000

Frontend Masters donates to open source projects through thanks.dev and Open Collective, as well as donates to non-profits like The Last Mile, Annie Canons, and Vets Who Code.