npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@magic-spells/scroll-trigger

v0.1.2

Published

Lightweight scroll-trigger plugin for tracking section visibility and syncing navigation state

Readme

ScrollTrigger

Lightweight scroll-spy plugin for tracking section visibility and syncing navigation state. Perfect for collection pages, documentation, and long-form content. Only 1.5kb gzipped.

Live Demo

Features

  • 🪶 Tiny bundle - Only 1.5kb gzipped
  • 🎯 IntersectionObserver-based - Modern, performant section tracking
  • 🔄 Callback system - Easy integration with custom navigation
  • Throttled updates - Optimized performance with configurable throttling
  • 📍 Precise control - Customizable trigger offset from viewport bottom
  • 🎨 Zero dependencies - Pure vanilla JavaScript
  • 🔧 Flexible API - Supports CSS selectors, NodeList, or element arrays
  • 📦 Multiple formats - ESM, CommonJS, and UMD builds

Installation

npm install @magic-spells/scroll-trigger

Or use via CDN:

<script type="module">
  import ScrollTrigger from 'https://unpkg.com/@magic-spells/scroll-trigger';
</script>

Basic Usage

import ScrollTrigger from '@magic-spells/scroll-trigger';

const trigger = new ScrollTrigger({
  sections: '.collection-section',
  offset: 100,
  onIndexChange: ({ currentIndex, currentElement }) => {
    // Update your navigation
    console.log('Active section:', currentIndex);
    console.log('Trigger element:', currentElement);
  }
});

Configuration Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | sections | string\|NodeList\|Array | required | Sections to track (CSS selector, NodeList, or Array) | | offset | number\|string | 100 | Distance from bottom of viewport to trigger active state (px or percentage like '50%') | | threshold | number | 0.1 | IntersectionObserver threshold (0-1) | | throttle | number | 100 | Throttle delay for updates (ms) | | behavior | string | 'smooth' | Scroll behavior ('smooth' or 'auto') | | onIndexChange | function | null | Callback when active section changes (receives object: { currentIndex, previousIndex, currentElement, previousElement }) |

Per-Element Custom Offsets

Each tracked element can override the global offset configuration using the data-animate-offset attribute:

<!-- Global offset is 10%, but these have custom offsets -->
<div data-animate-fade-up>Uses global offset (10%)</div>
<div data-animate-fade-up data-animate-offset="20%">Triggers at 20% from bottom</div>
<div data-animate-fade-up data-animate-offset="50">Triggers at 50px from bottom</div>
<div data-animate-fade-up data-animate-offset="15%">Triggers at 15% from bottom</div>
const scrollAnimation = new ScrollTrigger({
  sections: '[data-animate-fade-up]',
  offset: '10%', // Default offset for all elements
  onIndexChange: ({ currentElement }) => {
    if (currentElement && !currentElement.hasAttribute('data-animate-loaded')) {
      currentElement.setAttribute('data-animate-loaded', '');
    }
  }
});

How it works:

  • Each element is checked against its own trigger line based on its custom offset
  • Elements without data-animate-offset use the global offset from config
  • Supports both pixel values (100) and percentages ('20%')
  • Perfect for staggered animations or different timing for different elements

API Methods

getCurrentIndex()

Returns the current active section index (-1 if none).

const currentIndex = trigger.getCurrentIndex();

getCurrentElement()

Returns the current active element (null if none).

const element = trigger.getCurrentElement();

getElements()

Returns array of all tracked elements.

const elements = trigger.getElements();

scrollToIndex(index, options)

Scroll to a specific section by index.

trigger.scrollToIndex(2, {
  behavior: 'smooth',
  offset: 20 // Additional offset in pixels (positive = section appears higher)
});

scrollToElement(element, options)

Scroll to a specific element.

const element = document.querySelector('.my-section');
trigger.scrollToElement(element);

refresh()

Recalculate section positions (call after DOM changes).

trigger.refresh();

updateConfig(newConfig)

Update configuration dynamically.

trigger.updateConfig({
  offset: 150,
  throttle: 200
});

destroy()

Destroy the tracker and cleanup.

trigger.destroy();

Events

The tracker emits a custom event on the window:

window.addEventListener('scroll-trigger:change', (e) => {
  console.log('New index:', e.detail.index);
  console.log('Previous index:', e.detail.previousIndex);
  console.log('Current element:', e.detail.section);
  console.log('Previous element:', e.detail.previousSection);
});

Examples

Navigation Sync Example

<!DOCTYPE html>
<html>
<head>
  <style>
    .nav-item.active {
      background: blue;
      color: white;
    }
  </style>
