scrollytelling

ساخت روایت‌های تعاملی و سینمایی با اسکرول، پارالاکس و انیمیشن‌های مرحله‌ای

این Skill برای ساخت تجربه‌های Scrollytelling طراحی شده است؛ از reveal و parallax تا progress bar، بخش‌های snap، عناصر sticky و روایت‌های چندمرحله‌ای. می‌تواند بر پایه CSS مدرن، Intersection Observer یا GSAP ScrollTrigger راهکار مناسب پیشنهاد دهد.

برای لندینگ‌پیج‌های تعاملی، داستان‌گویی داده‌محور و تجربه‌های محصولی جذاب مفید است و هم‌زمان fallback مرورگرها و حالت prefers-reduced-motion را در نظر می‌گیرد.

چه چیزی می‌گیرید

  • الگوی کامل reveal، stagger و فعال‌سازی محتوا هنگام ورود به viewport
  • پیاده‌سازی parallax، scrub، progress bar و بخش‌های pinned
  • ساخت روایت‌های مرحله‌ای، افقی و مبتنی بر scroll position
  • پشتیبانی از CSS Scroll-Driven Animations، Intersection Observer و GSAP ScrollTrigger
  • راهکارهای دسترس‌پذیر برای reduced motion و مرورگرهای بدون پشتیبانی کامل

برای چه کاری خوب است

  • لندینگ‌پیج‌های برند، محصول یا کمپین با روایت تصویری و تعاملی
  • داشبوردها و گزارش‌های داده‌محور که باید با اسکرول مرحله‌به‌مرحله نمایش داده شوند
  • تجربه‌هایی که به parallax، sticky sections یا انیمیشن وابسته به پیشرفت اسکرول نیاز دارند

برای چه کاری نیست

  • صفحه‌های ساده‌ای که فقط به چند transition معمولی نیاز دارند
  • اپلیکیشن‌هایی که اسکرول آزاد و کم‌تحرک برای عملکرد یا دسترس‌پذیری اولویت دارد
  • انیمیشن‌های مستقل از اسکرول یا تولید ویدیوهای خطی

دربارهٔ این اسکیل

Scrollytelling

Scroll-driven narrative animations.

Standards Reference

StandardDocumentation
W3C Scroll-Driven Animationsdrafts.csswg.org/scroll-animations-1
CSS scroll-snapdeveloper.mozilla.org/CSS/scroll-snap
Intersection Observerdeveloper.mozilla.org/API/Intersection_Observer

Quick Reference

Uses tokens from: tokens/motion.tokens.json

Scroll Timings

TriggerAnimation StartAnimation End
Appear0% visible50% visible
Full reveal25% visible100% visible
ParallaxEnter viewportExit viewport
ScrubScroll positionScroll position

Selection Guide

What scroll effect?
│
├── REVEAL ON SCROLL → Elements appear
│   ├── Fade up → opacity + translate
│   ├── Stagger → sequential reveal
│   └── Intersection-based trigger
│
├── PARALLAX → Depth layers
│   ├── Background → slow scroll
│   ├── Foreground → fast scroll
│   └── Multi-layer composition
│
├── SCROLL PROGRESS → Track position
│   ├── Progress bar → top of page
│   ├── Chapter indicator → current section
│   └── Scrub animation → position-linked
│
├── SNAP SECTIONS → Full-page stops
│   ├── Mandatory → always snap
│   ├── Proximity → snap when close
│   └── With transitions
│
├── STICKY ELEMENTS → Fixed while scrolling
│   ├── Sticky header → compact on scroll
│   ├── Sticky sidebar → stays visible
│   └── Pinned section → holds during scroll
│
└── NARRATIVE → Story progression
    ├── Step-based → discrete chapters
    ├── Continuous → smooth progression
    └── Interactive → user-controlled pace

Patterns

Scroll Reveal

Elements appear on scroll.

CSS (Intersection Observer):

