Swipe horizontally. Each panel snaps to the start edge.
1. Scroll Snap
scroll-snap-type on the container and scroll-snap-align on children create smooth snapping behavior for carousels, image galleries, and full-page sections.
Scroll vertically. Full-page section snapping effect.
Only snaps when close to a snap point. More forgiving than mandatory.
Items snap to center. Each item is 80% width, creating a peek effect.
/* Container: enable snapping */
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory; /* x | y | both, mandatory | proximity */
}
/* Children: define snap points */
.carousel-item {
flex: 0 0 100%;
scroll-snap-align: start; /* start | center | end */
}
/* Vertical full-page snapping */
.fullpage {
overflow-y: auto;
scroll-snap-type: y mandatory;
height: 100vh;
}
.fullpage section {
height: 100vh;
scroll-snap-align: start;
}
/* Proximity: only snap when near a snap point */
.gallery { scroll-snap-type: x proximity; }
/* Padding for peek effect */
.carousel { scroll-padding: 0 1rem; }
/* Stop scrolling momentum at snap point */
.carousel-item { scroll-snap-stop: always; }2. Scroll Progress Indicator
A progress bar that fills as the user scrolls. Can be built with JavaScript or purely with CSS using animation-timeline: scroll().
Scroll down inside this container to see the progress bar fill up at the top. This is a common pattern for reading progress on articles and blog posts.
The progress bar width is calculated based on how far the user has scrolled through the content. It uses a simple scroll event listener.
This technique works well for long-form content where users want to know how much is left to read.
The calculation is: scrollTop / (scrollHeight - clientHeight) * 100. This gives a percentage from 0 to 100.
You can customize the bar with gradients, different heights, or even rounded edges. The transition property makes it feel smooth.
For best performance, consider using requestAnimationFrame or a passive scroll listener to avoid jank.
Scroll inside the container. The bar tracks scroll position via JS.
This progress bar is pure CSS using animation-timeline: scroll(). No JavaScript needed!
The browser automatically maps the scroll position to the animation progress. The bar grows from 0% to 100% width.
This is part of the Scroll-Driven Animations spec, supported in Chrome 115+ and Edge 115+.
Keep scrolling to see the bar fill up. It updates in real-time with the scroll position.
The animation uses the nearest scrollable ancestor by default, but you can target specific containers.
This approach is more performant than JavaScript because it runs on the compositor thread.
You can combine scroll() with other animation properties like timing functions for custom easing.
The future of scroll-linked effects is CSS-native, reducing the need for scroll libraries.
Pure CSS using animation-timeline: scroll(). No JS required.
/* CSS-only scroll progress bar */
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
width: 0%;
animation: grow-bar linear;
animation-timeline: scroll(); /* links to page scroll */
}
@keyframes grow-bar {
from { width: 0%; }
to { width: 100%; }
}
/* JavaScript approach */
// window.addEventListener('scroll', () => {
// const pct = window.scrollY
// / (document.body.scrollHeight - window.innerHeight) * 100;
// bar.style.width = pct + '%';
// }, { passive: true });3. Reveal on Scroll
Use IntersectionObserver to trigger CSS animations when elements enter the viewport. A lightweight alternative to scroll event listeners.
Elements fade in and slide up as they enter the scroll viewport.
Mix slide and scale reveals for varied entrance effects.
/* CSS: hidden state */
.reveal {
opacity: 0;
transform: translateY(2rem);
transition: opacity 0.6s ease, transform 0.6s ease;
}
/* CSS: visible state (added by JS) */
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Slide from left variant */
.reveal--left {
transform: translateX(-3rem);
}
.reveal--left.is-visible {
transform: translateX(0);
}
/* JavaScript: IntersectionObserver */
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.reveal')
.forEach(el => observer.observe(el));4. Parallax with Sticky Layers
Create depth with layers that scroll at different speeds. This technique uses position: sticky to stack layers as the user scrolls.
Scroll to see layers stack over each other using position: sticky.
/* Parallax with position: sticky */
.parallax-container {
height: 100vh;
overflow-y: auto;
}
.parallax-wrapper {
height: 300vh; /* tall enough for scroll distance */
}
.parallax-layer {
position: sticky;
top: 0;
height: 100vh;
}
.layer-back { z-index: 1; background: #0f172a; }
.layer-mid { z-index: 2; background: #4f46e5; }
.layer-front { z-index: 3; background: #d97706; }
/* CSS transform parallax (alternative) */
.parallax-bg {
transform: translateZ(-1px) scale(2);
/* requires perspective on parent */
}
.parallax-parent {
perspective: 1px;
height: 100vh;
overflow-y: auto;
transform-style: preserve-3d;
}5. Scroll-Driven View Animations
The animation-timeline: view() property links an animation to an element's visibility within its scroll container. Pure CSS, no JavaScript needed.
Scroll down to see the box animate as it enters and exits the viewport. This uses animation-timeline: view() which is pure CSS.
The animation progress is tied to the element's visibility within the scrollport.
The box rotates and scales based on how much of it is visible. At 0% visibility it's small, at 50% it's full size, and back to small at 100%.
This works without any JavaScript. The browser handles all the scroll tracking on the compositor thread for smooth performance.
You can apply different keyframe animations to different elements, each responding to their own visibility.
Supported in Chrome/Edge 115+. Use @supports to provide fallbacks.
Boxes rotate and scale as they scroll into view. Pure CSS.
/* Animate based on element visibility in scrollport */
.reveal-box {
animation: appear linear;
animation-timeline: view(); /* track this element's visibility */
animation-range: entry 0% cover 40%; /* start-end of animation range */
}
@keyframes appear {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
/* Rotate as element scrolls through viewport */
.spin-box {
animation: spin-through linear;
animation-timeline: view();
}
@keyframes spin-through {
from { transform: rotate(0deg) scale(0.5); opacity: 0; }
50% { transform: rotate(180deg) scale(1); opacity: 1; }
to { transform: rotate(360deg) scale(0.5); opacity: 0; }
}
/* Feature detection */
@supports (animation-timeline: view()) {
.box { animation: fade-in linear; animation-timeline: view(); }
}