@magic-spells/scroll-trigger
Version:
Lightweight scroll-trigger plugin for tracking section visibility and syncing navigation state
395 lines (319 loc) âĸ 11.2 kB
Markdown
# 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**](https://magic-spells.github.io/scroll-trigger/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
```bash
npm install @magic-spells/scroll-trigger
```
Or use via CDN:
```html
<script type="module">
import ScrollTrigger from 'https://unpkg.com/@magic-spells/scroll-trigger';
</script>
```
## Basic Usage
```javascript
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:
```html
<!-- 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>
```
```javascript
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).
```javascript
const currentIndex = trigger.getCurrentIndex();
```
### `getCurrentElement()`
Returns the current active element (null if none).
```javascript
const element = trigger.getCurrentElement();
```
### `getElements()`
Returns array of all tracked elements.
```javascript
const elements = trigger.getElements();
```
### `scrollToIndex(index, options)`
Scroll to a specific section by index.
```javascript
trigger.scrollToIndex(2, {
behavior: 'smooth',
offset: 20 // Additional offset in pixels (positive = section appears higher)
});
```
### `scrollToElement(element, options)`
Scroll to a specific element.
```javascript
const element = document.querySelector('.my-section');
trigger.scrollToElement(element);
```
### `refresh()`
Recalculate section positions (call after DOM changes).
```javascript
trigger.refresh();
```
### `updateConfig(newConfig)`
Update configuration dynamically.
```javascript
trigger.updateConfig({
offset: 150,
throttle: 200
});
```
### `destroy()`
Destroy the tracker and cleanup.
```javascript
trigger.destroy();
```
## Events
The tracker emits a custom event on the window:
```javascript
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
```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:
```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:
```html
<!-- 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>
```
```javascript
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