{"id":8684,"date":"2026-02-26T09:08:10","date_gmt":"2026-02-26T14:08:10","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=8684"},"modified":"2026-02-27T08:58:52","modified_gmt":"2026-02-27T13:58:52","slug":"nav-thumbnail-flip-image","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/nav-thumbnail-flip-image\/","title":{"rendered":"Lessons Learned from Failed Demos: Pure CSS Nav Thumb Flip on Scroll"},"content":{"rendered":"\n<p><a href=\"https:\/\/codepen.io\/spark\/497\">A recent CodePen Spark<\/a> led me to discover&nbsp;<a href=\"https:\/\/codepen.io\/vii120\/pen\/KwMJeXP\">this cool-looking demo<\/a>. It&#8217;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 &#8220;hinged&#8221; to the top\/bottom edge, depending on the direction in which we&#8217;re going.<\/p>\n\n\n\n<p>About half an hour later, I had this:<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"760\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550874633-b1332c1f-234c-47db-9bdb-f060d7ad3628.gif?resize=800%2C760&#038;ssl=1\" alt=\"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.\" class=\"wp-image-8687\" style=\"aspect-ratio:1.0526469230582347;object-fit:cover;width:450px\"\/><figcaption class=\"wp-element-caption\">recording of my result<\/figcaption><\/figure>\n<\/div>\n\n\n<p>Let&#8217;s see how I did it&#8230; and what went wrong.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-layout-basics\">The Layout Basics<\/h2>\n\n\n\n<p>We have a&nbsp;<code>&lt;nav&gt;<\/code>&nbsp;element with&nbsp;<code>n<\/code>&nbsp;children. Since we&#8217;ll be needing this number&nbsp;<code>n<\/code>&nbsp;to make styling choices, we pass it to the CSS as a custom property. The same goes for the index&nbsp;<code>i<\/code>&nbsp;of each&nbsp;<code>nav<\/code>&nbsp;item. To make it easier for myself, I used Pug to generate the HTML from a data object &#8211; the result looks as follows:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">nav<\/span> <span class=\"hljs-attr\">style<\/span>=<span class=\"hljs-string\">\"--n: 7\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">a<\/span> <span class=\"hljs-attr\">href<\/span>=<span class=\"hljs-string\">\"#\"<\/span> <span class=\"hljs-attr\">style<\/span>=<span class=\"hljs-string\">\"--i: 0\"<\/span>&gt;<\/span>\n    tiger\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">img<\/span> <span class=\"hljs-attr\">src<\/span>=<span class=\"hljs-string\">\"tiger.jpg\"<\/span> <span class=\"hljs-attr\">alt<\/span>=<span class=\"hljs-string\">\"tiger drinking water\"<\/span> \/&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">a<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">a<\/span> <span class=\"hljs-attr\">href<\/span>=<span class=\"hljs-string\">\"#\"<\/span> <span class=\"hljs-attr\">style<\/span>=<span class=\"hljs-string\">\"--i: 1\"<\/span>&gt;<\/span>\n    lion\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">img<\/span> <span class=\"hljs-attr\">src<\/span>=<span class=\"hljs-string\">\"lion.jpg\"<\/span> <span class=\"hljs-attr\">alt<\/span>=<span class=\"hljs-string\">\"lion couple on a rock\"<\/span> \/&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">a<\/span>&gt;<\/span>\n  <span class=\"hljs-comment\">&lt;!-- the other cats --&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">nav<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>It&#8217;s a pretty simple structure, just a <code>nav<\/code> wrapper around <code>a<\/code> items, each of these items containing text and an <code>img<\/code> child.<\/p>\n\n\n\n<p>The <code>sibling-index()<\/code> and <code>sibling-count()<\/code> CSS functions are <a href=\"https:\/\/bugzilla.mozilla.org\/show_bug.cgi?id=1953973\">not yet a thing cross-browser<\/a>, so we&#8217;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.<\/p>\n\n\n\n<p>Moving on to the CSS, our&nbsp;nav is using&nbsp;fixed positioning and made to cover all available viewport space (note that this excludes any scrollbars we might have).<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">nav<\/span> {\n  <span class=\"hljs-attribute\">position<\/span>: fixed;\n  <span class=\"hljs-attribute\">inset<\/span>: <span class=\"hljs-number\">0<\/span>;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The next step is to use a <code>grid<\/code> layout for it, <a href=\"https:\/\/frontendmasters.com\/blog\/super-simple-full-bleed-breakout-styles\/\">limit the width of the grid&#8217;s one column<\/a>, and middle-align this grid within the element:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> {\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">display<\/span>: grid;\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">grid-template-columns<\/span>: <span class=\"hljs-built_in\">min<\/span>(<span class=\"hljs-number\">100%<\/span> - <span class=\"hljs-number\">1em<\/span>, <span class=\"hljs-number\">25em<\/span>);\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">place-content<\/span>: center;\n<\/span><\/mark><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">position<\/span>: fixed;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">inset<\/span>: <span class=\"hljs-number\">0<\/span>;\n<\/span><\/span><span class='shcb-loc'><span>}\n<\/span><\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Note that we use <code>100% - 1em<\/code> inside the <code>min()<\/code> 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 <code>padding<\/code> rule. Because why waste precious screen space on a non-essential declaration when we could find more important CSS to cram in there?<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003446-e3d4a37f-db71-4df8-b40e-6edf4e143778.png?resize=800%2C800&#038;ssl=1\" alt=\"Screenshot. Shows the result so far, with the grid overlay highlighted from DevTools.\" class=\"wp-image-8700\" style=\"width:450px\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003446-e3d4a37f-db71-4df8-b40e-6edf4e143778.png?w=800&amp;ssl=1 800w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003446-e3d4a37f-db71-4df8-b40e-6edf4e143778.png?resize=300%2C300&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003446-e3d4a37f-db71-4df8-b40e-6edf4e143778.png?resize=150%2C150&amp;ssl=1 150w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003446-e3d4a37f-db71-4df8-b40e-6edf4e143778.png?resize=768%2C768&amp;ssl=1 768w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><figcaption class=\"wp-element-caption\">doesn&#8217;t look like much yet<\/figcaption><\/figure>\n<\/div>\n\n\n<p>We&#8217;re done with the important styles on the <code>nav<\/code>, so we move on to prettifying touches. We slap on a subtle background and give it a viewport-relative <code>font<\/code>, kept within reasonable limits by a <code>clamp()<\/code> &#8211; we don&#8217;t want the text to get so small it&#8217;s unreadable, nor do we want it to balloon on huge screens.<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003613-ff332bde-b9f6-4d26-a66a-99977a8508f9.png?resize=800%2C800&#038;ssl=1\" alt=\"Screenshot. Shows the same grid from before, but the font got bumped up and there's a less bright background behind.\" class=\"wp-image-8702\" style=\"width:450px\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003613-ff332bde-b9f6-4d26-a66a-99977a8508f9.png?w=800&amp;ssl=1 800w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003613-ff332bde-b9f6-4d26-a66a-99977a8508f9.png?resize=300%2C300&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003613-ff332bde-b9f6-4d26-a66a-99977a8508f9.png?resize=150%2C150&amp;ssl=1 150w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003613-ff332bde-b9f6-4d26-a66a-99977a8508f9.png?resize=768%2C768&amp;ssl=1 768w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><figcaption class=\"wp-element-caption\">well, that makes a bit of a difference<\/figcaption><\/figure>\n<\/div>\n\n\n<p>With the <code>nav<\/code> styles settled, we turn our attention to the links, for which we use a <code>flex<\/code> layout. This allows us to middle-align the text content and the <code>img<\/code> vertically and push them to opposite ends horizontally:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-attribute\">display<\/span>: flex;\n  <span class=\"hljs-attribute\">align-items<\/span>: center;\n  <span class=\"hljs-attribute\">justify-content<\/span>: space-between;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003796-e7eeb327-595d-4081-b906-34ad9d44584e.png?resize=800%2C800&#038;ssl=1\" alt=\"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.\" class=\"wp-image-8703\" style=\"width:450px\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003796-e7eeb327-595d-4081-b906-34ad9d44584e.png?w=800&amp;ssl=1 800w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003796-e7eeb327-595d-4081-b906-34ad9d44584e.png?resize=300%2C300&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003796-e7eeb327-595d-4081-b906-34ad9d44584e.png?resize=150%2C150&amp;ssl=1 150w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551003796-e7eeb327-595d-4081-b906-34ad9d44584e.png?resize=768%2C768&amp;ssl=1 768w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><figcaption class=\"wp-element-caption\">starting to look like something<\/figcaption><\/figure>\n<\/div>\n\n\n<p>Each link receives a thin <code>border-bottom<\/code> 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&#8217;s for a good reason.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">--pad<\/span>: <span class=\"hljs-built_in\">min<\/span>(<span class=\"hljs-number\">2em<\/span>, <span class=\"hljs-number\">4vw<\/span>);\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">--l<\/span>: <span class=\"hljs-number\">1px<\/span>;\n<\/span><\/mark><span class='shcb-loc'><span>\t\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">display<\/span>: flex;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">align-items<\/span>: center;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">justify-content<\/span>: space-between;\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">border-bottom<\/span>: solid <span class=\"hljs-built_in\">var<\/span>(--l) <span class=\"hljs-number\">#000<\/span>;\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">padding<\/span>: <span class=\"hljs-number\">0<\/span> <span class=\"hljs-built_in\">var<\/span>(--pad);\n<\/span><\/mark><span class='shcb-loc'><span>}\n<\/span><\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We give each link a <code>color<\/code> and strip the default underline with <code>text\u2011decoration: none<\/code>. These are purely cosmetic, and we&#8217;ll revisit them later in the article.<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551004002-1e83aaf9-fa1f-482d-b182-3c08e20452a5.png?resize=800%2C800&#038;ssl=1\" alt=\"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.\" class=\"wp-image-8704\" style=\"width:450px\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551004002-1e83aaf9-fa1f-482d-b182-3c08e20452a5.png?w=800&amp;ssl=1 800w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551004002-1e83aaf9-fa1f-482d-b182-3c08e20452a5.png?resize=300%2C300&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551004002-1e83aaf9-fa1f-482d-b182-3c08e20452a5.png?resize=150%2C150&amp;ssl=1 150w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551004002-1e83aaf9-fa1f-482d-b182-3c08e20452a5.png?resize=768%2C768&amp;ssl=1 768w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><figcaption class=\"wp-element-caption\">getting a little bit less rough<\/figcaption><\/figure>\n<\/div>\n\n\n<p>Next, we prepare the <code>img<\/code> elements for future magic by sizing them and ensuring they act like well-behaved cats &#8211; 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 &#8211; the purpose of doing so will become clear shortly.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--pad<\/span>: <span class=\"hljs-built_in\">min<\/span>(<span class=\"hljs-number\">2em<\/span>, <span class=\"hljs-number\">4vw<\/span>);\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--l<\/span>: <span class=\"hljs-number\">1px<\/span>;\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">--r<\/span>: <span class=\"hljs-number\">3<\/span>\/ <span class=\"hljs-number\">2<\/span>;\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">--h<\/span>: <span class=\"hljs-built_in\">round<\/span>(down, min(<span class=\"hljs-number\">4em<\/span>, <span class=\"hljs-number\">30vw<\/span>, <span class=\"hljs-number\">100<\/span>dvh\/(var(--n) + <span class=\"hljs-number\">1<\/span>)), <span class=\"hljs-number\">2px<\/span>);\n<\/span><\/mark><span class='shcb-loc'><span>\t\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">display<\/span>: flex;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">align-items<\/span>: center;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">justify-content<\/span>: space-between;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">border-bottom<\/span>: solid <span class=\"hljs-built_in\">var<\/span>(--l) <span class=\"hljs-number\">#000<\/span>;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">padding<\/span>: <span class=\"hljs-number\">0<\/span> <span class=\"hljs-built_in\">var<\/span>(--pad);\n<\/span><\/span><span class='shcb-loc'><span>}\n<\/span><\/span><span class='shcb-loc'><span>\n<\/span><\/span><mark class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">img<\/span> {\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">height<\/span>: <span class=\"hljs-built_in\">var<\/span>(--h);\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">aspect-ratio<\/span>: <span class=\"hljs-built_in\">var<\/span>(--r);\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">object-fit<\/span>: cover;\n<\/span><\/mark><mark class='shcb-loc'><span>}\n<\/span><\/mark><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550962275-908aa24f-35a8-4da4-9b08-7a781f5950d7.png?resize=800%2C800&#038;ssl=1\" alt=\"Screenshot. Shows the same grid as above, except now all images (and consequently, the nav items containing them) have the same height.\" class=\"wp-image-8706\" style=\"width:450px\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550962275-908aa24f-35a8-4da4-9b08-7a781f5950d7.png?w=800&amp;ssl=1 800w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550962275-908aa24f-35a8-4da4-9b08-7a781f5950d7.png?resize=300%2C300&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550962275-908aa24f-35a8-4da4-9b08-7a781f5950d7.png?resize=150%2C150&amp;ssl=1 150w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550962275-908aa24f-35a8-4da4-9b08-7a781f5950d7.png?resize=768%2C768&amp;ssl=1 768w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><figcaption class=\"wp-element-caption\">all finally looking consistent<\/figcaption><\/figure>\n<\/div>\n\n\n<p>Since the images will flip in 3D, they also get <code>backface-visibility: hidden<\/code>, so we only see them when they&#8217;re facing us and they\u2019re invisible when facing the back of the screen.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">img<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">height<\/span>: <span class=\"hljs-built_in\">var<\/span>(--h);\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">aspect-ratio<\/span>: <span class=\"hljs-built_in\">var<\/span>(--r);\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">object-fit<\/span>: cover;\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">backface-visibility<\/span>: hidden;\n<\/span><\/mark><span class='shcb-loc'><span>}\n<\/span><\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This is handy when we want to make sure they&#8217;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&#8217;re in the right position even when facing the other way.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_PqvemZ\" src=\"\/\/codepen.io\/anon\/embed\/PqvemZ?height=590&amp;theme-id=1&amp;slug-hash=PqvemZ&amp;default-tab=result\" height=\"590\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed PqvemZ\" title=\"CodePen Embed PqvemZ\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>In order for the thumbnails to really look like they&#8217;re rotating in 3D, we add a <code>perspective<\/code> and a <code>perspective\u2011origin<\/code> to each <code>img<\/code> parent. The horizontal position of the origin needs to be a padding <code>--pad<\/code> plus half an <code>img<\/code> width (computed from the height <code>--h<\/code> and aspect ratio <code>--r<\/code>) to the left of the right edge (which is at 100%).<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--pad<\/span>: <span class=\"hljs-built_in\">min<\/span>(<span class=\"hljs-number\">2em<\/span>, <span class=\"hljs-number\">4vw<\/span>);\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--l<\/span>: <span class=\"hljs-number\">1px<\/span>;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--r<\/span>: <span class=\"hljs-number\">3<\/span>\/ <span class=\"hljs-number\">2<\/span>;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--h<\/span>: <span class=\"hljs-built_in\">round<\/span>(down, min(<span class=\"hljs-number\">30vw<\/span>, <span class=\"hljs-number\">100<\/span>dvh\/(var(--n) + <span class=\"hljs-number\">1<\/span>)), <span class=\"hljs-number\">2px<\/span>);\n<\/span><\/span><span class='shcb-loc'><span>\t\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">display<\/span>: flex;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">align-items<\/span>: center;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">justify-content<\/span>: space-between;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">border-bottom<\/span>: solid <span class=\"hljs-built_in\">var<\/span>(--l) <span class=\"hljs-number\">#000<\/span>;\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">padding<\/span>: <span class=\"hljs-number\">0<\/span> <span class=\"hljs-built_in\">var<\/span>(--pad);\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">perspective-origin<\/span>: \n<\/span><\/mark><mark class='shcb-loc'><span>    <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">100%<\/span> - var(--pad) - .<span class=\"hljs-number\">5<\/span>*<span class=\"hljs-built_in\">var<\/span>(--h)*<span class=\"hljs-built_in\">var<\/span>(--r)); \n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">perspective<\/span>: <span class=\"hljs-number\">20em<\/span>;\n<\/span><\/mark><span class='shcb-loc'><span>}\n<\/span><\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>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.<\/p>\n\n\n\n<p>So far, this is what we have:<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550961846-c7534391-5005-4ebd-b78c-c8941af1821e.png?resize=800%2C800&#038;ssl=1\" alt=\"Screenshot. The same result as before, now without overlays.\" class=\"wp-image-8710\" style=\"width:450px\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550961846-c7534391-5005-4ebd-b78c-c8941af1821e.png?w=800&amp;ssl=1 800w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550961846-c7534391-5005-4ebd-b78c-c8941af1821e.png?resize=300%2C300&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550961846-c7534391-5005-4ebd-b78c-c8941af1821e.png?resize=150%2C150&amp;ssl=1 150w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/550961846-c7534391-5005-4ebd-b78c-c8941af1821e.png?resize=768%2C768&amp;ssl=1 768w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><figcaption class=\"wp-element-caption\">the current visual result with no grid or flex overlays<\/figcaption><\/figure>\n<\/div>\n\n\n<p>Now let&#8217;s make it work!<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-scroll-basics\">The Scroll Basics<\/h2>\n\n\n\n<p>Unfortunately, <a href=\"https:\/\/www.w3.org\/TR\/2015\/WD-css-snappoints-1-20150326\/#scroll-snap-points\"><code>scroll-snap-points<\/code><\/a> got <a href=\"https:\/\/lists.w3.org\/Archives\/Public\/www-style\/2015Nov\/0266.html\">deprecated<\/a>, so now we need to resort to adding this abomination of a phantom branch to the DOM tree:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">'snaps'<\/span> <span class=\"hljs-attr\">aria-hidden<\/span>=<span class=\"hljs-string\">'true'<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">'snap'<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">'snap'<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n  <span class=\"hljs-comment\">&lt;!-- as may of these as nav items --&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We need the <code>nav<\/code> content to remain permanently in view, so it cannot scroll. But, since just making the <code>html<\/code> tall doesn&#8217;t suffice for scroll snapping now anymore, we need to create these scrolling elements to snap to.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\">* { <span class=\"hljs-attribute\">margin<\/span>: <span class=\"hljs-number\">0<\/span> }\n\n<span class=\"hljs-selector-tag\">html<\/span> {\n  <span class=\"hljs-attribute\">scroll-snap-type<\/span>: y mandatory;\n  <span class=\"hljs-attribute\">overscroll-behavior<\/span>: none\n}\n\n<span class=\"hljs-selector-class\">.snap<\/span> {\n  <span class=\"hljs-attribute\">scroll-snap-align<\/span>: center;\n  <span class=\"hljs-attribute\">scroll-snap-stop<\/span>: always;\n  <span class=\"hljs-attribute\">height<\/span>: <span class=\"hljs-number\">100<\/span>dvh\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551198558-6319c7a8-ab9a-4ffb-ac6f-297aae9487a2.gif?resize=800%2C800&#038;ssl=1\" alt=\"Animated GIF. Shows scrolling through the snap elements, each of which has its boundary highlighted.\" class=\"wp-image-8711\" style=\"width:450px\"\/><figcaption class=\"wp-element-caption\">how the <code>.snap<\/code> elements are used here<\/figcaption><\/figure>\n<\/div>\n\n\n<p>We&#8217;ve also added <code>overscroll-behavior<\/code> to kill the rubber\u2011band overscroll bounce and <code>scroll-snap-stop<\/code> to stop the scroll from skipping over snap points when going quickly up or down. Though, unless I&#8217;m misunderstanding what they&#8217;re supposed to do, neither of them actually works.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-scroll-animation\">The Scroll Animation<\/h2>\n\n\n\n<p>We introduce a new custom property <code>--k<\/code> to track the scroll progress. First, we register it via <code>@property<\/code> so the browser treats it as an animatable numeric value. Otherwise, it would just abruptly flip in between the animation end state values.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-keyword\">@property<\/span> --k {\n  <span class=\"hljs-selector-tag\">syntax<\/span>: '&lt;<span class=\"hljs-selector-tag\">number<\/span>&gt;';\n  <span class=\"hljs-selector-tag\">initial-value<\/span>: 0;\n  <span class=\"hljs-selector-tag\">inherits<\/span>: <span class=\"hljs-selector-tag\">true<\/span>\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Then we drive <code>--k<\/code> to\u202f<code>1<\/code> from its <code>initial-value<\/code> of <code>0<\/code> via a keyframe <code>animation<\/code> that we tie to the scroll timeline:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">nav<\/span> {\n  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n  <span class=\"hljs-attribute\">animation<\/span>: k <span class=\"hljs-number\">1s<\/span> linear both;\n  <span class=\"hljs-attribute\">animation-timeline<\/span>: <span class=\"hljs-built_in\">scroll<\/span>();\n}\n\n<span class=\"hljs-keyword\">@keyframes<\/span> k { <span class=\"hljs-selector-tag\">to<\/span> { <span class=\"hljs-attribute\">--k<\/span>: <span class=\"hljs-number\">1<\/span> } }<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We use this <code>--k<\/code> value to compute the current <code>nav<\/code> item index, which we call <code>--j<\/code> and which needs to be registered as an integer:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-keyword\">@property<\/span> --j {\n  <span class=\"hljs-selector-tag\">syntax<\/span>: '&lt;<span class=\"hljs-selector-tag\">integer<\/span>&gt;';\n  <span class=\"hljs-selector-tag\">initial-value<\/span>: 0;\n  <span class=\"hljs-selector-tag\">inherits<\/span>: <span class=\"hljs-selector-tag\">true<\/span>\n}\n\n<span class=\"hljs-selector-tag\">nav<\/span> {\n  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n  <span class=\"hljs-attribute\">--j<\/span>: <span class=\"hljs-built_in\">round<\/span>(var(--k)*(<span class=\"hljs-built_in\">var<\/span>(--n) - <span class=\"hljs-number\">1<\/span>));\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551106094-9d9df46f-77c1-4e45-b228-28303eea6914.gif?resize=800%2C800&#038;ssl=1\" alt=\"Animated GIF. Shows how the current item index changes as we scroll down and then back up.\" class=\"wp-image-8712\" style=\"width:450px\"\/><figcaption class=\"wp-element-caption\">scrolling down, the current item index changes<\/figcaption><\/figure>\n<\/div>\n\n\n<p>There are two things to note here.<\/p>\n\n\n\n<p>One, we need to register <code>--j<\/code> in order for the animation to work in Chrome. I don&#8217;t really understand why, since it&#8217;s not the CSS variable being animated here, and in Safari, the animation works the same whether it&#8217;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 <code>@property<\/code> block. Maybe someone who knows better can chime in.<\/p>\n\n\n\n<p>Two, animating <code>--k<\/code> directly in steps from <code>0<\/code> to <code>n - 1<\/code> would have been simpler. However, at this point, Firefox still <a href=\"https:\/\/bugzilla.mozilla.org\/show_bug.cgi?id=1899531\">refuses<\/a> to animate a custom property to a value depending on another custom property.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-interesting-part\">The Interesting Part!<\/h2>\n\n\n\n<p>We can now move on to computing the rotation and &#8220;hinge&#8221; position (set via <code>transform-origin<\/code>) based on each <code>nav<\/code> item&#8217;s index <code>--i<\/code> and the index of the current item <code>--j<\/code>.<\/p>\n\n\n\n<p>We start by comparing each item\u2019s own index (<code>--i<\/code>) with the scroll\u2011derived current index (<code>--j<\/code>). 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 (<code>--sel<\/code>). When <code>--sel<\/code> is <code>1<\/code> the item is the one currently under the spotlight.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n  <span class=\"hljs-attribute\">--sgn<\/span>: <span class=\"hljs-built_in\">sign<\/span>(var(--i) - <span class=\"hljs-built_in\">var<\/span>(--j));\n  <span class=\"hljs-attribute\">--sel<\/span>: <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">1<\/span> - abs(var(--sgn)));\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Think of this selection flag as a CSS boolean, which is something <a href=\"https:\/\/css-tricks.com\/logical-operations-with-css-variables\/\">I&#8217;ve written about before<\/a>, in a lot of detail even.<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551111930-096d773f-772e-4c9c-932c-e4276c11ceb7.gif?resize=800%2C800&#038;ssl=1\" alt=\"Animated GIF. Shows how the sign and selection flag changes as we scroll down\/ back up.\" class=\"wp-image-8713\" style=\"width:450px\"\/><figcaption class=\"wp-element-caption\">the sign and selection flag computations in all cases<\/figcaption><\/figure>\n<\/div>\n\n\n<p>We have three possible cases here.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>--i<\/code> is bigger than <code>--j<\/code> (the item of index <code>--i<\/code> is ahead of the current one), so the sign of their difference is <code>1<\/code> and the selection flag is <code>0<\/code> (the item of index <code>--i<\/code> is not selected)<\/li>\n\n\n\n<li><code>--i<\/code> is equal to <code>--j<\/code> (the item of index <code>--i<\/code> is the current one), so the sign of their difference is <code>0<\/code> and the selection flag is <code>1<\/code><\/li>\n\n\n\n<li><code>--i<\/code> is smaller than <code>--j<\/code> (the item of index <code>--i<\/code> is behind the current one), so the sign of their difference is <code>-1<\/code> and the selection flag is <code>0<\/code> (the item of index <code>--i<\/code> is not selected)<\/li>\n<\/ul>\n\n\n\n<p><span style=\"box-sizing: border-box; margin: 0px; padding: 0px;\">Now we need to use these values <span style=\"box-sizing: border-box; margin: 0px; padding: 0px;\">to compute the rotation around the&nbsp;<em>x-<\/em>axis and the vertical position of the horizontal axis for our navigation items in all three&nbsp;<\/span>scenarios.<\/span><\/p>\n\n\n\n<p>In case you need a CSS 3D refresher, a rotation around the <span style=\"box-sizing: border-box; margin: 0px; padding: 0px;\"><em>x-<\/em>axis<\/span> works as illustrated by the following live demo:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_azZeBKx\/6835dbdaf8a4f23501cd19934d483e80\" src=\"\/\/codepen.io\/anon\/embed\/azZeBKx\/6835dbdaf8a4f23501cd19934d483e80?height=850&amp;theme-id=1&amp;slug-hash=azZeBKx\/6835dbdaf8a4f23501cd19934d483e80&amp;default-tab=result\" height=\"850\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed azZeBKx\/6835dbdaf8a4f23501cd19934d483e80\" title=\"CodePen Embed azZeBKx\/6835dbdaf8a4f23501cd19934d483e80\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>The <span style=\"box-sizing: border-box; margin: 0px; padding: 0px;\"><em>x-<\/em>axis<\/span> we rotate around points towards the cat. From the point of view of the cat, a positive rotation is one she sees going clockwise.<\/p>\n\n\n\n<p>Knowing all of this, we can use it as follows in our three cases:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>i &gt; j<\/code> (ahead of the current item, when the sign is <code>+1<\/code>) &#8211; the image rotates by <code>+180\u00b0<\/code>, 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 <code>-.5*l<\/code> or, equivalently, <code>50% - +1\u00b7(50%\u202f+\u202f.5\u00b7l)<\/code><\/li>\n\n\n\n<li><code>i = j<\/code> (the current item, when the sign is <code>0<\/code>) &#8211; the image doesn&#8217;t rotate, so we can consider that to be a <code>0\u00b0<\/code> rotation, or, equivalently, <code>0\u00b7180\u00b0<\/code>; since there is no rotation, the hinge is irrelevant, so we can take its vertical position as being whatever, for example, just the default <code>50%<\/code> or, equivalently, <code>50% - 0\u00b7(50%\u202f+\u202f.5\u00b7l)<\/code><\/li>\n\n\n\n<li><code>i &lt; j<\/code> (behind the current item, when the sign is <code>-1<\/code>) &#8211; the image rotates by <code>-180\u00b0<\/code>, 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 <code>100% + .5*l<\/code> or, equivalently, <code>50% - -1\u00b7(50%\u202f+\u202f.5\u00b7l)<\/code><\/li>\n<\/ul>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551164553-47498afe-ef19-4e96-a67d-d442e843ffae.gif?resize=800%2C800&#038;ssl=1\" alt=\"Animated GIF. Shows how the rotation angle and vertical position of hinge are computed for each item as we scroll down\/ back up.\" class=\"wp-image-8714\" style=\"width:450px\"\/><figcaption class=\"wp-element-caption\">rotation-related computations<\/figcaption><\/figure>\n<\/div>\n\n\n<p>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&#8217;t overlap &#8211; this is just to show them side by side, we don&#8217;t have this translation in the actual demo.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>The rotation is:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>+1\u00b7180\u00b0<\/code> when the sign is <code>+1<\/code><\/li>\n\n\n\n<li><code>0\u00b7180\u00b0<\/code> when the sign is <code>0<\/code><\/li>\n\n\n\n<li><code>-1\u00b7180\u00b0<\/code> when the sign is <code>-1<\/code><\/li>\n<\/ul>\n\n\n\n<p>Do you see a pattern? The rotation is the sign multiplied by <code>180\u00b0<\/code>.<\/p>\n\n\n\n<p>Similarly, the <em>y<\/em> axis position of the hinge is:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>50% - +1\u00b7(50%\u202f+\u202f.5\u00b7l)<\/code> when the sign is <code>+1<\/code><\/li>\n\n\n\n<li><code>50% - 0\u00b7(50%\u202f+\u202f.5\u00b7l)<\/code> when the sign is <code>0<\/code><\/li>\n\n\n\n<li><code>50% - -1\u00b7(50%\u202f+\u202f.5\u00b7l)<\/code> when the sign is <code>-1<\/code><\/li>\n<\/ul>\n\n\n\n<p>Again, it&#8217;s all almost the same, except for the sign.<\/p>\n\n\n\n<p>Putting it all into CSS, we have:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--sgn<\/span>: <span class=\"hljs-built_in\">sign<\/span>(var(--i) - <span class=\"hljs-built_in\">var<\/span>(--j));\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--sel<\/span>: <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">1<\/span> - abs(var(--sgn)));\n<\/span><\/span><span class='shcb-loc'><span>}\n<\/span><\/span><span class='shcb-loc'><span>\n<\/span><\/span><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">img<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">transform-origin<\/span>:\n<\/span><\/mark><mark class='shcb-loc'><span>    <span class=\"hljs-number\">0<\/span> <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">50%<\/span> - var(--sgn)*(<span class=\"hljs-number\">50%<\/span> + .<span class=\"hljs-number\">5<\/span>*<span class=\"hljs-built_in\">var<\/span>(--l))); \n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">rotate<\/span>: x <span class=\"hljs-built_in\">calc<\/span>(var(--sgn)*<span class=\"hljs-number\">180deg<\/span>);\n<\/span><\/mark><span class='shcb-loc'><span>}\n<\/span><\/span><span class='shcb-loc'><span>\n<\/span><\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The final piece here is transitioning the rotation so our images don&#8217;t just appear in place when the containing item is selected. Since we also want to have a <code>color<\/code> and <code>text-indent<\/code> transition on the item text as well, we set the duration as a custom property at item level:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-16\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--sgn<\/span>: <span class=\"hljs-built_in\">sign<\/span>(var(--i) - <span class=\"hljs-built_in\">var<\/span>(--j));\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--sel<\/span>: <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">1<\/span> - abs(var(--sgn)));\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">--t<\/span>: .<span class=\"hljs-number\">5s<\/span>;\n<\/span><\/mark><span class='shcb-loc'><span>}\n<\/span><\/span><span class='shcb-loc'><span>\n<\/span><\/span><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">img<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">transform-origin<\/span>: \n<\/span><\/span><span class='shcb-loc'><span>    <span class=\"hljs-number\">0<\/span> <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">50%<\/span> - var(--sgn)*(<span class=\"hljs-number\">50%<\/span> + .<span class=\"hljs-number\">5<\/span>*<span class=\"hljs-built_in\">var<\/span>(--l)));\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">rotate<\/span>: x <span class=\"hljs-built_in\">calc<\/span>(var(--sgn)*<span class=\"hljs-number\">180deg<\/span>);\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">transition<\/span>: <span class=\"hljs-built_in\">var<\/span>(--t) rotate\n<\/span><\/mark><span class='shcb-loc'><span>}\n<\/span><\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-16\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Almost there, but not quite:<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551176165-94fef295-bd91-486e-ab25-75b400ebe72f.gif?resize=800%2C800&#038;ssl=1\" alt=\"Animated GIF. Shows how the rotation angle and vertical position of hinge are computed for each item as we scroll down\/ back up.\" class=\"wp-image-8715\" style=\"width:450px\"\/><figcaption class=\"wp-element-caption\">slowed down animation to make what&#8217;s happening more clear<\/figcaption><\/figure>\n<\/div>\n\n\n<p>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&#8217;t rotate in as it should, around its enter hinge. Instead, it just rotates in around its middle axis.<\/p>\n\n\n\n<p>The problem is that once an item becomes selected, the second value of the <code>transform-origin<\/code>, which gives us the <em>y<\/em> 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&nbsp;<em>after<\/em>&nbsp;the rotation, so we want to add a delay&nbsp;equal to the&nbsp;<code>transition-duration<\/code> of the rotation.<\/p>\n\n\n\n<p>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 <code>transform-origin<\/code> to abruptly move half a line thickness above\/ below the top\/ bottom edge, depending of the direction we go in.<\/p>\n\n\n\n<p>So we want a delay in the abrupt change (<code>0s<\/code> duration) of <code>transform-origin<\/code> only when an item becomes selected (<code>--sel<\/code> has flipped to <code>1<\/code>), but not when it becomes deselected (<code>--sel<\/code> has flipped to <code>0<\/code>). This means we need to multiply the delay with the selection flag.<\/p>\n\n\n\n<p>The final <code>transition<\/code> declaration therefore looks like this:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-17\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">transition<\/span>: \n  0<span class=\"hljs-selector-tag\">s<\/span> <span class=\"hljs-selector-tag\">transform-origin<\/span> <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--sel<\/span>)*<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--t<\/span>)), \n  <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--t<\/span>) <span class=\"hljs-selector-tag\">rotate<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-17\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"800\" height=\"800\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/551199082-b3e4ba83-071b-4e8b-970b-04e0f89befc2.gif?resize=800%2C800&#038;ssl=1\" alt=\"Animated GIF. Shows the correct rotation animation around the correct axis.\" class=\"wp-image-8716\" style=\"width:450px\"\/><figcaption class=\"wp-element-caption\">correct hinging all the way<\/figcaption><\/figure>\n<\/div>\n\n\n<h2 class=\"wp-block-heading\" id=\"refining-touches\">Refining Touches<\/h2>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>The same <code>--sel<\/code> flag that tells us whether an item is selected drives both the <code>color<\/code> and the <code>text\u2011indent<\/code> change. The <code>color<\/code> goes from a mid grey in the normal case to an almost black in the selected case, while the <code>text-indent<\/code> goes from <code>0<\/code> to <code>1em<\/code>. Both properties get a simple <code>transition<\/code> so the shift feels smooth.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-18\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-comment\">\/* relevant CSS for the visual motion part only *\/<\/span>\n<\/span><\/span><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--sgn<\/span>: <span class=\"hljs-built_in\">sign<\/span>(var(--i) - <span class=\"hljs-built_in\">var<\/span>(--j));\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--sel<\/span>: <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">1<\/span> - abs(var(--sgn)));\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">--t<\/span>: .<span class=\"hljs-number\">5s<\/span>;\n<\/span><\/span><span class='shcb-loc'><span>\t\n<\/span><\/span><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">color<\/span>: <span class=\"hljs-built_in\">hsl<\/span>(<span class=\"hljs-number\">0<\/span> <span class=\"hljs-number\">0%<\/span> calc(<span class=\"hljs-number\">50%<\/span> - var(--sel)*<span class=\"hljs-number\">43%<\/span>));\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">text-indent<\/span>: <span class=\"hljs-built_in\">calc<\/span>(var(--sel)*<span class=\"hljs-number\">1em<\/span>);\n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">transition<\/span>: <span class=\"hljs-built_in\">var<\/span>(--t); \n<\/span><\/mark><mark class='shcb-loc'><span>  <span class=\"hljs-attribute\">transition-property<\/span>: color, text-indent; \n<\/span><\/mark><span class='shcb-loc'><span>}\n<\/span><\/span><span class='shcb-loc'><span>\n<\/span><\/span><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">img<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">transform-origin<\/span>: \n<\/span><\/span><span class='shcb-loc'><span>    <span class=\"hljs-number\">0<\/span> <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">50%<\/span> - var(--sgn)*(<span class=\"hljs-number\">50%<\/span> + .<span class=\"hljs-number\">5<\/span>*<span class=\"hljs-built_in\">var<\/span>(--l)));\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">rotate<\/span>: x <span class=\"hljs-built_in\">calc<\/span>(var(--sgn)*<span class=\"hljs-number\">180deg<\/span>);\n<\/span><\/span><span class='shcb-loc'><span>  <span class=\"hljs-attribute\">transition<\/span>: <span class=\"hljs-built_in\">var<\/span>(--t) rotate\n<\/span><\/span><span class='shcb-loc'><span>}\n<\/span><\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-18\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Our demo now behaves like the original version, except it&#8217;s driven by scroll and the rotations are &#8220;hinged&#8221; around the separator lines. This is the version seen in the recording at the start of the article.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"issues\">Issues<\/h2>\n\n\n\n<p>The final result, while looking good in Chrome, is glitchy in Epiphany, though this doesn&#8217;t seem to be as much of a problem in actual Safari, according to the responses I got when I asked on <a href=\"https:\/\/mastodon.social\/@anatudor\/116085163828052104\">Mastodon<\/a> and <a href=\"https:\/\/bsky.app\/profile\/anatudor.bsky.social\/post\/3mf27lgt5as22\">Bluesky<\/a>. It also completely lacks any animation in Firefox. It turns out the root cause of the Firefox problem is <a href=\"https:\/\/bugzilla.mozilla.org\/show_bug.cgi?id=1927325\">this bug<\/a> some rando filed a couple of years ago. That rando was seemingly me, though I have no recollection of it anymore.<\/p>\n\n\n\n<p>Another issue is that, since both the <code>nav<\/code> and the snaps are using the dynamic viewport, there&#8217;s a lot of jumping around on mobile\/ tablet. So it&#8217;s probably better to use the small viewport for the <code>nav<\/code> and the large one for the snaps.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-19\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-class\">.snap<\/span> {\n  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n  <span class=\"hljs-attribute\">height<\/span>: <span class=\"hljs-number\">100<\/span>lvh\n}\n\n<span class=\"hljs-selector-tag\">nav<\/span> {\n  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n  <span class=\"hljs-attribute\">height<\/span>: <span class=\"hljs-number\">100s<\/span>vh\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-19\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>However, using the small viewport for the <code>nav<\/code> means it may not cover the entire viewport in all scenarios, so we could get a white band at the bottom &#8211; the default page background contrasting with the subtle one on the <code>nav<\/code>. To fix this, we need to move the <code>background<\/code> from the <code>nav<\/code> to the <code>html<\/code> or the <code>body<\/code>.<\/p>\n\n\n\n<p>Since our nav items are links, they should have usable <code>:hover<\/code> and <code>:focus<\/code> styles.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-20\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">nav<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n  <span class=\"hljs-attribute\">--hov<\/span>: <span class=\"hljs-number\">0<\/span>;\n  <span class=\"hljs-attribute\">color<\/span>: \n    <span class=\"hljs-built_in\">hsl<\/span>(<span class=\"hljs-number\">345<\/span> \n      calc(var(--hov)*<span class=\"hljs-number\">100%<\/span>) \n      <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">50%<\/span> - var(--sel)*(<span class=\"hljs-number\">1<\/span> - <span class=\"hljs-built_in\">var<\/span>(--hov))*<span class=\"hljs-number\">53%<\/span>));\n\n  &amp;:is(:hover, :focus) { <span class=\"hljs-attribute\">--hov<\/span>: <span class=\"hljs-number\">1<\/span> }\n\n  &amp;<span class=\"hljs-selector-pseudo\">:focus-visible<\/span> {\n    <span class=\"hljs-attribute\">outline<\/span>: dotted <span class=\"hljs-number\">4px<\/span>;\n    <span class=\"hljs-attribute\">outline-offset<\/span>: <span class=\"hljs-number\">2px<\/span>\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-20\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>And it&#8217;s probably best not to greet night owls with such a bright <code>background<\/code>, so we should respect user-set dark mode preferences, which means <a href=\"https:\/\/web.dev\/articles\/light-dark\">rethinking<\/a> how we set the <code>color<\/code>.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-21\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">html<\/span> {\n  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n  <span class=\"hljs-attribute\">color-scheme<\/span>: light dark;\n  <span class=\"hljs-attribute\">background<\/span>: <span class=\"hljs-built_in\">light-dark<\/span>(#dedede, #<span class=\"hljs-number\">212121<\/span>)\n}\n\n<span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-comment\">\/* same as before *\/<\/span>\n  <span class=\"hljs-attribute\">border-bottom<\/span>: solid <span class=\"hljs-built_in\">var<\/span>(--l) <span class=\"hljs-built_in\">light-dark<\/span>(#<span class=\"hljs-number\">121212<\/span>, #ededed);\n  <span class=\"hljs-attribute\">color<\/span>: \n    <span class=\"hljs-built_in\">light-dark<\/span>(\n      color-mix(in srgb, \n        #<span class=\"hljs-number\">9<\/span>b2226 var(--prc-hov), \n        <span class=\"hljs-built_in\">color-mix<\/span>(in srgb, #<span class=\"hljs-number\">023047<\/span> var(--prc-sel), <span class=\"hljs-number\">#454545<\/span>)), \n      <span class=\"hljs-built_in\">color-mix<\/span>(in srgb, \n        #ffb703 var(--prc-hov), \n        <span class=\"hljs-built_in\">color-mix<\/span>(in srgb, #<span class=\"hljs-number\">8<\/span>ecae6 var(--prc-sel), <span class=\"hljs-number\">#ababab<\/span>))\n    );\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-21\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Here&#8217;s that demo (and remember this is scroll-based not hover-based):<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_JoKqRjZ\" src=\"\/\/codepen.io\/anon\/embed\/JoKqRjZ?height=450&amp;theme-id=1&amp;slug-hash=JoKqRjZ&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed JoKqRjZ\" title=\"CodePen Embed JoKqRjZ\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>And maybe we shouldn&#8217;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&#8217;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.<\/p>\n\n\n\n<p>Finally, it&#8217;s often said scroll-jacking is a bad idea, don&#8217;t do it. I personally like scroll effects if they&#8217;re well done and not excessive, but I can understand others may have different preferences.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>However, this comes with extra challenges when sections have different heights, as well as when skipping sections via the navigation. Neither of which I&#8217;m capable of solving.<\/p>\n\n\n\n<p>Below is the best I could get. It uses JavaScript, and the animation looks bad when skipping items. It&#8217;s also not responsive, and I don&#8217;t really know what to do about it on small or very large viewports.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_zxBVyLR\" src=\"\/\/codepen.io\/anon\/embed\/zxBVyLR?height=450&amp;theme-id=1&amp;slug-hash=zxBVyLR&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed zxBVyLR\" title=\"CodePen Embed zxBVyLR\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"lessons-learned\">Lessons Learned<\/h2>\n\n\n\n<p>The most important one is probably that things don&#8217;t turn out as you expect them to.<\/p>\n\n\n\n<p>I needlessly complicated this demo early on (setting custom properties instead of <code>sibling-index()<\/code> and <code>sibling-count()<\/code>, not animating the current item index <code>--j<\/code> directly) for the sake of wider support\/avoiding bugs. And in the end, I didn&#8217;t even need to do that because it doesn&#8217;t work cross-browser anyway.<\/p>\n\n\n\n<p>I also aimed for a pure CSS solution with a nice hinging animation, but when I tried to make it usable, I couldn&#8217;t do it without JavaScript, and I couldn&#8217;t keep the animation looking nice.<\/p>\n\n\n\n<p>The other very important one is that anything can turn into a deep rabbit hole when you&#8217;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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A list of items with thumbnails that flip into place as needed. Can we ditch the JavaScript?<\/p>\n","protected":false},"author":32,"featured_media":8733,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[103,7,92],"class_list":["post-8684","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-3d","tag-css","tag-scrolling"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/flip.jpg?fit=2100%2C1321&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8684","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/32"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=8684"}],"version-history":[{"count":45,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8684\/revisions"}],"predecessor-version":[{"id":8835,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8684\/revisions\/8835"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/8733"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=8684"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=8684"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=8684"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}