offset 85%
ScrollTrigger
@magic-spells/scroll-trigger

Know where
you are.

One line across the viewport. The tracker tells you which section has crossed it — and nothing else.

npm install @magic-spells/scroll-trigger

1.5 kB JS gzip · 0.1 kB CSS gzip · 0 dependencies

scroll
01 — Overview

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)
02 — Trigger line

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.

// 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%' })
03 — Reveal

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.

8% Fires almost at the bottom edge, as soon as the tile is on screen.
18% A little higher. The stagger comes from geometry, not a timer.
28% Higher still — this one waits until the row is well into view.
120px Pixels work too. Mix units freely across one instance.
<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', '')
    })
  },
})
04 — API

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
05 — Events

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`
})

GitHub · npm · MIT