Modern Vanilla JS Library for Scrollytelling – Scrolleo

Category: Animation , Javascript | February 11, 2026
AuthorZeitOnline
Last UpdateFebruary 11, 2026
LicenseMIT
Views42 views
Modern Vanilla JS Library for Scrollytelling – Scrolleo

Scrolleo is a lightweight vanilla JavaScript scrollytelling library that creates scroll-driven interactive experiences.

It modernizes the traditional scrollytelling libraries (like Scrollama) with improved performance through IntersectionObserver, TypeScript definitions, and a pure ESM architecture.

Features:

  • Modern ESM Build: ES modules for optimal tree-shaking and bundle size.
  • IntersectionObserver: Uses native browser APIs instead of scroll event listeners for better frame rates.
  • Offset Control: Supports both percentage-based (0-1) and pixel-based offset values.
  • Progress Tracking: Optional granular progress callbacks as users scroll through steps.
  • Custom Step Offsets: Override global offset settings per element using data attributes.
  • Multiple Instances: Run several independent scrollytelling sequences on the same page.
  • Nested Scroll Support: Works with custom scroll containers beyond the window object.
  • Automatic Resize Handling: Built-in ResizeObserver that maintains accurate trigger points during window or element resizing.

See It In Action:

Use Cases:

  • Data Journalism: Build narrative-driven articles that reveal charts and visualizations as readers scroll through the story.
  • Product Showcases: Create sticky graphics that update as scrolling product descriptions or feature lists change.
  • Educational Content: Guide learners through step-by-step tutorials, with each scroll position revealing new information or code examples.
  • Landing Pages: Design interactive hero sections where background elements respond to scroll position for depth effects.

How To Use It:

1. Install the scrolleo package with NPM and import it into your project.

# NPM
$ npm install @zeitonline/scrolleo
import scrolleo from '@zeitonline/scrolleo';

2. Structure your HTML with a container and several “step” elements.

<section id="scrolly-section">
  <article>
    <!-- Each .step div acts as a trigger point -->
    <div class="step" data-step="1">Step 1 Content</div>
    <div class="step" data-step="2">Step 2 Content</div>
    <div class="step" data-step="3">Step 3 Content</div>
  </article>
</section>

3. Initialize the library and attach your callback functions:

const scroller = scrolleo();
scroller
  .setup({
    // Target the narrative steps
    step: '#scrolly-section .step',
    // Trigger when 50% of the viewport is reached
    offset: 0.5,
    // Enable visual debugging lines
    debug: false
  })
  .onStepEnter((response) => {
    // Add an active class to the current element
    response.element.classList.add('is-active');
    console.log(`Step ${response.index} entered from ${response.direction}`);
  })
  .onStepExit((response) => {
    // Remove the class when the element leaves the threshold
    response.element.classList.remove('is-active');
  });

4. Available configuration options.

  • step (string | HTMLElement[]): CSS selector or array of DOM elements that serve as scroll triggers. This parameter is required.
  • offset (number | string): Viewport position where step callbacks fire. Use 0-1 for percentage (0 = top, 1 = bottom) or add “px” for fixed pixels like “200px”. Default: 0.5.
  • progress (boolean): Activates incremental progress callbacks as the step moves through the offset zone. Default: false.
  • threshold (number): Granularity of progress updates in pixels. Lower values create smoother progress but more callbacks. Default: 4.
  • once (boolean): Triggers step enter callback only on first entry then removes the observer. Default: false.
  • debug (boolean): Displays visual overlay lines showing offset positions and step boundaries. Default: false.
  • parent (HTMLElement[]): Parent element for step selector queries. Use this when steps exist in Shadow DOM.
  • container (HTMLElement): Scroll container element. Use when your scrollytelling content sits inside an element with overflow: scroll or overflow: auto.
  • root (HTMLElement): Element used as the viewport for IntersectionObserver visibility checks. Must be an ancestor of the target steps. Defaults to browser viewport.
const scroller = scrolleo({
  // options here
});

5. API methods.

