Supercharged Scrolling with CSS

For Web Directions Developer Summit, 2025
by Lee Meyer
Senior Software Engineer @ propelventures.ai by day
CSS trickster @ css-tricks.com by night

old-school analog scrolling

"Read you a story? What fun would that be? I've got a better idea. Let's tell a story together"
- Adam Cadre, Photopia

acclaimed scrollytelling in 2012

https://www.nytimes.com/projects/2012/snow-fall/index.html

acclaimed scrollytelling in 2020

https://www.nytimes.com/interactive/2020/05/24/us/us-coronavirus-deaths-100000.html

Scroll-linked animation before scroll timeline

See the Pen Bind CSS keyframe animation to scroll by Chris Coyier (@chriscoyier) on CodePen.

Scroll-driven animation with scroll timeline!

See the Pen scroll timeline cube by lee (@leemeyer) on CodePen.

Scroll-linked animation without scroll timeline

							
								window.addEventListener('scroll', () => {
									document.body.style.setProperty('--scroll',
									window.pageYOffset / (document.body.offsetHeight - window.innerHeight));
								}, false);
							
						
													
							body, .progress, .cube {
								/* Pause the animation */
								animation-play-state: paused;
								/* Bind the animation to scroll */
								animation-delay: calc(var(--scroll) * -1s);
								/* These last 2 properites clean up overshoot weirdness */
								animation-iteration-count: 1;
								animation-fill-mode: both;
							}
							
						

Scroll-driven animation with scroll timeline!


						body, .progress, .cube {
							animation-timeline: scroll();
						}
					  
					  

Browser support is improving!

timeline scope

See the Pen scroll linked 3D demo by lee (@leemeyer) on CodePen.

timeline scope

						
							.scene {
								transform: rotateY(var(--my-y-angle)) rotateX(var(--my-x-angle));
							}

							body {
								timeline-scope: --myScroller,--myScroller2; 
							}

							.card:first-child {
								scroll-timeline-axis: x;
								scroll-timeline-name: --myScroller;
							}
							  
							.card:nth-child(2) {
								scroll-timeline-axis: y;
								scroll-timeline-name: --myScroller2;
							}

							.scene {
								animation: rotateHorizontal,rotateVertical;
								animation-timeline: --myScroller,--myScroller2;
							}
							  
							@keyframes rotateHorizontal {
								to {
								  --my-y-angle: 360deg;
								}
							}
							  
							@keyframes rotateVertical {
								to {
									--my-x-angle: 360deg;
								}
							}
						
					

timeline scope range inputs

See the Pen scroll-linked X-wing by lee (@leemeyer) on CodePen.

Scroll-triggered animations

See the Pen Web-slinger.css demo by lee (@leemeyer) on CodePen.

https://css-tricks.com/web-slinger-css-like-wow-js-but-with-css-y-scroll-animations/

timeline scope pong

See the Pen collision detection using style queries plus keyframes by lee (@leemeyer) on CodePen.

timeline scope pong without range syntax

						
							body {
								--int-ball-position-x: round(down, var(--ball-position-x));
								--min-ball-position-y-and-top-of-paddle: min(
								  var(--ball-position-y) + var(--ball-height),
								  var(--ping-position)
								);
								--min-ball-position-y-and-bottom-of-paddle: min(
								  var(--ball-position-y),
								  var(--ping-position) + var(--paddle-height)
								);
							}

							@container style(--min-ball-position-y-and-top-of-paddle: var(--ping-position)) 
							and style(--min-ball-position-y-and-bottom-of-paddle: var(--ball-position-y)) 
							and style(--int-ball-position-x: var(--ball-left-boundary)) {
								.screen {
									--lives-decrement: paused;
						
								.field {
									background: green;
									}
								}
							}	
							  
							@container style(--int-ball-position-x: var(--ball-left-boundary)) {
								.screen {
								  --lives-decrement: running;
							  
								  .field {
								    background: red;
								  }
								}
							}						  
						
					

timeline scope pong with range syntax

						
							body {
								--int-ball-position-x: round(down, var(--ball-position-x));
								--bottom-of-paddle-y: calc(var(--ping-position) + var(--paddle-height));
							}

							@container style(--ping-position < --ball-position-y) 
							and style(--ball-position-y < --bottom-of-paddle-y) 
							and style(--int-ball-position-x: var(--ball-left-boundary)) {
								.screen {
									--lives-decrement: paused;
						
								.field {
									background: green;
									}
								}
							}	
							  
							@container style(--int-ball-position-x: var(--ball-left-boundary)) {
								.screen {
								  --lives-decrement: running;
							  
								  .field {
								    background: red;
								  }
								}
							}						  
						
					

scroll-state queries

						
							@container scroll-state(scrollable: top) {}
							@container scroll-state(scrollable: right) {}
							@container scroll-state(scrollable: bottom) {}
							@container scroll-state(scrollable: left) {}
						
					