.reveal {
  opacity: 0;
  transform: translateY(40px);
  transition: opacity 600ms ease-out, transform 600ms ease-out;
}

.reveal.visible {
  opacity: 1;
  transform: translateY(0);
}

JavaScript:

function setupScrollReveal() {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
        observer.unobserve(entry.target); // Only animate once
      }
    });
  }, {
    threshold: 0.2, // 20% visible
    rootMargin: '0px 0px -50px 0px' // Trigger slightly before center
  });

  document.querySelectorAll('.reveal').forEach(el => {
    observer.observe(el);
  });
}

Staggered reveal:

function staggeredReveal(container) {
  const items = container.querySelectorAll('.reveal-item');

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        items.forEach((item, i) => {
          setTimeout(() => {
            item.classList.add('visible');
          }, i * 100); // 100ms stagger
        });
        observer.disconnect();
      }
    });
  }, { threshold: 0.1 });

  observer.observe(container);
}

Parallax Layers

Multi-layer depth effect.

CSS (Native scroll-driven):

@supports (animation-timeline: scroll()) {
  .parallax-slow {
    animation: parallax-move linear both;
    animation-timeline: scroll();
    animation-range: 0% 100%;
  }

  @keyframes parallax-move {
    from { transform: translateY(-20%); }
    to { transform: translateY(20%); }
  }
}

JavaScript (Fallback):

class ParallaxScroll {
  constructor() {
    this.layers = [];
    this.bind();
  }

  add(element, speed) {
    this.layers.push({ element, speed });
  }

  bind() {
    let ticking = false;

    window.addEventListener('scroll', () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          this.update();
          ticking = false;
        });
        ticking = true;
      }
    });
  }

  update() {
    const scrollY = window.scrollY;

    this.layers.forEach(({ element, speed }) => {
      const offset = scrollY * speed;
      element.style.transform = `translateY(${offset}px)`;
    });
  }
}

// Usage
const parallax = new ParallaxScroll();
parallax.add(document.querySelector('.bg-layer'), 0.3);
parallax.add(document.querySelector('.mid-layer'), 0.6);
parallax.add(document.querySelector('.fg-layer'), 1.2);

Scroll Progress

Track scroll position.

CSS (Native):

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  background: #3b82f6;
  transform-origin: left;
  animation: progress linear both;
  animation-timeline: scroll();
}

@keyframes progress {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

JavaScript:

function setupProgressBar(bar) {
  function update() {
    const scrollTop = window.scrollY;
    const docHeight = document.documentElement.scrollHeight - window.innerHeight;
    const progress = scrollTop / docHeight;

    bar.style.transform = `scaleX(${progress})`;
  }

  window.addEventListener('scroll', update, { passive: true });
  update();
}

Scrub Animation

Animation tied to scroll position.

GSAP ScrollTrigger:

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

gsap.to('.scrub-element', {
  x: 500,
  rotation: 360,
  scrollTrigger: {
    trigger: '.scrub-section',
    start: 'top center',
    end: 'bottom center',
    scrub: 1 // Smooth scrubbing
  }
});

Native CSS:

@supports (animation-timeline: scroll()) {
  .scrub-element {
    animation: scrub-move linear both;
    animation-timeline: view();
    animation-range: entry 0% exit 100%;
  }

  @keyframes scrub-move {
    from {
      transform: translateX(-100px) rotate(0deg);
      opacity: 0;
    }
    to {
      transform: translateX(100px) rotate(180deg);
      opacity: 1;
    }
  }
}

Snap Sections

Full-page snap scrolling.

CSS:

.snap-container {
  scroll-snap-type: y mandatory;
  overflow-y: scroll;
  height: 100vh;
}

.snap-section {
  scroll-snap-align: start;
  height: 100vh;
}

/* Proximity snap (softer) */
.snap-container-proximity {
  scroll-snap-type: y proximity;
}

With transitions:

function setupSnapTransitions(container) {
  const sections = container.querySelectorAll('.snap-section');
  let currentSection = 0;

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
        const index = Array.from(sections).indexOf(entry.target);

        if (index !== currentSection) {
          // Exit old section
          sections[currentSection].classList.remove('active');
          sections[currentSection].classList.add('exiting');

          // Enter new section
          entry.target.classList.add('active');
          entry.target.classList.remove('exiting');

          currentSection = index;
        }
      }
    });
  }, { threshold: 0.5 });

  sections.forEach(section => observer.observe(section));
}