// Get or set the offset value dynamically
scroller.offset(); // Returns current offset
scroller.offset(0.75); // Sets new offset to 75% from top
scroller.offset("150px"); // Sets fixed 150px offset
// Enable observation after disabling
scroller.enable();
// Pause all scroll observation temporarily
scroller.disable();
// Recalculate dimensions (rarely needed due to built-in ResizeObserver)
scroller.resize();
// Clean up all observers and callbacks
scroller.destroy();

6. Events:

// Callback fires when step enters the offset threshold
scroller.onStepEnter((response) => {
  // response.element: The DOM node that triggered
  // response.index: Zero-based step index
  // response.direction: 'up' or 'down' scroll direction
  console.log(response);
});
// Callback fires when step exits the offset threshold
scroller.onStepExit((response) => {
  // Same response object structure as onStepEnter
  console.log(response);
});
// Callback fires repeatedly as step moves through threshold
scroller.onStepProgress((response) => {
  // response.element: The triggering DOM node
  // response.index: Zero-based step index
  // response.progress: Value from 0 to 1 indicating completion
  // response.direction: 'up' or 'down' scroll direction
  
  // Example: Update opacity based on progress
  response.element.style.opacity = response.progress;
});

Advanced Usages:

1. Override the global offset for specific steps using data attributes:

<!-- This step triggers at 25% from viewport top -->
<div class="step" data-offset="0.25">
  <p>Custom offset step</p>
</div>
<!-- This step triggers at exactly 100 pixels from top -->
<div class="step" data-offset="100px">
  <p>Fixed pixel offset step</p>
</div>

2. Track granular scroll progress through each step:

const progressScroller = scrolleo();
progressScroller
  .setup({
    step: '.progress-step',
    offset: 0.5,
    progress: true, // Enable progress callbacks
    threshold: 2 // Update every 2 pixels for smooth tracking
  })
  .onStepProgress((response) => {
    // Update progress bar width
    const progressBar = response.element.querySelector('.progress-bar');
    progressBar.style.width = `${response.progress * 100}%`;// Fade in content based on progress
    response.element.style.opacity = Math.min(response.progress * 2, 1);
  });

3. Run independent scrollytelling sequences on the same page:

// First instance for main content
const mainScroller = scrolleo();
mainScroller.setup({
  step: '#main-story .step',
  offset: 0.5
}).onStepEnter(handleMainStory);
// Second instance for sidebar content
const sidebarScroller = scrolleo();
sidebarScroller.setup({
  step: '#sidebar .step',
  offset: 0.3
}).onStepEnter(handleSidebar);

4. Use Scrolleo inside a scrollable div instead of the window:

const containerElement = document.querySelector('#scroll-container');
const nestedScroller = scrolleo();
nestedScroller.setup({
  step: '#scroll-container .step',
  container: containerElement, // Custom scroll container
  root: containerElement, // Also set as IntersectionObserver root
  offset: 0.5
});

Alternatives:

FAQs:

Q: Why do my callbacks fire multiple times on fast scrolling?
A: This happens when you scroll past several steps quickly. Each step fires its own enter/exit callbacks independently. If you need to debounce updates to external state, wrap your callback logic in a debounce function or track the last processed index manually.

Q: Can I animate the sticky graphic smoothly between steps?
A: Use the onStepProgress callback with linear interpolation. Store the state for each step index, then calculate intermediate values based on response.progress. This creates smooth transitions as users scroll between steps.

Q: How do I handle dynamic content that changes step heights?
A: Scrolleo’s built-in ResizeObserver handles this automatically. When step elements change height, the library recalculates trigger positions. You do not need to call resize() manually unless you disable the ResizeObserver for performance reasons.

Q: What’s the performance impact of enabling progress tracking?
A: Progress tracking creates additional IntersectionObserver instances and fires callbacks more frequently. For most use cases the impact is negligible. If you have dozens of steps, consider increasing the threshold value to reduce callback frequency or disable progress on steps that don’t need it.

You Might Be Interested In:


Leave a Reply