</head>
<body>
  <!-- Navigation -->
  <nav id="nav">
    <div class="nav-item" data-index="0">Section 1</div>
    <div class="nav-item" data-index="1">Section 2</div>
    <div class="nav-item" data-index="2">Section 3</div>
  </nav>

  <!-- Sections -->
  <section class="section">Content 1</section>
  <section class="section">Content 2</section>
  <section class="section">Content 3</section>

  <script type="module">
    import ScrollTrigger from './scroll-trigger.esm.js';

    const navItems = document.querySelectorAll('.nav-item');

    const trigger = new ScrollTrigger({
      sections: '.section',
      offset: 100,
      onIndexChange: ({ currentIndex }) => {
        // Update nav
        navItems.forEach((item, i) => {
          item.classList.toggle('active', i === currentIndex);
        });

        // Scroll nav item into view
        if (currentIndex >= 0) {
          navItems[currentIndex].scrollIntoView({
            behavior: 'smooth',
            block: 'nearest',
            inline: 'center'
          });
        }
      }
    });

    // Handle nav clicks
    navItems.forEach((item, index) => {
      item.addEventListener('click', () => {
        trigger.scrollToIndex(index);
      });
    });
  </script>
</body>
</html>

Scroll Animations Example

You can use multiple ScrollTrigger instances to create different effects. Here's how to add scroll-triggered fade-up animations:

<!DOCTYPE html>
<html>
<head>
  <style>
    /* Animation states */
    [data-animate-fade-up] {
      opacity: 0;
      transform: translateY(60px);
      filter: blur(3px);
      transition:
        opacity 0.5s ease-out,
        transform 0.5s ease-out,
        filter 0.5s ease-out;
    }

    [data-animate-fade-up][data-animate-loaded] {
      opacity: 1;
      transform: translateY(0);
      filter: blur(0);
    }

    .product-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
      gap: 1rem;
    }
  </style>
</head>
<body>
  <!-- Content with animation triggers -->
  <section>
    <h2>Featured Products</h2>
    <div class="product-grid" data-animate-fade-up data-animate-offset="15%">
      <div class="product">Product 1</div>
      <div class="product">Product 2</div>
      <div class="product">Product 3</div>
    </div>
  </section>

  <section>
    <h2>More Products</h2>
    <div class="product-grid" data-animate-fade-up data-animate-offset="20%">
      <div class="product">Product 4</div>
      <div class="product">Product 5</div>
      <div class="product">Product 6</div>
    </div>
  </section>

  <script type="module">
    import ScrollTrigger from './scroll-trigger.esm.js';

    // Scroll animations - triggers once per element
    const scrollAnimation = new ScrollTrigger({
      sections: '[data-animate-fade-up]',
      offset: '10%', // Trigger when 10% from bottom of viewport
      threshold: 0.1,
      onIndexChange: ({ currentElement }) => {
        // Only animate once - check if already loaded
        if (currentElement && !currentElement.hasAttribute('data-animate-loaded')) {
          currentElement.setAttribute('data-animate-loaded', '');
        }
      }
    });
  </script>
</body>
</html>

Key Points:

  • Elements start hidden with opacity: 0, translateY(60px), and blur(3px)
  • When they enter the trigger zone, data-animate-loaded is added
  • CSS transitions animate them to visible state
  • The hasAttribute check ensures animations only trigger once
  • Each element can have a custom data-animate-offset to trigger at different positions
  • You can combine multiple ScrollTrigger instances for different purposes

Accessibility

Note: ScrollTrigger does not automatically manage ARIA attributes. You must implement accessibility features yourself in your onIndexChange callback.

Recommended Implementation

For accessible navigation that works with screen readers and keyboard navigation:

<!-- Use semantic nav with aria-label -->
<nav aria-label="Product categories">
  <a href="#cereal" class="nav-item">Cereal</a>
  <a href="#granola" class="nav-item">Granola</a>
  <a href="#snacks" class="nav-item">Snacks</a>
</nav>

<!-- Add IDs and aria-labelledby to sections -->
<section id="cereal" aria-labelledby="cereal-heading">
  <h2 id="cereal-heading" data-section-trigger>Cereal</h2>
  <!-- content -->
</section>

<section id="granola" aria-labelledby="granola-heading">
  <h2 id="granola-heading" data-section-trigger>Granola</h2>
  <!-- content -->
</section>
const navItems = document.querySelectorAll('.nav-item');

const trigger = new ScrollTrigger({
  sections: '[data-section-trigger]',
  offset: '50%',
  onIndexChange: ({ currentIndex }) => {
    navItems.forEach((item, i) => {
      if (i === currentIndex) {
        item.classList.add('active');
        // Use aria-current to indicate current location
        item.setAttribute('aria-current', 'location');
      } else {
        item.classList.remove('active');
        item.removeAttribute('aria-current');
      }
    });
  }
});

// Prevent default, use smooth scroll, and update URL
navItems.forEach((item, index) => {
  item.addEventListener('click', (e) => {
    e.preventDefault();
    trigger.scrollToIndex(index);

    // Update URL for bookmarking/sharing
    history.pushState(null, '', item.getAttribute('href'));
  });
});

Best Practices

  1. Use aria-current="location" instead of aria-selected for navigation
  2. Use <a> tags with href for keyboard navigation and right-click support
  3. Add aria-label to the <nav> element to describe its purpose
  4. Use aria-labelledby to connect sections with their headings
  5. Add IDs to sections to enable direct linking and browser history
  6. Update the URL on navigation for bookmarking and sharing

See the /demo/index.html file for a complete accessible implementation.

Browser Support

  • Modern browsers with IntersectionObserver support
  • Chrome 51+
  • Firefox 55+
  • Safari 12.1+
  • Edge 15+

License

MIT © Cory Schulz