https://developer.chrome.com/blog/css-scroll-state-queries

scroll-state queries

nonlinear scrollytelling with scroll-state

See the Pen Nonlinear Scrollytelling by lee (@leemeyer) on CodePen.

						
							.idle {
								animation: idleAnim 1s steps(6) infinite;
							}
							
							/*scroll direction detection using scroll timelines 
							https://www.bram.us/2023/10/23/css-scroll-detection*/
							.sprite {
								transform: rotateY(calc(1deg * 
								   min(0, var(--scroll-direction) * 180)));
							}
							
							@container not style(--scroll-direction: 0) {
								.sprite {
									animation: runAnim 0.8s steps(8) infinite;
								}
							}
						
					
						
							.sky, .buildings-back, .buildings-mid, .sky-vertical, .buildings-back-vertical, .buildings-mid-vertical {
								position: fixed;
								top: 0;
								left: 0;
								width: 800%;
								height: max(100vh, 300px);
								background-size: auto max(100vh, 300px);
								background-repeat: repeat-x;
								animation-timing-function: linear;
								animation-timeline: scroll(x);
							}
							  
							/*...repetitively assign the corresponding animations to each layer...*/
							  
							@keyframes move-sky {
								from {
								  transform: translateX(0);
								}
								to {
								  transform: translateX(-2.5%);
								}
							}
							  
							@keyframes move-back {
								from {
								  transform: translateX(0);
								}
								to {
								  transform: translateX(-6.25%);
								}
							}
							  
							@keyframes move-mid {
								from {
								  transform: translateX(0);
								}
								to {
								  transform: translateX(-12.5%);
								}
							}
						
					
						
							@container scroll-state((scrollable: left)) {
								body {
									overflow-y: hidden;
								}
							}
							
							@container scroll-state((scrollable: bottom)) {
								body {
									width: 0;
								}
							}

							:root {
								animation: adjust-pos 1s linear, move-sprite-up 1s linear, move-player 1s linear;
								animation-timeline: scroll(x), scroll(y), scroll(x);
								container-type: scroll-state;
							
								.ladder {
								  height: 300vh;
								}

								.spawn-point {
									position: absolute;
									left: 400vw;
									scroll-initial-target: nearest;
								}
							}
							
							@keyframes collect-saber {
								from {
									--player-has-saber: false;
								}
							
								to {
									--player-has-saber: true;
								}
							}
							
							body {
								animation: .25s forwards var(--saber-collection-state, paused) collect-saber;
							}
							
							@container scroll-state(not (scrollable: top)) {
								body {
									--saber-collection-state: running;
								}
							}
							
							@container style(--player-has-saber: true) {
								.sprite {
									background-image: url(/*combat spritesheet*/);
								}
							
								.lightsaber {
									visibility: hidden;
								}
							}
							
							body {
								--min-of-player-and-enemy-x: min(var(--player-x-offset), var(--enemy-x-offset) - 10px);
								--max-of-player-and-enemy-y: max(var(--player-y-offset, 5px));
								--game-state: if(style(--min-of-player-and-enemy-x: calc(var(--enemy-x-offset) - 10px)) and style(--max-of-player-and-enemy-y: 5px): ending;
										else: playing);
								overflow: if(style(--game-state: ending): hidden;
										else: scroll);
							}
							
							@container style(--player-has-saber: true) and style(--game-state: ending) {
								.player-wrapper {
									.sprite {
										animation: attack 0.7s steps(4) forwards;
									}
							
									.speech-bubble {
										animation: show-endgame-message 3s linear 1s forwards;
							
										&::before {
											content: 'Refresh the page to play again';
										}
									}
							
									.evil-twin-wrapper {
										.evil-twin {
											evil-twin-die 0.8s steps(4) .7s forwards;
										}
									}
								}
							
								@container style(--player-has-saber: false) and style(--game-state: ending) {
									.player-wrapper {
										.sprite {
											animation: player-die .8s steps(6) .7s forwards;
										}
									}
							
									.evil-twin-wrapper {
										.speech-bubble {
											animation: show-endgame-message 3s linear 1s forwards;
											display: block;
							
											&::before {
												content: 'Baha! Refresh the page to fight me again';
											}
							
											.evil-twin {
												attack 0.8s steps(4) infinite;
											}
										}
									}
								}
								
								@container scroll-state((scrollable: top) and ((scrollable: bottom))) {
									.player-wrapper {
										.sprite {
											animation: climbAnim 1s steps(8);
											animation-timeline: scroll(root y);
											animation-iteration-count: 10;
										}
									}
								}
						
					

Parting thoughts

  • Design is storytelling - does it fit your usecase?
  • If so use the platform! It has scrollytelling built in!

Thank you

https://css-tricks.com/author/leemeyer/ https://www.linkedin.com/in/meyerlee/
Slides @ https://leemeyer.github.io/