Pinned Section

Element stays fixed during scroll.

CSS:

.pin-container {
  height: 300vh; /* Extra height for scroll duration */
  position: relative;
}

.pinned-content {
  position: sticky;
  top: 0;
  height: 100vh;
}

GSAP ScrollTrigger:

gsap.to('.pinned-content', {
  scrollTrigger: {
    trigger: '.pin-container',
    start: 'top top',
    end: 'bottom bottom',
    pin: true,
    scrub: true
  }
});

Narrative Steps

Story chapters with scroll.

JavaScript:

class ScrollNarrative {
  constructor(container) {
    this.container = container;
    this.steps = container.querySelectorAll('.story-step');
    this.content = container.querySelector('.story-content');
    this.currentStep = 0;

    this.setupObserver();
  }

  setupObserver() {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const stepIndex = parseInt(entry.target.dataset.step);
          this.activateStep(stepIndex);
        }
      });
    }, {
      threshold: 0.5,
      rootMargin: '-20% 0px -20% 0px'
    });

    this.steps.forEach(step => observer.observe(step));
  }

  activateStep(index) {
    if (index === this.currentStep) return;

    // Update step indicators
    this.steps[this.currentStep].classList.remove('active');
    this.steps[index].classList.add('active');

    // Animate content change
    this.transitionContent(index);

    this.currentStep = index;
  }

  transitionContent(index) {
    const content = this.content;

    // Fade out
    content.animate([
      { opacity: 1 },
      { opacity: 0 }
    ], {
      duration: 200,
      easing: 'ease-in'
    }).onfinish = () => {
      // Update content
      content.dataset.step = index;

      // Fade in
      content.animate([
        { opacity: 0 },
        { opacity: 1 }
      ], {
        duration: 300,
        easing: 'ease-out'
      });
    };
  }
}

Horizontal Scroll Section

Scroll horizontally within vertical page.

CSS + JavaScript:

.horizontal-section {
  height: 400vh; /* Scroll distance */
  position: relative;
}

.horizontal-track {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

.horizontal-content {
  display: flex;
  height: 100%;
  width: 400vw; /* Content width */
}
function setupHorizontalScroll(section) {
  const track = section.querySelector('.horizontal-track');
  const content = section.querySelector('.horizontal-content');

  function update() {
    const rect = section.getBoundingClientRect();
    const scrollProgress = -rect.top / (rect.height - window.innerHeight);
    const clampedProgress = Math.max(0, Math.min(1, scrollProgress));

    const translateX = clampedProgress * (content.scrollWidth - window.innerWidth);
    content.style.transform = `translateX(-${translateX}px)`;
  }

  window.addEventListener('scroll', update, { passive: true });
}

Reduced Motion

For prefers-reduced-motion: reduce:

Full MotionReduced Alternative
ParallaxStatic layers
Scroll revealInstant visibility
Scrub animationFinal state only
Pinned sectionsNormal scroll

Implementation:

@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }

  .parallax-layer {
    transform: none !important;
  }

  .snap-container {
    scroll-snap-type: none;
  }
}

Cross-References

  • See scroll-animations/ for basic scroll triggers
  • See sequencing/ for stagger timing
  • See parallax in camera/ for depth effects
  • See reveals/ for reveal animations

References

  • references/scrollytelling-patterns.md — Narrative scroll patterns

دانلود ZIP