Loading...
Loading...
CSS Scroll-Driven Animations with ScrollTimeline, ViewTimeline, parallax effects, and progressive enhancement for performant scroll effects. Use when implementing scroll-linked animations or parallax.
npx skill4agent add yonatangross/orchestkit scroll-driven-animations| Timeline | CSS Function | Use Case |
|---|---|---|
| Scroll Progress | | Tied to scroll container position (0-100%) |
| View Progress | | Tied to element visibility in viewport |
/* Progress bar that fills as page scrolls */
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: var(--color-primary);
transform-origin: left;
/* Animate based on root scroll */
animation: grow-progress linear;
animation-timeline: scroll(root block);
}
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}/* Fade in when element enters viewport */
.reveal-on-scroll {
animation: fade-slide-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes fade-slide-up {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}/* Fine-tune when animation runs */
.card {
animation: scale-up linear both;
animation-timeline: view();
/* Start at 25% entry, complete at 75% entry */
animation-range: entry 25% entry 75%;
}
/* Full visibility animation */
.hero-image {
animation: parallax linear both;
animation-timeline: view();
/* Animate through entire visibility */
animation-range: cover 0% cover 100%;
}
@keyframes parallax {
from { transform: translateY(-20%); }
to { transform: translateY(20%); }
}/* Define timeline on scroll container */
.scroll-container {
overflow-y: auto;
scroll-timeline-name: --container-scroll;
scroll-timeline-axis: block;
}
/* Use timeline in descendant */
.progress-indicator {
animation: progress linear;
animation-timeline: --container-scroll;
}
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}/* Parent sets up the timeline scope */
.gallery {
timeline-scope: --card-timeline;
}
/* Each card defines its view timeline */
.gallery-card {
view-timeline-name: --card-timeline;
view-timeline-axis: block;
}
/* Animate based on card visibility */
.gallery-card .image {
animation: zoom-in linear both;
animation-timeline: --card-timeline;
animation-range: entry 0% cover 50%;
}
@keyframes zoom-in {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}.parallax-section {
position: relative;
overflow: hidden;
}
.parallax-bg {
position: absolute;
inset: -20% 0;
animation: parallax-scroll linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
@keyframes parallax-scroll {
from { transform: translateY(0); }
to { transform: translateY(40%); }
}.header {
position: sticky;
top: 0;
animation: shrink-header linear both;
animation-timeline: scroll(root);
animation-range: 0px 200px;
}
@keyframes shrink-header {
from {
padding-block: 2rem;
background: transparent;
}
to {
padding-block: 0.5rem;
background: var(--color-surface);
box-shadow: var(--shadow-md);
}
}// Create scroll timeline programmatically
const scrollTimeline = new ScrollTimeline({
source: document.documentElement, // or specific scroll container
axis: 'block', // 'block' | 'inline' | 'x' | 'y'
});
// Attach to animation
element.animate(
[
{ transform: 'translateY(100px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 },
],
{
timeline: scrollTimeline,
fill: 'both',
}
);// Create view timeline for specific element
const viewTimeline = new ViewTimeline({
subject: element, // Element to track
axis: 'block',
inset: [CSS.px(0), CSS.px(0)], // Optional viewport inset
});
// Animate based on element visibility
element.animate(
[
{ opacity: 0, transform: 'scale(0.8)' },
{ opacity: 1, transform: 'scale(1)' },
],
{
timeline: viewTimeline,
fill: 'both',
rangeStart: 'entry 0%',
rangeEnd: 'cover 50%',
}
);import { useRef, useEffect } from 'react';
function useScrollAnimation(
keyframes: Keyframe[],
options: {
timeline?: 'scroll' | 'view';
range?: string;
} = {}
) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!element || !('animate' in element)) return;
// Feature detection
if (!('ScrollTimeline' in window)) {
console.warn('Scroll-driven animations not supported');
return;
}
const timeline = options.timeline === 'view'
? new ViewTimeline({ subject: element, axis: 'block' })
: new ScrollTimeline({ source: document.documentElement, axis: 'block' });
const animation = element.animate(keyframes, {
timeline,
fill: 'both',
...(options.range && {
rangeStart: options.range.split(' ')[0],
rangeEnd: options.range.split(' ')[1],
}),
});
return () => animation.cancel();
}, [keyframes, options.timeline, options.range]);
return ref;
}
// Usage
function RevealCard({ children }: { children: React.ReactNode }) {
const ref = useScrollAnimation(
[
{ opacity: 0, transform: 'translateY(50px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ timeline: 'view', range: 'entry cover' }
);
return <div ref={ref}>{children}</div>;
}/* Fallback for unsupported browsers */
.reveal-on-scroll {
opacity: 1; /* Default visible */
transform: translateY(0);
}
/* Apply animation only when supported */
@supports (animation-timeline: view()) {
.reveal-on-scroll {
animation: fade-slide-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}// Feature detection in React
const supportsScrollTimeline =
typeof ScrollTimeline !== 'undefined';
function AnimatedSection({ children }: { children: React.ReactNode }) {
if (!supportsScrollTimeline) {
// Fallback: use Intersection Observer
return <IntersectionObserverFallback>{children}</IntersectionObserverFallback>;
}
return <ScrollAnimatedSection>{children}</ScrollAnimatedSection>;
}/* ✅ CORRECT: Animate transform/opacity only */
@keyframes good-animation {
from { transform: translateY(100px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* ❌ WRONG: Animate layout properties */
@keyframes bad-animation {
from { margin-top: 100px; height: 0; }
to { margin-top: 0; height: auto; }
}
/* ✅ Use will-change sparingly */
.scroll-animated {
will-change: transform, opacity;
}/* ❌ NEVER: Animate layout-triggering properties */
@keyframes bad {
from { width: 0; margin-left: 100px; }
to { width: 100%; margin-left: 0; }
}
/* ❌ NEVER: Use without fallback */
.element {
animation-timeline: scroll(); /* Breaks in Firefox! */
}
/* ❌ NEVER: Overly complex animation chains */
.element {
animation: anim1, anim2, anim3, anim4, anim5;
animation-timeline: view(), scroll(), view(), scroll(), view();
}
/* ❌ NEVER: Scroll animations on non-scrollable containers */
.no-overflow {
overflow: hidden;
scroll-timeline-name: --timeline; /* Won't work! */
}| Browser | scroll() | view() | ScrollTimeline API |
|---|---|---|---|
| Chrome 115+ | ✅ | ✅ | ✅ |
| Edge 115+ | ✅ | ✅ | ✅ |
| Safari 18.4+ | ✅ | ✅ | ✅ |
| Firefox | ❌ | ❌ | ❌ (in development) |
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Timeline type | scroll() | view() | view() for reveals, scroll() for progress |
| Fallback strategy | IntersectionObserver | No animation | IntersectionObserver fallback |
| Animation properties | All CSS | transform/opacity | transform/opacity only |
| Range units | Percentages | Named ranges | Named ranges (entry, cover) for clarity |
motion-animation-patternscore-web-vitalsview-transitionsreferences/css-scroll-timeline.mdreferences/js-api.mdscripts/parallax-section.tsx