Know where
you are.
npm install @magic-spells/scroll-trigger
1.5 kB JS gzip · 0.1 kB CSS gzip · 0 dependencies
A line, and what has crossed it
The chapter pills above are driven by the tracker on this page. An IntersectionObserver watches every section against a single horizontal line; the last section whose top has passed it is the active one. No scroll maths in your code, no listener to throttle.
Scroll slowly and watch the dashed line — the pill flips the instant a heading crosses it. The panel in the corner moves that line live.
// npm
import ScrollTrigger from '@magic-spells/scroll-trigger'
const trigger = new ScrollTrigger({
sections: '.screen',
offset: '85%', // measured up from the bottom of the viewport
throttle: 40,
behavior: 'smooth',
onIndexChange({ currentIndex, currentElement }) {
pills.forEach((p, i) => p.toggleAttribute('data-active', i === currentIndex))
},
})
// jump to one
trigger.scrollToIndex(2)
- IntersectionObserver, not a scroll listener
- Offset in pixels or in
%of the viewport - Per-element override via
data-animate-offset - Percentages re-measure on resize, through ResizeObserver
- Callback and
scroll-trigger:changeevent, both scrollToIndex()lands on the same line it measures- Throttled updates, tunable per instance
- Plain ES class — no custom element, no framework
refresh()after DOM changes,destroy()on teardown- Zero dependencies, 1.5 kB gzipped
The offset is measured from the bottom
offset is the distance from the bottom of the viewport up to the line. A
number is pixels; a string ending in % is a share of the viewport height, and
re-measures whenever the viewport changes size.
Small values put the line near the bottom, which suits reveal animations — an element fires as it enters the screen. Large values put it near the top, which suits navigation — a section owns the nav once its heading reaches reading position.
- offset: 0 Bottom edge. Fires the moment an element appears.
- offset: '25%' A quarter up. The usual choice for fade-ups.
- offset: '50%' Dead centre.
- offset: '85%' Near the top — reading position, for navigation.
// pixels from the bottom
new ScrollTrigger({ sections: '.section', offset: 100 })
// share of the viewport, responsive
new ScrollTrigger({ sections: '.section', offset: '85%' })
// change it at runtime — observers are rebuilt for you
trigger.updateConfig({ offset: '50%' })
Every element can move its own line
data-animate-offset overrides the instance offset for one element. Same
tracker, same observer — four different trigger heights. Each tile below names the offset
it fires at, so a higher percentage waits longer before it arrives.
<div data-animate-fade-up> // uses the instance offset
<div data-animate-fade-up data-animate-offset="18%"> // 18% up from the bottom
<div data-animate-fade-up data-animate-offset="120"> // 120px up from the bottom
const tiles = [...document.querySelectorAll('[data-animate-fade-up]')]
new ScrollTrigger({
sections: tiles,
offset: '12%',
onIndexChange({ currentIndex }) {
// everything up to the active index has crossed its own line
tiles.forEach((el, i) => {
if (i <= currentIndex) el.setAttribute('data-animate-loaded', '')
})
},
})
Six options, seven methods
Options
| Option | Default | Description |
|---|---|---|
sections |
null |
Selector string, NodeList or array of elements to track. Required |
offset |
100 |
Distance from the bottom of the viewport to the trigger line. Number for pixels, string for a percentage of viewport height |
threshold |
0.1 |
IntersectionObserver threshold. Use 0 for maximum sensitivity |
throttle |
100 |
Milliseconds between index recalculations |
behavior |
'smooth' |
Scroll behaviour for scrollToIndex(). Overridable per call |
onIndexChange |
null |
Called with
{ currentIndex, previousIndex, currentElement, previousElement }
whenever the active element changes
|
Methods
| Method | Returns | Description |
|---|---|---|
getCurrentIndex() |
number |
Active index, or -1 when nothing has crossed the line |
getCurrentElement() |
Element|null |
The active element |
getElements() |
Element[] |
A copy of the tracked list |
scrollToIndex(i, opts) |
void |
Scrolls until that element's top sits on its own trigger line. Accepts
{ behavior, offset }
|
scrollToElement(el, opts) |
void |
Same, addressed by element |
refresh() |
void |
Re-reads the DOM. Call after adding or removing tracked elements |
updateConfig(cfg) |
void |
Merges new options, rebuilding observers when the geometry changes |
destroy() |
void |
Disconnects every observer, listener and timer |
Or skip the callback entirely
Every change also fires scroll-trigger:change on window, so
unrelated parts of a page can react without a reference to the instance. The readout in
the corner is wired this way.
| Detail | Type | Description |
|---|---|---|
index |
number |
New active index, -1 for none |
previousIndex |
number |
Index before the change |
section |
Element|null |
New active element |
previousSection |
Element|null |
Element before the change |
window.addEventListener('scroll-trigger:change', (e) => {
const { index, previousIndex, section } = e.detail
if (section) document.title = `${section.id} — Docs`
})