CSS Scroll Snapping and Scroll Timeline to create a card stack with a non linear animation timeline. JS required for Scroll Snap events.
Inspired by https://x.com/nasm423/status/1795133452016054401
A Pen by Whizboy-Arnold on CodePen.
| <main data-active-index="0" data-debug="false"> | |
| <div class="card-stack"> | |
| <div class="card"><img src="https://assets.codepen.io/215059/card-stack-demo-05.jpg" /></div> | |
| <div class="card"><img src="https://assets.codepen.io/215059/card-stack-demo-04.jpg" /></div> | |
| <div class="card"><img src="https://assets.codepen.io/215059/card-stack-demo-06.jpg" /></div> | |
| <div class="card"><img src="https://assets.codepen.io/215059/card-stack-demo-07.jpg" /></div> | |
| <div class="card"><img src="https://assets.codepen.io/215059/card-stack-demo-08.jpg" /></div> | |
| </div> | |
| <div class="scroller"> | |
| <div class="scroll-item"></div> | |
| <div class="scroll-item"></div> | |
| <div class="scroll-item"></div> | |
| <div class="scroll-item"></div> | |
| <div class="scroll-item"></div> | |
| </div> | |
| </main> | |
| <!-- Everything below this line is for the demo only --> | |
| <div class="warning"> | |
| <span>Mouse drag not supported. Use responsive mode or a touch pad to scroll.</span> | |
| </div> | |
| <div class="explainer"> | |
| <input type="checkbox"></input> | |
| <div> | |
| <svg id="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><style>.cls-1{fill:none;}</style></defs><title>help</title><path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2Zm0,26A12,12,0,1,1,28,16,12,12,0,0,1,16,28Z" fill="white"/><circle cx="16" cy="23.5" r="1.5" fill="white"/><path d="M17,8H15.5A4.49,4.49,0,0,0,11,12.5V13h2v-.5A2.5,2.5,0,0,1,15.5,10H17a2.5,2.5,0,0,1,0,5H15v4.5h2V17a4.5,4.5,0,0,0,0-9Z" fill="white"/></svg> | |
| </div> | |
| <p>Inspired by original <a href="https://x.com/nasm423/status/1795133452016054401">Nate Smith</a> demo.</p> | |
| <p> | |
| Demo of CSS <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline/scroll#browser_compatibility">scroll()</a> function with <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type">scroll-snap</a>, <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline">animation-timeline</a>, and <a href="https://developer.chrome.com/blog/scroll-snap-events">scroll snap events</a>. | |
| </p> | |
| <p> | |
| Requires Chrome 129+. Try in Chrome on Android or in Chrome Desktop responsive mode (use <a href="https://codepen.io/paulnoble/pen/gOVPedz">debug view</a>) or with a trackpad for best scrolling experience. | |
| </p> | |
| <p> | |
| <a href="https://x.com/paul_uiux">paul_uiux@</a></p> | |
| </div> | |
| <!-- Warn pointer down --> | |
| <script> | |
| const warn = () => { | |
| document.querySelector('.warning').classList.toggle('show'); | |
| setTimeout(() => document.querySelector('.warning').classList.toggle('show'), 3000); | |
| }; | |
| document.querySelector('main').addEventListener('mousedown', warn); | |
| </script> |
| const main = document.querySelector("main"); | |
| document.querySelector(".scroller").addEventListener("scrollsnapchange", (event) => { | |
| main.dataset.activeIndex = Math.round(event.target.scrollLeft / main.getBoundingClientRect().width); | |
| }); |
CSS Scroll Snapping and Scroll Timeline to create a card stack with a non linear animation timeline. JS required for Scroll Snap events.
Inspired by https://x.com/nasm423/status/1795133452016054401
A Pen by Whizboy-Arnold on CodePen.
| @use "sass:math"; | |
| $card-count: 5; | |
| $step-distance: (100 / ($card-count - 1)); | |
| $half-step-distance: ($step-distance / 2); | |
| $theme-body-bg: #471703, #282624, #212e14, #184152, #162432; | |
| $theme-card-bg: #471703, #282624, #212e14, #184152, #162432; | |
| :root { | |
| font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; | |
| line-height: 1.5; | |
| font-weight: 400; | |
| --dim-card-width: 300px; | |
| --dim-card-height: 400px; | |
| --dim-card-border-radius: 16px; | |
| --box-shadow-card: 0 1px 12px rgba(0, 0, 0, 0.4); | |
| } | |
| ::-webkit-scrollbar { | |
| display: none; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| body, | |
| html { | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background-color: #ddd; | |
| height: 100vh; | |
| min-height: 800px; | |
| timeline-scope: --scroll-timeline; | |
| animation: theme-bg linear forwards; | |
| animation-timeline: --scroll-timeline; | |
| } | |
| main { | |
| width: 800px; | |
| height: 600px; | |
| perspective: 1000px; | |
| transform-style: preserve-3d; | |
| } | |
| .scroller { | |
| width: 100%; | |
| height: 100%; | |
| overflow: auto; | |
| white-space: nowrap; | |
| display: flex; | |
| scroll-snap-type: x mandatory; | |
| scroll-timeline: --scroll-timeline; | |
| scroll-timeline-axis: x; | |
| } | |
| .scroll-item { | |
| flex: 0 0 100%; | |
| height: 100%; | |
| scroll-snap-align: start; | |
| } | |
| [data-debug="true"] .scroll-item { | |
| outline: 1px dashed magenta; | |
| } | |
| .card-stack { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| bottom: 0; | |
| right: 0; | |
| transform-style: preserve-3d; | |
| pointer-events: none; | |
| } | |
| .card { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| bottom: 0; | |
| right: 0; | |
| margin: auto; | |
| width: var(--dim-card-width); | |
| height: var(--dim-card-height); | |
| border-radius: var(--dim-card-border-radius); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transform-style: preserve-3d; | |
| overflow: hidden; | |
| background-color: rgba(255, 255, 255, 0.8); | |
| box-shadow: var(--box-shadow-card); | |
| } | |
| .card img { | |
| position: absolute; | |
| object-fit: cover; | |
| width: 100%; | |
| z-index: 1; | |
| height: 100%; | |
| } | |
| .card::before { | |
| position: absolute; | |
| top: -4px; | |
| bottom: -4px; | |
| right: -4px; | |
| left: -4px; | |
| content: ""; | |
| } | |
| @mixin card-active { | |
| transform: translateX(0) rotateY(0) rotateZ(0) rotateX(0) translateZ(0); | |
| } | |
| @mixin card-exiting-to-left { | |
| transform: translateX(-116%) rotateY(-24deg) rotateZ(0) rotateX(2deg) | |
| translateZ(-156px); | |
| } | |
| @mixin card-exiting-to-right { | |
| transform: translateX(116%) rotateY(24deg) rotateZ(0) rotateX(2deg) | |
| translateZ(-156px); | |
| } | |
| @mixin card-exited-to-left($steps) { | |
| $rotateZ: -4 + ($steps * -2); | |
| $translateX: -3 - ($steps * 8); | |
| $translateZ: -140 - ($steps * 20); | |
| transform: translateX($translateX * 1%) rotateY(0) rotateZ($rotateZ * 1deg) | |
| rotateX(0) translateZ($translateZ * 1px); | |
| } | |
| @mixin card-exited-to-right($steps) { | |
| $rotateZ: 4 + ($steps * 2); | |
| $translateX: 3 + ($steps * 8); | |
| $translateZ: -140 - ($steps * 20); | |
| transform: translateX($translateX * 1%) rotateY(0) rotateZ($rotateZ * 1deg) | |
| rotateX(0) translateZ($translateZ * 1px); | |
| } | |
| @mixin animate-active($index) { | |
| @keyframes animate#{$index}-active { | |
| @for $i from 0 through $card-count - 1 { | |
| $percentage: $step-distance * $i; | |
| $active: $i == $index - 1; | |
| $steps: $i - ($index - 1); | |
| @if not $active { | |
| #{$percentage * 1%} { | |
| @if $steps > 0 { | |
| @include card-exited-to-left($steps); | |
| } @else { | |
| @include card-exited-to-right((0 - $steps)); | |
| } | |
| } | |
| } | |
| @if ($active) { | |
| @if $i > 0 { | |
| #{($percentage - $half-step-distance) * 1%} { | |
| @include card-exiting-to-right; | |
| } | |
| } | |
| #{$percentage * 1%} { | |
| @include card-active; | |
| } | |
| #{($percentage + $half-step-distance) * 1%} { | |
| @include card-exiting-to-left; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @mixin animate-inactive($index) { | |
| @keyframes animate#{$index}-inactive { | |
| @for $i from 0 through $card-count - 1 { | |
| $percentage: $step-distance * $i; | |
| $active: $i == $index - 1; | |
| $steps: $i - ($index - 1); | |
| @if not $active { | |
| #{$percentage * 1%} { | |
| @if $steps > 0 { | |
| @include card-exited-to-left($steps); | |
| } @else { | |
| @include card-exited-to-right((0 - $steps)); | |
| } | |
| } | |
| } | |
| @if ($active) { | |
| #{$percentage * 1%} { | |
| @include card-active; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @mixin card-stack-keyframes($index) { | |
| @keyframes card-stack-#{$index} { | |
| @for $i from 0 through $card-count - 1 { | |
| $percentage: $step-distance * $i; | |
| $steps: $i - ($index - 1); | |
| $rotate: if($steps > 0, -24deg, 24deg); | |
| @if $i > 0 { | |
| #{($percentage - $half-step-distance) * 1%} { | |
| transform: rotateY($rotate); | |
| } | |
| } | |
| #{$percentage * 1%} { | |
| transform: rotateY(0deg); | |
| } | |
| #{($percentage + $half-step-distance) * 1%} { | |
| transform: rotateY($rotate) translateZ($rotate); | |
| } | |
| } | |
| } | |
| } | |
| @mixin animate-card-image($index) { | |
| @keyframes animate-card-image-#{$index} { | |
| @for $i from 0 through $card-count - 1 { | |
| $percentage: $step-distance * $i; | |
| $active: $i == $index - 1; | |
| $steps: $i - ($index - 1); | |
| $opacity: 1 / (math.pow(3, abs($steps))); | |
| #{$percentage * 1%} { | |
| opacity: $opacity; | |
| } | |
| } | |
| } | |
| } | |
| @for $i from 1 through $card-count { | |
| .card:nth-child(#{$i}) { | |
| animation: animate#{$i}-inactive linear forwards; | |
| animation-timeline: --scroll-timeline; | |
| } | |
| [data-active-index="#{$i - 1}"] .card:nth-child(#{$i}) { | |
| animation: animate#{$i}-active linear forwards; | |
| animation-timeline: --scroll-timeline; | |
| } | |
| .card:nth-child(#{$i}) img { | |
| animation: animate-card-image-#{$i} linear forwards; | |
| animation-timeline: --scroll-timeline; | |
| } | |
| [data-active-index="#{$i - 1}"] .card-stack { | |
| animation: card-stack-#{$i} linear forwards; | |
| animation-timeline: --scroll-timeline; | |
| } | |
| .card:nth-child(#{$i})::before { | |
| animation: card-bg linear forwards; | |
| animation-timeline: --scroll-timeline; | |
| } | |
| @include animate-active($i); | |
| @include animate-inactive($i); | |
| @include animate-card-image($i); | |
| @include card-stack-keyframes($i); | |
| } | |
| // Back ground theme | |
| @keyframes theme-bg { | |
| @for $i from 1 through $card-count { | |
| $percentage: $step-distance * ($i - 1); | |
| #{$percentage * 1%} { | |
| background-color: nth($theme-body-bg, $i); | |
| } | |
| } | |
| } | |
| // Back ground theme | |
| @keyframes card-bg { | |
| @for $i from 1 through $card-count { | |
| $percentage: $step-distance * ($i - 1); | |
| #{$percentage * 1%} { | |
| background-color: nth($theme-card-bg, $i); | |
| } | |
| } | |
| } | |
| // ----------------------------------- | |
| // Styles for the demo explainer only | |
| .warning { | |
| position: absolute; | |
| bottom: 24px; | |
| left: 0; | |
| right: 0; | |
| text-align: center; | |
| white-space: nowrap; | |
| opacity: 0; | |
| transition: 300ms opacity; | |
| span { | |
| display: inline-flex; | |
| font-size: 12px; | |
| height: 28px; | |
| align-items: center; | |
| color: white; | |
| padding: 0 12px; | |
| border-radius: 14px; | |
| font-weight: 500; | |
| background-color: rgba(0, 0, 0, 0.66); | |
| } | |
| } | |
| .warning.show { | |
| opacity: 1; | |
| } | |
| .explainer { | |
| position: absolute; | |
| right: 12px; | |
| top: 12px; | |
| background-color: transparent; | |
| width: 300px; | |
| color: white; | |
| padding: 24px; | |
| padding-right: 40px; | |
| display: none; | |
| z-index: 9; | |
| } | |
| .explainer input, .explainer div { | |
| position: absolute; | |
| top: 8px; | |
| right: 8px; | |
| appearance: none; | |
| width: 30px; | |
| height: 30px; | |
| } | |
| .explainer div { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| pointer-events: none; | |
| border-radius: 50%; | |
| } | |
| .explainer div svg { | |
| width: 24px; | |
| opacity: 0.5; | |
| } | |
| .explainer:has(input:checked) div::before { | |
| content: '×'; | |
| font-size: 20px; | |
| } | |
| .explainer:has(input:checked) div svg { | |
| opacity: 0; | |
| } | |
| .explainer p { | |
| display: none; | |
| } | |
| .explainer:has(input:checked) p { | |
| display: block; | |
| } | |
| .explainer:has(input:checked) { | |
| background-color: black; | |
| } | |
| .explainer a { | |
| color: inherit; | |
| font-family: inherit; | |
| } | |
| .explainer p { | |
| font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; | |
| font-size: 12px; | |
| line-height: 18px; | |
| } | |
| .explainer p ~ p { | |
| margin-top: 1em; | |
| } | |
| @media (min-width: 768px) { | |
| .explainer { | |
| display: block; | |
| } | |
| .device { | |
| border-radius: 40px; | |
| box-shadow: 0 0 0 6px rgb(20, 20, 20); | |
| } | |
| } |