Repository: janpaepke/ScrollMagic
Branch: main
Commit: 7ce4e3ea6078
Files: 76
Total size: 257.9 KB
Directory structure:
gitextract_3ruugf_l/
├── .gitignore
├── .prettierignore
├── CHANGELOG.md
├── LICENSE.md
├── MAINTAINING.md
├── PLUGINS.md
├── README.md
├── ROADMAP.md
├── config/
│ └── banner.txt
├── docs/
│ └── diagrams/
│ ├── contain.html
│ ├── intersect.html
│ └── shared.css
├── eslint.config.mjs
├── package.json
├── prettier.config.mjs
├── rollup.config.mjs
├── scripts/
│ └── export-diagrams.mjs
├── src/
│ ├── Container.ts
│ ├── ContainerProxy.ts
│ ├── EventDispatcher.ts
│ ├── ExecutionQueue.ts
│ ├── Options.processors.ts
│ ├── Options.ts
│ ├── ScrollMagic.ts
│ ├── ScrollMagicError.ts
│ ├── ScrollMagicEvent.ts
│ ├── ViewportObserver.ts
│ ├── env.d.ts
│ ├── index.ts
│ ├── util/
│ │ ├── agnosticValues.ts
│ │ ├── getScrollContainerDimensions.ts
│ │ ├── getScrollPos.ts
│ │ ├── pickDifferencesFlat.ts
│ │ ├── processProperties.ts
│ │ ├── rafQueue.ts
│ │ ├── registerEvent.ts
│ │ ├── sanitizeProperties.ts
│ │ ├── sharedResizeObserver.ts
│ │ ├── throttleRaf.ts
│ │ ├── transformObject.ts
│ │ ├── transformers.ts
│ │ └── typeguards.ts
│ └── util.ts
├── tests/
│ ├── e2e/
│ │ ├── UNTESTED-KNOWN-BUGS.md
│ │ ├── caching.test.ts
│ │ ├── containers.test.ts
│ │ ├── destroy.test.ts
│ │ ├── dev-warnings.test.ts
│ │ ├── element-tracking.test.ts
│ │ ├── enable-disable.test.ts
│ │ ├── helpers.ts
│ │ ├── refresh.test.ts
│ │ ├── scroll-progress.test.ts
│ │ └── scroll-velocity.test.ts
│ └── unit/
│ ├── ContainerProxy.test.ts
│ ├── EventDispatcher.test.ts
│ ├── ExecutionQueue.test.ts
│ ├── Options.processors.test.ts
│ ├── ScrollMagicError.test.ts
│ ├── ScrollMagicEvent.test.ts
│ ├── agnosticValues.test.ts
│ ├── getScrollContainerDimensions.test.ts
│ ├── getScrollPos.test.ts
│ ├── pickDifferencesFlat.test.ts
│ ├── processProperties.test.ts
│ ├── rafQueue.test.ts
│ ├── registerEvent.test.ts
│ ├── sanitizeProperties.test.ts
│ ├── sharedResizeObserver.test.ts
│ ├── throttleRaf.test.ts
│ ├── transformObject.test.ts
│ ├── transformers.test.ts
│ └── typeguards.test.ts
├── tsconfig.json
├── typedoc.json
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
.DS_Store
node_modules
/dist
/docs/tsdoc
__screenshots__
================================================
FILE: .prettierignore
================================================
dist
.claude
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## Unreleased
#### New Features
- **`{ signal: AbortSignal }` event listener option** — follows the DOM `addEventListener` pattern. Pass an `AbortController`'s signal to `on()` or `subscribe()` to remove one or many listeners with a single `abort()` call.
### 3.0.0-beta.4
#### Internal
- Codebase terminology cleanup (remove legacy "scene" naming, rename `scrollOffset` → `activeRange`).
- ContainerProxy: separate `size`/`position` getters replace combined `rect`.
- Unit test coverage expanded from 84 to 164 tests.
### 3.0.0-beta.3
#### Breaking Changes
- **Options renamed** — `scrollParent` → `container`, `triggerStart` → `containerStart`, `triggerEnd` → `containerEnd`. This groups container-related options under a shared prefix and avoids confusion with GSAP's `trigger` (which refers to the element, not the container). The `resolvedBounds` getter now returns `{ element, container }` instead of `{ element, scrollParent }`.
#### New Features
- **Multi-match selector warning** (dev mode) — when a CSS selector passed to `element` or `container` matches more than one DOM element, a warning advises creating one instance per element instead.
#### Build
- **Original sources embedded in source maps** — published `.map` files now contain the actual TypeScript source via `inlineSources`, making them useful for debugging without the `src/` directory.
### 3.0.0-beta.2
#### Breaking Changes
- **`destroy()` calls `onDestroy` instead of `onRemove` on plugins** — `onRemove` now only fires via `removePlugin()`. Plugins that used `onRemove` for destroy cleanup should add `onDestroy` (or assign the same function to both).
- **`computedOptions` removed** — replaced by `resolvedBounds`, which returns `{ element: ElementBounds, container: ContainerBounds }` (cached layout bounds only, no longer leaks the full internal options structure).
- **Getter return types narrowed** — `element`, `container`, and `vertical` getters now return resolved types (`Element`, `Window | Element`, `boolean`) instead of the raw public input union. Setters still accept the full public types.
#### New Features
- **Plugin lifecycle hooks: `onEnable`, `onDisable`, `onDestroy`** — plugins can react to enable/disable transitions and distinguish manual removal from instance teardown. `destroy()` on an enabled instance fires `onDisable` → `onDestroy` in sequence.
- **`scrollmagic/util` subpath export** — exposes `agnosticValues` and `agnosticProps` via `import { ... } from 'scrollmagic/util'` for plugin authors working with direction-agnostic bounds.
- **`ElementBounds`, `ContainerBounds`, `ResolvedBounds` types exported** — available from the main entry point for plugin and integration authors.
## 3.0.0-beta.1
### New Features
- **`scrollVelocity` getter** — per-container scroll velocity in px/s, shared across all instances on the same container via ContainerProxy. Returns 0 when disabled, destroyed, or idle (100ms staleness decay).
- **`enable()` / `disable()`** — temporarily disconnect all observers without destroying the instance. Progress freezes at its current value; `modify()`, `on()`/`off()`, plugins, and most getters remain functional. Re-enabling reconnects everything and schedules a full recalculation.
- **`{ once: true }` event listener option** — follows the DOM `addEventListener` options bag pattern. Works with both `.on()` and `.subscribe()`.
- **`refresh()` / `refreshAll()` / `destroyAll()`** — force bounds recalculation after layout changes invisible to ResizeObserver (position shifts, class toggles, sibling DOM mutations, font loading, etc.).
- **Post-destroy and non-browser guards** — all public methods now warn in dev mode and bail cleanly instead of producing undefined behavior when called after `destroy()` or outside a browser environment.
- **Element–container ancestry validation** (dev mode) — `console.error` when the tracked element isn't a descendant of its container, catching silent IntersectionObserver misconfiguration.
### Bug Fixes
- **Container position not initialized synchronously** — non-window containers defaulted to `{top:0,left:0}` until the first scroll/resize event, producing wrong initial progress for containers offset from the viewport top.
- **Zero-size container guard** — when a scroll container collapses to 0px, `updateProgress()` no longer produces incorrect values (division by near-zero) and `updateViewportObserver()` no longer passes broken margins to the IntersectionObserver.
- **Direction change not invalidating elementBoundsCache** — changing `vertical` via `modify()` left stale axis-dependent bounds in the cache.
- **containerBounds not rescheduled on option changes** — `containerStart`, `containerEnd`, and `vertical` changes via `modify()` didn't trigger a container bounds recalculation, causing wrong progress and viewport margins.
- **Stale closure in `onElementResize`** — `updateElementBoundsCache()` replaced the entire bounds object, but the resize handler's destructured reference pointed to the old one, so size comparisons always returned false and progress never recalculated after element resize.
- **`destroy()` skipping plugin `onRemove` callbacks** — plugin cleanup was routed through `removePlugin()`, which hit the `guardInert()` check (destroyed was already true) and silently skipped all `onRemove` callbacks.
### Performance
- **Replace debounce with `throttleRaf` for container resize** — removes the arbitrary 100ms debounce delay. Both window and element resize paths now use rAF-batched throttling for consistent, responsive behavior.
- **Cache PixelConverter results** — `elementStart`/`elementEnd` converters are skipped when element size is unchanged (common during scroll). Bounds caches mutated in-place via `Object.assign` instead of allocating new objects each frame.
### Internal
- Explicit `type` keyword on type-only imports for better tree-shaking.
- New `Vector` type for `{x, y}` pairs, replacing the old `ScrollDelta` shape.
- E2e tests reorganized from origin-based to feature-based structure. 13 regression tests covering v2-reported edge cases added.
- Added MAINTAINING.md and ROADMAP.md.
================================================
FILE: LICENSE.md
================================================
MIT License
Copyright (c) 2014-present Jan Paepke
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: MAINTAINING.md
================================================
# Maintaining ScrollMagic
Reference for triaging issues and PRs against **v3**.
---
## Philosophy
- **Actually read the issue** — every response should show it. No copy-paste walls.
- **Close fast over leaving open** — an open issue implies intent to act. If you won't act on it, close it clearly.
- **Specific > comprehensive** — 2–4 sentences that address the actual scenario beats a thorough generic answer.
---
## Issue Triage
### Categories
**Bug Reports** — broken behavior, browser-specific failures, wrong values, etc.
- Investigate. If confirmed, fix or document in `tests/e2e/UNTESTED-KNOWN-BUGS.md`.
- Label: `bug`
**Support / How-To** — "How do I...", "Is it possible to...", "Not working with X"
- Check comments first — if already answered, reference the solution: _"Looks like [username] provided a working solution above."_
- If you can answer confidently, do so briefly.
- Label: `support`
**Feature Requests** — new capabilities, API additions
- Evaluate fit with v3's scope and architecture.
- If in scope and a PR would be welcome, say so and label `help wanted`.
- Label: `enhancement`
**Build / Module Issues** — webpack, npm, TypeScript types, bundler problems
- v3 is a native ES module with TypeScript built-in — many v2-era build problems don't apply.
- Label: `support` or `bug` depending on whether something is broken vs. misunderstood.
**Meta / Admin** — license questions, dead links, repo housekeeping
- Handle case-by-case.
- Label: `invalid` or close without comment if clearly stale.
---
### Labels
| Label | Meaning |
| -------------------- | --------------------------------------------------- |
| `bug` | Confirmed broken behavior |
| `enhancement` | New capability request |
| `support` | Usage question, how-to |
| `needs-info` | Waiting on reporter to provide more context |
| `needs-reproduction` | Cannot reproduce; a minimal repro is required |
| `help wanted` | Community contribution welcome |
| `good first issue` | Low barrier, good entry point for new contributors |
| `duplicate` | Already tracked elsewhere — close and link |
| `wontfix` | Deliberate decision not to address |
| `invalid` | Off-topic, spam, malformed |
| `mobile` | Mobile-specific concern (IO, viewport, touch, etc.) |
---
### Response Guidelines
**Format:** No emojis in the body. 2–4 sentences specific to the issue, then close.
**Close reason:**
- Won't fix / out of scope: `not planned`
- Duplicate: `duplicate`
- Resolved or confirmed working: `completed`
- Spam / off-topic: `not planned`
### Response Templates
**Cannot reproduce:**
> Unable to reproduce this with the information provided. If you can share a minimal reproduction (CodePen, StackBlitz, or a small repo), that would help narrow it down significantly.
**Already answered in comments:**
> Looks like [username] provided a working solution above — hopefully that helps. Feel free to reopen with a reproduction if you're still running into this.
**Feature request, not accepting:**
> Thanks for the suggestion. This falls outside v3's current scope — [brief reason]. Feel free to open a discussion if you want to explore it further.
---
## Pull Requests
### Merge Criteria
- CI passes (types, lint, tests)
- New behavior has test coverage
- No unrelated changes bundled in
- Commit messages follow conventional commits
### Review Checklist
- Does the change match the stated intent?
- Is there a simpler approach?
- Are edge cases handled?
- Does it follow existing code style?
### Abandoned PRs
Comment asking for an update after 30 days of inactivity. Close after 60 days total — invite the author to reopen or for someone else to pick it up.
---
## Stale Issue Policy
1. Apply `needs-info` or `needs-reproduction` when waiting on the reporter.
2. If no response after 30 days, leave one comment asking for an update.
3. Close after 60 days total: _"Closing for inactivity — feel free to reopen or link a reproduction if you revisit this."_
---
## Release Process
- Semver: patch for bug fixes, minor for new features, major for breaking changes
- Changelog maintained in `CHANGELOG.md`
- Tagged GitHub releases with release notes
- Published to npm as `scrollmagic`
---
## Handling v2 Issues
v2 is in maintenance-only mode — 2.0.9 is the final release. Issues filed against v2 should be acknowledged and redirected to v3.
### Approach by Category
**Bug:** Acknowledge the specific bug. Mention if v3's architecture approaches it differently (but don't promise a fix). Close as `not planned`.
**Support / How-To:** Answer briefly if confident — it helps people who find the issue via search. Then redirect to v3.
**Feature Request:** Note if v3 already covers it (see [v3 feature reference](#v3-feature-reference) below), or invite them to try v3 and reopen if still needed. Close as `not planned`.
**Build / Module:** Note that v3 is a native ES module with TypeScript built-in — most v2 bundler issues don't apply. Close as `not planned`.
When redirecting to v3, link to the [README on main](https://github.com/janpaepke/ScrollMagic/blob/main/README.md).
### v3 Feature Reference
Quick reference for assessing whether a v2 request or bug is addressed in v3:
- **No Controller** — each `new ScrollMagic({ element })` is self-contained
- **No pinning** — no pin system; CSS `position: sticky` covers most use cases
- **No built-in animation** — pair with GSAP, Motion, anime.js, etc.
- **Horizontal scroll** — `vertical: false` option
- **Any scroll container** — `scrollParent` accepts `window` or any element
- **Plugin system** — `addPlugin()` with `onAdd`, `onRemove`, `onModify` lifecycle hooks
- **Named position shorthands** — `'here'` (0%), `'center'` (50%), `'opposite'` (100%)
- **Inset functions** — `(size) => number` for dynamic computation
- **Native TypeScript** with full type exports
- **ES module** with UMD fallback, zero dependencies
- **SSR safe**
- **MIT license only** (v2 was dual MIT/GPL-3.0+)
- **Events:** `enter`, `leave`, `progress` — each with `direction`, `location`, `event.target`
- **Getters/setters** for all options; `modify()` for batch updates
- **`enable()` / `disable()`** — pause/resume tracking without destroying; `modify()` works while disabled
================================================
FILE: PLUGINS.md
================================================
# ScrollMagic Plugins
Plugins extend ScrollMagic instances with custom behaviour — class toggles, debug overlays, animation bindings, or anything else. Each plugin is a plain object with a `name` and optional lifecycle hooks.
## Basic Example
```ts
import ScrollMagic, { type ScrollMagicPlugin } from 'scrollmagic';
const myPlugin: ScrollMagicPlugin = {
name: 'my-plugin',
onAdd() {
// `this` is the ScrollMagic instance
this.on('enter', () => {
/* ... */
});
},
onRemove() {
this.off('enter' /* ... */);
},
};
const sm = new ScrollMagic({ element: '#target' });
sm.addPlugin(myPlugin);
sm.removePlugin(myPlugin); // or let destroy() handle cleanup
```
## Lifecycle Hooks
All hooks are optional. In every hook, `this` is bound to the ScrollMagic instance the plugin is attached to.
| Hook | When it fires |
| --- | --- |
| `onAdd()` | Immediately when `addPlugin()` is called. |
| `onRemove()` | When `removePlugin()` is called. The instance is still alive. |
| `onEnable()` | When `enable()` resumes tracking. |
| `onDisable()` | When `disable()` pauses tracking. Also fires during `destroy()` (before `onDestroy`) if the instance was enabled. |
| `onDestroy()` | When `destroy()` tears down the instance. The instance is already disabled at this point. |
| `onModify(changes)` | When options change via `modify()` or a setter. `changes` contains only the options that actually changed. |
### Hook Sequence
**`removePlugin(plugin)`** fires `onRemove` only.
**`destroy()`** on an enabled instance fires in order:
1. `onDisable` — tracking paused (observers disconnected)
2. `onDestroy` — instance dying (final cleanup)
If the instance was already disabled before `destroy()`, only `onDestroy` fires.
Note: `onRemove` does **not** fire during `destroy()`. Use `onDestroy` for teardown cleanup. If a plugin needs the same cleanup logic for both removal and destruction, assign the same function to both hooks:
```ts
const cleanup = function (this: ScrollMagic) {
/* ... */
};
const plugin: ScrollMagicPlugin = {
name: 'shared-cleanup',
onRemove: cleanup,
onDestroy: cleanup,
};
```
## Using Utilities
ScrollMagic exposes direction-agnostic helpers via a separate entry point. These are useful for plugins that need to work with both vertical and horizontal scrolling:
```ts
import { agnosticValues, agnosticProps } from 'scrollmagic/util';
```
- **`agnosticProps(vertical)`** — returns a map of direction-neutral prop names (`start`, `end`, `size`, etc.) to their CSS/DOM equivalents (`top`/`left`, `bottom`/`right`, `height`/`width`, etc.).
- **`agnosticValues(vertical, obj)`** — extracts the relevant values from a rect or similar object based on scroll direction. For example, `agnosticValues(true, element.getBoundingClientRect())` returns `{ start: rect.top, end: rect.bottom, size: rect.height, ... }`.
## Plugin Instance Methods
These methods are available on every ScrollMagic instance — plugins use them via `this` in hooks:
```ts
// Event listeners
this.on(type, callback);
this.off(type, callback);
this.subscribe(type, callback); // returns unsubscribe function
// Read state
this.progress; // 0–1
this.disabled; // true when disabled or destroyed
this.scrollVelocity; // px/s along tracked axis
this.activeRange; // { start, end } container scroll positions where tracking is active
this.resolvedBounds; // { element, container } cached layout bounds
// Read/write options
this.element;
this.elementStart;
this.elementEnd;
this.container;
this.containerStart;
this.containerEnd;
this.vertical;
// Actions
this.modify(options); // update multiple options at once
this.refresh(); // force bounds recalculation
this.enable();
this.disable();
this.destroy();
// Plugin management
this.addPlugin(plugin);
this.removePlugin(plugin);
this.pluginList; // snapshot of registered plugins
```
================================================
FILE: README.md
================================================
# ScrollMagic 3
[](https://www.npmjs.com/package/scrollmagic/v/next)
[](LICENSE.md)
[](https://bundlephobia.com/package/scrollmagic)
[](https://npmgraph.js.org/?q=scrollmagic)
[](https://www.typescriptlang.org/)
### The lightweight library for magical scroll interactions
> **Looking for ScrollMagic v2?** The legacy version is on the [`v2-stable`](https://github.com/janpaepke/ScrollMagic/tree/v2-stable) branch.
ScrollMagic tells you where an element is relative to the viewport as the user scrolls — and fires events when that changes.
It's a convenience wrapper around [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) and [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) that handles the performance pitfalls and counter-intuitive edge cases for you.
[](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=8BJC8B58XHKLL 'Shut up and take my money!')
### Not an animation library – unless you want it to be
By itself, ScrollMagic doesn't animate anything. It provides precise scroll-position data and events — what you do with them is up to you. If you're looking for a ready-made scroll animation solution, check out [GSAP ScrollTrigger](https://gsap.com/docs/v3/Plugins/ScrollTrigger/), [Motion](https://motion.dev/docs/scroll), or [anime.js](https://animejs.com/).
For pure CSS-driven scroll animations, see native [scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations) (not yet supported in all browsers). ScrollMagic complements them by providing cross-browser support, event callbacks, progress values, and state management that the native API doesn't cover.
ScrollMagic is a general-purpose, framework-agnostic, zero-dependency foundation for scroll-driven UX — what you do with it is entirely up to you: class toggles, animations, lazy loading, parallax, scroll-linked video, behavioural tracking, or anything else.
### Why ScrollMagic?
- Tiny footprint, zero dependencies
- Free to use ([open source](LICENSE.md))
- Optimized for performance (shared observers, batched rAF, single-frame updates)
- Built for modern browsers, mobile compatible
- Native TypeScript support
- SSR safe
- Works with any scroll container (window or custom element)
- Horizontal and vertical scrolling
- Plugin system for extensibility
- Framework agnostic — works with React, Vue, vanilla JS, anything
## Installation
```sh
npm install scrollmagic@next
```
## Quick Start
```js
import ScrollMagic from 'scrollmagic';
new ScrollMagic({ element: '#my-element' })
.on('enter', () => console.log('visible!'))
.on('leave', () => console.log('gone!'))
.on('progress', e => console.log(`${(e.target.progress * 100).toFixed(0)}%`));
```
## How It Works
ScrollMagic uses two sets of bounds to define the active range:
- **Container bounds** — a zone on the scroll container, defined by `containerStart` and `containerEnd`
- **Element bounds** — a zone on the tracked element, defined by `elementStart` and `elementEnd`
Progress goes from `0` to `1` as the element bounds pass through the container bounds. Events fire on enter, leave, and progress change.
### Contain and Intersect
The two most common configurations are **contain** and **intersect**. They differ in where the container bounds are positioned:
#### Contain (default when `element` is `null`)
The container bounds match the viewport edges — `containerStart` and `containerEnd` are both at `'here'` (`0%`). Progress goes from 0 to 1 while one fully **contains** the other: either the element is fully visible inside the viewport, or the element fully covers the viewport.
Typical uses: scroll progress bars, parallax, scroll-linked video, scroll-driven storytelling.
#### Intersect (default when `element` is set)
The container bounds span the full viewport — `containerStart` and `containerEnd` are at `'opposite'` edges (`100%`). Progress goes from 0 to 1 while the element **intersects** with the viewport: starting when its leading edge enters and ending when its trailing edge leaves.
Typical uses: enter/leave animations, lazy loading, class toggles, visibility tracking.
#### Not just defaults
While _contain_ and _intersect_ are the inferred defaults, you can also configure them explicitly — for example setting `containerStart: 0, containerEnd: 0` on an instance that has an element to get contain behaviour, or mixing container and element insets for custom tracking zones. The two configurations are **useful mental models, not rigid modes**.
#### Native scroll-driven animation ranges
If you're familiar with [CSS scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations), here's how the native `view()` timeline ranges map to ScrollMagic configurations:
| Native range | ScrollMagic equivalent |
| ------------ | ---------------------- |
| `cover` | _intersect_ default — `containerStart: 'opposite', containerEnd: 'opposite'` |
| `contain` | _contain_ default — `containerStart: 0, containerEnd: 0` |
| `entry` | `containerStart: 'opposite', containerEnd: 0` — container zone collapses to the trailing edge |
| `exit` | `containerStart: 0, containerEnd: 'opposite'` — container zone collapses to the leading edge |
The native `entry-crossing` and `exit-crossing` ranges are equivalent to `entry` and `exit` above — the distinction only applies when subdividing a single native timeline, not when defining standalone tracking ranges.
## Options
All options are optional. They can be passed to the constructor and updated at any time via setters or `.modify()`.
| Option | Type | Default | Description |
| ---------------- | -------------------------------------- | -------------------------- | ----------------------------------------------------- |
| `element` | `Element \| string \| null` | first child of `container` | The tracked element (or CSS selector). Selectors match only the first element — create one instance per element to track multiple. |
| `elementStart` | `number \| string \| function` | `0` | Start **inset** on the element. |
| `elementEnd` | `number \| string \| function` | `0` | End **inset** on the element. |
| `container` | `Window \| Element \| string \| null` | `window` | The scroll container (or CSS selector). Selectors use the first match. |
| `containerStart` | `number \| string \| function \| null` | inferred (see below) | Start **inset** on the scroll container. |
| `containerEnd` | `number \| string \| function \| null` | inferred (see below) | End **inset** on the scroll container. |
| `vertical` | `boolean` | `true` | Scroll axis. `true` = vertical, `false` = horizontal. |
**Inset values** work like CSS `top`/`bottom`: positive values offset inward from the respective edge in the tracked direction. Accepted value types:
- **Numbers** — pixel values (e.g. `50`)
- **Strings** — percentage or pixel strings (e.g. `'50%'`, `'20px'`), relative to the parent size (scroll container for container options, element for element options)
- **Named positions** — `'here'` (0%), `'center'` (50%), `'opposite'` (100%)
- **Functions** — `(size) => number` for dynamic computation
**`null` means infer:** For `element`, `container`, `containerStart`, or `containerEnd`, setting it to `null` resets them to their inferred default.
For `containerStart`/`containerEnd` the inferred values depend on `element`:
- **`element` is `null`** → defaults to [**contain**](#contain-default-when-element-is-null): the element is inferred as the first child of the container (for `window` this is `document.body`), container offsets are `'here'` (0%), mapping progress to overall scroll position.
- **`element` is not `null`** → defaults to [**intersect**](#intersect-default-when-element-is-set): container offsets are `'opposite'` (100%), tracking the element as it scrolls through the full viewport.
## Events
Subscribe with `.on()`, `.off()`, or `.subscribe()` (returns an unsubscribe function). Pass `{ once: true }` to auto-remove the listener after its first invocation. Calling `.off()` or the unsubscribe function after the listener has already been removed (e.g. after a `once` listener fires) is a safe no-op.
| Event | When |
| ---------- | -------------------------------------------------------- |
| `enter` | Element enters the active zone (progress leaves 0 or 1) |
| `leave` | Element leaves the active zone (progress reaches 0 or 1) |
| `progress` | Progress value changes while in the active zone |
Every event provides:
```ts
event.target; // the ScrollMagic instance (access all properties, e.g. event.target.progress, event.target.element)
event.type; // 'enter' | 'leave' | 'progress'
event.direction; // 'forward' | 'reverse'
event.location; // 'start' | 'inside' | 'end'
```
## Examples
```js
// Intersect (default): active while any part of the element
// is visible in the viewport
new ScrollMagic({
element: '#a',
});
// Intersect with narrowed container zone:
// active while the element passes through the center line
new ScrollMagic({
element: '#b',
containerStart: 'center',
containerEnd: 'center',
});
// Same as above, but with element offsets:
// starts 50px before the element, ends 100px after it
new ScrollMagic({
element: '#c',
containerStart: 'center',
containerEnd: 'center',
elementStart: -50,
elementEnd: -100,
});
// Fixed scroll distance of 150px, regardless of element height.
// elementEnd receives the element's size and offsets from
// the bottom — (size - 150) leaves only 150px of track.
new ScrollMagic({
element: '#d',
containerStart: 'center',
containerEnd: 'center',
elementEnd: size => size - 150,
});
// Contain: active only while the element is fully visible
// (element insets pushed to opposite edges = full element height)
new ScrollMagic({
element: '#e',
elementStart: 'opposite', // same as '100%'
elementEnd: 'opposite', // same as '100%'
});
// Contain (default when no element): track overall scroll progress
new ScrollMagic();
```
## API
```ts
const sm = new ScrollMagic(options);
// Event listeners
sm.on(type, callback); // add listener, returns instance (chainable)
sm.on(type, callback, { once: true }); // listener auto-removes after first invocation
sm.off(type, callback); // remove listener, returns instance (chainable)
sm.subscribe(type, callback); // add listener, returns unsubscribe function
sm.subscribe(type, callback, { once: true }); // both auto-removes and returns unsubscribe
// Modify options after creation
sm.modify({ containerStart: 'center' });
// All options can also be directly read and written
const elem = sm.element; // get the tracked element
sm.containerStart = 'center'; // set individual options
// Read-only getters
sm.progress; // 0–1, how far through the active zone
sm.activeRange; // { start, end } container scroll positions where tracking is active
sm.scrollVelocity; // px/s along tracked axis, 0 when idle
sm.resolvedBounds; // { element, container } cached layout bounds
// Refresh — recalculate bounds after external layout changes
sm.refresh();
// Pause / resume tracking without destroying
sm.disable(); // disconnects all observers, freezes progress
sm.enable(); // reconnects observers, recalculates from current state
sm.disabled; // read-only, true when disabled or destroyed
// Lifecycle
sm.destroy();
// Static
ScrollMagic.defaultOptions({ vertical: false }); // get/set defaults for new instances
ScrollMagic.refreshAll(); // refresh every active instance
ScrollMagic.destroyAll(); // destroy every active instance
```
## When to use `refresh()`
ScrollMagic automatically tracks element size changes (via `ResizeObserver`) and scroll position changes. But some layout changes are invisible to these observers — they change an element's **position** without changing its **size** or triggering a scroll event.
Call `refresh()` (or `ScrollMagic.refreshAll()`) after:
- **CSS position/margin/padding changes** — `element.style.marginTop = '20px'`
- **CSS class toggles that affect layout** — `element.classList.add('expanded')`
- **DOM structure changes** — siblings added/removed above the element, shifting its position
- **Images loading without explicit dimensions** — an ` ` above the tracked element loads and expands, pushing it down
- **Font loading** — `document.fonts.ready.then(() => ScrollMagic.refreshAll())`
- **Route changes in SPAs** — content swap changes scroll height
- **Dynamic content loading** — CMS-injected content, third-party widgets
```js
// After changing a style that affects position
element.style.marginTop = '100px';
sm.refresh();
// After fonts finish loading (affects text reflow)
document.fonts.ready.then(() => ScrollMagic.refreshAll());
// After a framework re-render that changes layout
onRouteChange(() => ScrollMagic.refreshAll());
```
Note that `refresh()` is only needed if you want bounds to update **before the next scroll event**. If the user keeps scrolling, element positions are re-read on every scroll frame anyway. `refresh()` matters when layout changes while tracking is active and the scroll position stays the same — e.g. toggling a class or injecting content without any scrolling.
`refresh()` is asynchronous — it schedules recalculation for the next animation frame and returns immediately. Multiple `refresh()` calls within the same frame are batched automatically.
## Plugins
ScrollMagic has a plugin system for extending instance behaviour.
```ts
sm.addPlugin(myPlugin);
sm.removePlugin(myPlugin);
```
See [PLUGINS.md](PLUGINS.md) for the full plugin authoring guide.
## Browser Support
Chrome 73+, Firefox 69+, Safari 13.1+, Edge 79+ (aligned to `ResizeObserver` support).
## License
MIT — [Jan Paepke](https://janpaepke.de)
================================================
FILE: ROADMAP.md
================================================
# Roadmap
Ideas and future directions for ScrollMagic v3. Nothing here is committed, just a notepad for enhancements.
## Documentation & Demo Pages
API docs and interactive demos for v3. (Highest priority)
## API Gaps
### Plugin candidates
- **toggleClass** — auto add/remove CSS class on enter/leave. The most common scroll use case — nearly every library has it. Usage-specific, so better as a bundled plugin than core API.
- **CSS variable output** — expose `--progress`, `--visible` etc. as CSS custom properties on elements. Enables pure-CSS scroll effects with zero JS callbacks.
- **Batch coordination** — when N elements enter the viewport in the same frame, fire one coordinated callback with stagger support. Essential for grid/list reveals.
- **Web Animation API bridge** — drive `element.animate()` keyframes from ScrollMagic progress. Use native `ScrollTimeline` when available (compositor-thread performance for `transform`/`opacity`/`filter`) and fall back to SM-driven progress updates when not. Gives SM users native animation performance without leaving SM's event/callback model.
## Plugin Ideas
### Auto-refresh (MutationObserver + PositionObserver)
An opt-in plugin that automatically calls `refresh()` when layout-affecting changes are detected on the tracked element. Two complementary approaches:
- **MutationObserver** — watches `style` and `class` attribute changes on the element. Catches inline style modifications (`element.style.margin = '...'`) and class toggles. Limitation: high false-positive rate (fires on non-layout changes like `color`), can't detect stylesheet rule changes or media query transitions.
- **Userland PositionObserver** (e.g. [Shopify/position-observer](https://github.com/Shopify/position-observer)) — IntersectionObserver-based, detects actual position shifts without polling. Limitation: ~1px precision, only works while the element intersects the root.
Neither covers all cases, but together they could reduce the need for manual `refresh()` in common scenarios (framework re-renders, third-party widgets, CMS-injected content). Should be off by default due to overhead.
### Debug indicators
Visual debugging overlay similar to ScrollMagic v2's `addIndicators` plugin. Shows trigger positions, element start/end markers, and current progress. Helps developers see what ScrollMagic is calculating without console logging.
### Pin
Element pinning during scroll progress — the v2 `setPin` equivalent. CSS `position: sticky` covers the basic "pin while in viewport" case well, but doesn't handle unpin-and-repin scenarios (pin an element, release it at a specific scroll position, then re-pin it later). A plugin could fill that gap while recommending `sticky` for simple cases.
## Framework Integrations
### React
React wrapper/hooks for ScrollMagic. Reference implementation: [sm-test-react](https://github.com/janpaepke/sm-test-react). Should handle lifecycle cleanup (destroy on unmount), re-render-safe refs, and ideally provide a `useScrollMagic` hook.
================================================
FILE: config/banner.txt
================================================
<%= pkg.title %> v<%= pkg.version %>
Author: <%= pkg.author.name %> (<%= pkg.author.url %>)
<%= pkg.contributors && pkg.contributors.some(function(c) { return c.name }) ? 'Contributors: ' + pkg.contributors.filter(function(c) { return c.name }).map(function(c) { return c.name }).join(', ') + '\n' : '' %>Generated: <%= moment().format('YYYY-MM-DD') %>
License: <%= pkg.license %>
Docs & Demos: <%= pkg.homepage %>
================================================
FILE: docs/diagrams/contain.html
================================================
ScrollMagic – Contain Diagram
Container
Element
containerStart
'here' (0%)
0%
================================================
FILE: docs/diagrams/intersect.html
================================================
ScrollMagic – Intersect Diagram
Container
Element
enter
leave
containerStart
'opposite' (100%)
containerEnd
'opposite' (100%)
0%
================================================
FILE: docs/diagrams/shared.css
================================================
/* shared styles for ScrollMagic animated diagrams */
:root {
--viewport-width: 260px;
--viewport-height: 174px;
--viewport-left: 170px;
--element-width: 100px;
--stage-padding-top: 120px;
--stage-padding-bottom: 120px;
--color-viewport: #e74c3c;
--color-element: #3498db;
--color-marker: #888;
--color-progress: #2ecc71;
--color-bg: #fff;
--color-label: #555;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--color-bg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
overflow: hidden;
}
.stage {
position: relative;
/* viewport-left + viewport + gap + progress track + label */
width: calc(var(--viewport-left) + var(--viewport-width) + 80px);
height: calc(var(--viewport-height) + var(--stage-padding-top) + var(--stage-padding-bottom));
}
/* mask overlays — white semi-transparent, dims the element outside the container */
.mask-top,
.mask-bottom {
position: absolute;
left: var(--viewport-left);
width: var(--viewport-width);
background: rgba(255, 255, 255, 0.7);
z-index: 2;
}
.mask-top {
top: 0;
height: var(--stage-padding-top);
}
.mask-bottom {
bottom: 0;
height: var(--stage-padding-bottom);
}
/* container box */
.viewport {
position: absolute;
left: var(--viewport-left);
top: var(--stage-padding-top);
width: var(--viewport-width);
height: var(--viewport-height);
border: 3px solid var(--color-viewport);
border-radius: 6px;
background: transparent;
z-index: 3;
}
.viewport-label {
position: absolute;
top: -22px;
left: 50%;
transform: translateX(-50%);
font-size: 13px;
font-weight: 600;
color: var(--color-viewport);
white-space: nowrap;
}
/* element (moves via animation) */
.element {
position: absolute;
left: calc(var(--viewport-left) + var(--viewport-width) / 2);
transform: translateX(-50%);
width: var(--element-width);
background: var(--color-element);
border-radius: 5px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 13px;
font-weight: 600;
}
/* container markers — left side, with arrow pointing to container edge */
.marker {
position: absolute;
z-index: 4;
display: flex;
align-items: center;
right: calc(100% - var(--viewport-left) + 10px);
flex-direction: row;
gap: 6px;
transform: translateY(-50%);
}
.marker-label-group {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
.marker-name {
font-size: 11px;
color: var(--color-label);
white-space: nowrap;
font-weight: 500;
}
.marker-value {
font-size: 10px;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
color: #999;
white-space: nowrap;
}
.marker-line {
position: relative;
height: 0;
width: 40px;
border-top: 2px dashed var(--color-marker);
flex-shrink: 0;
}
/* arrowhead pointing right toward the container edge */
.marker-line::after {
content: '';
position: absolute;
right: -7px;
top: -5px;
width: 0;
height: 0;
border-left: 7px solid var(--color-marker);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
}
/* progress indicator — right side of container */
.progress-track {
position: absolute;
z-index: 4;
left: calc(var(--viewport-left) + var(--viewport-width) + 20px);
width: 4px;
background: #e0e0e0;
border-radius: 2px;
}
.progress-fill {
position: absolute;
top: auto;
bottom: 0;
left: 0;
width: 100%;
background: var(--color-progress);
border-radius: 2px;
}
.progress-label {
position: absolute;
z-index: 4;
left: calc(var(--viewport-left) + var(--viewport-width) + 32px);
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-progress);
white-space: nowrap;
}
================================================
FILE: eslint.config.mjs
================================================
//@ts-check
import eslint from '@eslint/js';
import compat from 'eslint-plugin-compat';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default defineConfig(
{ ignores: ['dist/', 'docs/tsdoc'] },
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
compat.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
projectService: {
allowDefaultProject: ['vitest.config.ts'],
},
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'no-useless-rename': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
files: ['**/*.mjs'],
extends: [tseslint.configs.disableTypeChecked],
languageOptions: {
globals: {
...globals.node,
},
},
},
{
files: ['scripts/*.mjs'],
rules: {
'compat/compat': 'off',
},
}
);
================================================
FILE: package.json
================================================
{
"name": "scrollmagic",
"title": "ScrollMagic",
"version": "3.0.0-beta.4",
"description": "The lightweight library for magical scroll interactions.",
"type": "module",
"main": "./dist/scrollmagic.umd.js",
"module": "./dist/scrollmagic.esm.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/scrollmagic.esm.js",
"require": "./dist/scrollmagic.umd.js"
},
"./util": {
"types": "./dist/types/util.d.ts",
"import": "./dist/scrollmagic.util.esm.js",
"require": "./dist/scrollmagic.util.umd.js"
}
},
"sideEffects": false,
"files": [
"dist"
],
"devDependencies": {
"@eslint/js": "^10.0.1",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.3.0",
"@vitest/browser-playwright": "^4.0.18",
"eslint": "^10.0.0",
"eslint-plugin-compat": "^6.2.0",
"gifenc": "^1.0.3",
"globals": "^17.3.0",
"jsdom": "^28.1.0",
"pngjs": "^7.0.0",
"prettier": "^3.8.1",
"rollup": "^4.57.1",
"rollup-plugin-bundle-size": "^1.0.3",
"rollup-plugin-delete": "^3.0.2",
"rollup-plugin-license": "^3.7.0",
"tslib": "^2.8.1",
"typedoc": "^0.28.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=18"
},
"scripts": {
"build": "rollup -c",
"dev": "npm run build -- --watch",
"test": "npm run typecheck && vitest run",
"test:unit": "vitest run --project unit",
"test:e2e": "vitest run --project e2e",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "npm run lint -- --fix",
"prettier": "prettier --check .",
"prettier:fix": "prettier --write .",
"preversion": "npm run build && npm run test",
"prepublishOnly": "npm run build",
"docs:api": "typedoc",
"doces:export-diagrams": "node scripts/export-diagrams.mjs"
},
"repository": {
"type": "git",
"url": "git+https://github.com/janpaepke/ScrollMagic.git"
},
"author": {
"name": "Jan Paepke",
"url": "https://janpaepke.de",
"email": "e-mail@janpaepke.de"
},
"contributors": [
{}
],
"license": "MIT",
"bugs": {
"url": "https://github.com/janpaepke/ScrollMagic/issues"
},
"homepage": "https://scrollmagic.io",
"browserslist": [
"Chrome >= 73",
"Firefox >= 69",
"Safari >= 13.1",
"Edge >= 79",
"iOS >= 13.4",
"Samsung >= 9.2",
"not dead"
]
}
================================================
FILE: prettier.config.mjs
================================================
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
semi: true,
useTabs: true,
printWidth: 120,
bracketSpacing: true,
arrowParens: 'avoid',
htmlWhitespaceSensitivity: 'css',
endOfLine: 'lf',
singleQuote: true,
trailingComma: 'es5',
experimentalTernaries: true,
};
export default config;
================================================
FILE: rollup.config.mjs
================================================
import terser from '@rollup/plugin-terser';
import typescript from '@rollup/plugin-typescript';
import bundleSize from 'rollup-plugin-bundle-size';
import clean from 'rollup-plugin-delete';
import license from 'rollup-plugin-license';
import pkg from './package.json' with { type: 'json' };
import cfg from './tsconfig.json' with { type: 'json' };
const createCommonPlugins = () => [
bundleSize(),
typescript({
declarationDir: './dist/types',
exclude: ['tests/**/*'],
}),
terser(),
license({
banner: {
commentStyle: 'ignored',
content: {
file: './config/banner.txt',
encoding: 'utf-8',
},
},
}),
];
const main = {
input: './src/index.ts',
output: [
{
format: 'umd',
file: pkg.main,
name: pkg.title,
sourcemap: true,
},
{
format: 'esm',
file: pkg.module,
sourcemap: true,
},
],
plugins: [
clean({
targets: `${cfg.compilerOptions.outDir}/*`,
}),
...createCommonPlugins(),
],
};
const util = {
input: './src/util.ts',
output: [
{
format: 'umd',
file: pkg.exports['./util'].require,
name: `${pkg.title}Utils`,
sourcemap: true,
},
{
format: 'esm',
file: pkg.exports['./util'].import,
sourcemap: true,
},
],
plugins: createCommonPlugins(),
};
export default [main, util];
================================================
FILE: scripts/export-diagrams.mjs
================================================
/**
* Converts HTML animation files to GIF using Playwright + gifenc + pngjs.
*
* Usage:
* npm run export-diagrams # export all diagrams
* node scripts/export-diagrams.mjs intersect # export only intersect.gif
* node scripts/export-diagrams.mjs contain # export only contain.gif
*
* Options (env vars):
* FPS=15 frames per second (default: 15)
*/
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import pkg from 'pngjs';
const { PNG } = pkg;
import gifenc from 'gifenc';
const { GIFEncoder, quantize, applyPalette } = gifenc;
const __dirname = dirname(fileURLToPath(import.meta.url));
const srcDir = resolve(__dirname, '..', 'docs', 'diagrams');
const outDir = resolve(__dirname, '..', 'docs', 'dist', 'gfx');
const diagrams = {
intersect: resolve(srcDir, 'intersect.html'),
contain: resolve(srcDir, 'contain.html'),
};
const fps = parseInt(process.env.FPS || '15', 10);
async function exportDiagram(name, htmlPath) {
console.log(`\n--- Exporting ${name} ---`);
console.log(` Source: ${htmlPath}`);
const browser = await chromium.launch();
const page = await browser.newPage();
// Set viewport large enough to contain the stage (170+260+80=510 wide, 120+174+120=414 tall)
await page.setViewportSize({ width: 540, height: 450 });
await page.goto(`file://${htmlPath}`, { waitUntil: 'domcontentloaded' });
// Wait for animations to start
await page.waitForFunction(() => document.getAnimations().length > 0);
// Get animation duration and detect all animations
const animInfo = await page.evaluate(() => {
const animations = document.getAnimations();
const durations = animations.map(a => {
const timing = a.effect.getComputedTiming();
return timing.duration + timing.delay;
});
return {
count: animations.length,
duration: Math.max(...durations),
};
});
console.log(` Animations: ${animInfo.count}, duration: ${animInfo.duration}ms`);
console.log(` FPS: ${fps}`);
const duration = animInfo.duration;
const frameCount = Math.ceil((duration / 1000) * fps);
const frameDelay = Math.round(1000 / fps); // ms per frame for GIF
console.log(` Frames: ${frameCount}, delay: ${frameDelay}ms`);
// Pause all animations at time 0 and kill the rAF-based text loop
await page.evaluate(() => {
document.getAnimations().forEach(a => {
a.pause();
a.currentTime = 0;
});
// Override rAF so the HTML's live-preview loop can't overwrite text
window.requestAnimationFrame = () => 0;
});
// Determine tight bounding box of the .stage element
const stageBox = await page.evaluate(() => {
const stage = document.querySelector('.stage');
const rect = stage.getBoundingClientRect();
return {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
});
console.log(` Stage bounds: ${stageBox.width}x${stageBox.height} at (${stageBox.x}, ${stageBox.y})`);
const gif = GIFEncoder();
let width, height;
for (let i = 0; i < frameCount; i++) {
const time = (i / frameCount) * duration;
// Seek all animations to this time
await page.evaluate(t => {
document.getAnimations().forEach(a => {
a.currentTime = t;
});
}, time);
// Update the JS-driven progress counter via the page's own getProgress()
await page.evaluate(
({ t, dur }) => {
const el = document.querySelector('.progress-counter');
if (el && typeof window.getProgress === 'function') {
el.textContent = window.getProgress(t / dur) + '%';
}
},
{ t: time, dur: duration }
);
// Take screenshot of just the stage area
const pngBuffer = await page.screenshot({
clip: stageBox,
type: 'png',
});
const png = PNG.sync.read(pngBuffer);
if (i === 0) {
width = png.width;
height = png.height;
}
const palette = quantize(png.data, 256);
const index = applyPalette(png.data, palette);
gif.writeFrame(index, width, height, {
palette,
delay: frameDelay,
repeat: 0,
});
if ((i + 1) % 10 === 0 || i === frameCount - 1) {
process.stdout.write(`\r Frame ${i + 1}/${frameCount} (${Math.round(time)}ms)`);
}
}
gif.finish();
const gifBytes = gif.bytes();
// Ensure output directory
if (!existsSync(outDir)) {
mkdirSync(outDir, { recursive: true });
}
const outPath = resolve(outDir, `${name}.gif`);
writeFileSync(outPath, gifBytes);
const sizeKB = (gifBytes.length / 1024).toFixed(1);
console.log(`\n Output: ${outPath} (${sizeKB} KB)`);
await browser.close();
}
// Main
const requested = process.argv[2];
const names = requested ? [requested] : Object.keys(diagrams);
for (const name of names) {
if (!diagrams[name]) {
console.error(`Unknown diagram: ${name}`);
console.error(`Available: ${Object.keys(diagrams).join(', ')}`);
process.exit(1);
}
}
console.log(`Exporting diagrams: ${names.join(', ')}`);
for (const name of names) {
await exportDiagram(name, diagrams[name]);
}
console.log('\nDone!');
================================================
FILE: src/Container.ts
================================================
import { type DispatchableEvent, EventDispatcher } from './EventDispatcher';
import { getScrollContainerDimensions } from './util/getScrollContainerDimensions';
import { getScrollPos } from './util/getScrollPos';
import { registerEvent } from './util/registerEvent';
import { rafQueue } from './util/rafQueue';
import { observeResize } from './util/sharedResizeObserver';
import { throttleRaf } from './util/throttleRaf';
import { isWindow } from './util/typeguards';
export type ScrollContainer = HTMLElement | Window;
type CleanUpFunction = () => void;
type Vector = {
x: number;
y: number;
};
const ZERO_VECTOR: Readonly = Object.freeze({ x: 0, y: 0 });
// type EventType = 'scroll' | 'resize';
enum EventType {
Scroll = 'scroll',
Resize = 'resize',
}
export class ContainerEvent implements DispatchableEvent {
constructor(
public readonly target: Container,
public readonly type: `${EventType}`,
public readonly scrollDelta: Readonly = ZERO_VECTOR // I could make an additional EventType only for Scroll Events, but we'll just ignore these for resize events...
) {}
}
export class Container {
/** Time in milliseconds after which the scroll velocity is considered stale. */
private static readonly VELOCITY_STALE_MS = 100;
private dimensions = {
// inner size excluding scrollbars
clientWidth: 0,
clientHeight: 0,
// size of scrollable content
scrollWidth: 0,
scrollHeight: 0,
};
private scrollPos = {
top: 0,
left: 0,
};
private positionCache = {
// position of scroll parent (if not window) relative to window
top: 0,
left: 0,
};
private destroyed = false;
private lastScrollTime: number | undefined;
private readonly scrollVelocityCache: Vector = { x: 0, y: 0 };
private readonly dispatcher = new EventDispatcher();
private readonly cleanups: CleanUpFunction[] = [];
/**
* TODO: Currently we have no way of detecting, when physical scrollbars appear or disappear, which should technically trigger a resize event.
* One potential way of getting around this would be to add an additional resize observer to the documentElement and detect when it crosses 100% of the container's client size (either in or out)
* But this seems quite hacky and code intense for this edge case scenario. It would also work for document scrolls, not for Element scrolls.
*/
constructor(public readonly containerElement: ScrollContainer) {
const throttledScroll = throttleRaf(() => {
this.updateScrollPos();
rafQueue.flush();
});
const throttledResize = throttleRaf(() => {
this.updateDimensions();
rafQueue.flush();
});
if (!isWindow(containerElement)) {
const throttledMove = throttleRaf(this.updatePosition.bind(this));
this.cleanups.push(throttledMove.cancel, this.subscribeMove(throttledMove));
this.updatePosition(); // initialize synchronously; subsequent updates are throttled via subscribeMove
}
this.cleanups.push(
throttledScroll.cancel,
throttledResize.cancel,
this.subscribeScroll(throttledScroll),
this.subscribeResize(throttledResize)
);
this.updateScrollPos();
this.updateDimensions();
}
private updateScrollPos() {
const prevScrollPos = this.scrollPos;
this.scrollPos = getScrollPos(this.containerElement);
const deltaY = this.scrollPos.top - prevScrollPos.top;
const deltaX = this.scrollPos.left - prevScrollPos.left;
const now = performance.now();
if (undefined !== this.lastScrollTime) {
const dt = now - this.lastScrollTime;
if (dt > 0) {
this.scrollVelocityCache.x = (deltaX / dt) * 1000;
this.scrollVelocityCache.y = (deltaY / dt) * 1000;
}
}
this.lastScrollTime = now;
this.dispatcher.dispatchEvent(new ContainerEvent(this, EventType.Scroll, { x: deltaX, y: deltaY }));
}
private updateDimensions() {
this.dimensions = getScrollContainerDimensions(this.containerElement);
this.dispatcher.dispatchEvent(new ContainerEvent(this, EventType.Resize));
}
private updatePosition() {
// this should only be executed, when containerElement is NOT window
const { top, left } = (this.containerElement as HTMLElement).getBoundingClientRect();
this.positionCache = { top, left };
}
// subscribes to resize events of containerElement and returns a function to reverse the effect
private subscribeResize(onResize: () => void) {
const { containerElement } = this;
if (isWindow(containerElement)) {
return registerEvent(containerElement, EventType.Resize, onResize);
}
return observeResize(containerElement, onResize);
}
// subscribes to scroll events of containerElement and returns a function to reverse the effect
private subscribeScroll(onScroll: () => void) {
return registerEvent(this.containerElement, EventType.Scroll, onScroll, { passive: true });
}
private subscribeMove(onMove: () => void) {
const listeners = [
registerEvent(window, EventType.Scroll, onMove, { passive: true }),
registerEvent(window, EventType.Resize, onMove),
];
return () => listeners.forEach(cleanup => cleanup());
}
// subscribes Container and returns a function to reverse the effect
public subscribe(type: `${EventType}`, cb: (e: ContainerEvent) => void): () => void {
return this.dispatcher.addEventListener(type, cb);
}
public get size(): Readonly {
return this.dimensions;
}
public get position(): Readonly {
return this.positionCache;
}
public get scrollVelocity(): Readonly {
if (undefined === this.lastScrollTime || performance.now() - this.lastScrollTime > Container.VELOCITY_STALE_MS) {
return ZERO_VECTOR;
}
return this.scrollVelocityCache;
}
public destroy(): void {
if (this.destroyed) {
return;
}
this.destroyed = true;
this.cleanups.forEach(cleanup => cleanup());
this.cleanups.length = 0;
}
}
================================================
FILE: src/ContainerProxy.ts
================================================
import { Container, type ContainerEvent, type ScrollContainer } from './Container';
import { ScrollMagic } from './ScrollMagic';
import { ScrollMagicInternalError } from './ScrollMagicError';
type EventCallback = (e: ContainerEvent) => void;
type CleanUpFunction = () => void;
type Velocity = {
x: number;
y: number;
};
export class ContainerProxy {
private static cache = new WeakMap]>();
private container?: Container;
constructor(private readonly sm: ScrollMagic) {}
private unsubscribers: CleanUpFunction[] = [];
public attach(containerElement: ScrollContainer, onUpdate: EventCallback): void {
if (undefined !== this.container) {
this.detach();
}
let cache = ContainerProxy.cache.get(containerElement);
if (undefined === cache) {
cache = [new Container(containerElement), new Set()];
ContainerProxy.cache.set(containerElement, cache);
}
const [container, instances] = cache;
instances.add(this.sm);
this.container = container;
this.unsubscribers = [container.subscribe('resize', onUpdate), container.subscribe('scroll', onUpdate)];
}
public detach(): void {
if (undefined === this.container) {
return;
}
const { containerElement } = this.container;
const cache = ContainerProxy.cache.get(containerElement);
if (undefined === cache) {
throw new ScrollMagicInternalError('No cache info for container');
}
const [container, instances] = cache;
instances.delete(this.sm);
this.unsubscribers.forEach(unsubscribe => unsubscribe());
this.unsubscribers = [];
if (instances.size === 0) {
// no more attached instances
container.destroy();
ContainerProxy.cache.delete(containerElement);
}
this.container = undefined;
}
public get size(): Container['size'] {
if (undefined === this.container) {
throw new ScrollMagicInternalError(`Can't get size when not attached to a container`);
}
return this.container.size;
}
public get position(): Container['position'] {
if (undefined === this.container) {
throw new ScrollMagicInternalError(`Can't get position when not attached to a container`);
}
return this.container.position;
}
public get scrollVelocity(): Velocity {
if (undefined === this.container) {
return { x: 0, y: 0 };
}
return this.container.scrollVelocity;
}
}
================================================
FILE: src/EventDispatcher.ts
================================================
const noop = () => {};
type EventType = string;
export interface DispatchableEvent {
readonly target: unknown;
readonly type: EventType;
}
/** Options for event listener registration. */
export type ListenerOptions = {
/** If `true`, the listener is automatically removed after its first invocation. */
once?: boolean;
/** An {@link AbortSignal} — when aborted, the listener is automatically removed. Matches the DOM `addEventListener` pattern. */
signal?: AbortSignal;
};
type Callback = (event: E) => void;
type ListenerEntry = { cb: Callback; options: ListenerOptions };
export class EventDispatcher {
private listeners = new Map>>();
// adds a listener to the dispatcher. returns a function to reverse the effect.
public addEventListener(type: E['type'], cb: Callback, options: ListenerOptions = {}): () => void {
// Match DOM spec: if signal already aborted, don't register
if (options.signal?.aborted) {
return noop;
}
let set = this.listeners.get(type);
if (!set) {
set = new Set();
this.listeners.set(type, set);
}
// fresh object per registration — Set identity keeps duplicate registrations of the same callback distinct
const entry: ListenerEntry = { cb, options };
set.add(entry);
const remove = () => this.removeEntry(type, entry);
if (options.signal) {
options.signal.addEventListener('abort', remove, { once: true });
}
return remove;
}
// removes a listener from the dispatcher
public removeEventListener(type: E['type'], cb: Callback): void {
const set = this.listeners.get(type);
if (!set) {
return;
}
for (const entry of set) {
if (entry.cb === cb) {
this.removeEntry(type, entry);
break;
}
}
}
// dispatches an event
public dispatchEvent(event: E): void {
const set = this.listeners.get(event.type);
if (!set) {
return;
}
// iterate a copy so listeners added during dispatch don't fire in the same cycle
for (const entry of [...set]) {
if (entry.options.once) {
this.removeEntry(event.type, entry);
}
entry.cb(event);
}
}
private removeEntry(type: string, entry: ListenerEntry): void {
const set = this.listeners.get(type);
if (!set) {
return;
}
set.delete(entry);
if (0 === set.size) {
this.listeners.delete(type);
}
}
}
================================================
FILE: src/ExecutionQueue.ts
================================================
import { rafQueue } from './util/rafQueue';
import { transformObject } from './util/transformObject';
type Callback = () => void;
type ExecutionCondition = () => boolean;
const always: ExecutionCondition = () => true;
type CommandList = Record;
/**
* This class holds a list of callbacks allows them to be scheduled for execution on next animationFrame.
* Every callback will only be executed once per animationFrame, even if scheduled multiple times.
* The order of the queue superceeds the order of scheduling, this means that if the queue consists of callbacks a, b,
* a will always execute first, even if b is scheduled first.
*
* usage example:
* ```
* const queue = new ExecutionQueue({
* a: () => console.log('a');
* b: () => console.log('b');
* })
* queue.commands.b.schedule();
* queue.commands.a.schedule();
*
* 'a'
* 'b'
* ```
*
* For details about conditional execution see Command class below.
*
* To invoke execution now (and purge scheduled), call queue.execute.
* To cancel scheduled execution, call queue.cancel
*/
export class ExecutionQueue {
public readonly commands: CommandList;
constructor(queueItems: Record) {
this.commands = transformObject(queueItems, ([key, command]) => [key, new Command(command, () => rafQueue.schedule(this))]);
}
// executes all commands in the list in order, depending on whether or not their conditions are met
public execute(): void {
Object.values(this.commands).forEach(item => {
if (item.conditionsMet) {
item.execute();
}
item.resetConditions();
});
}
public cancel(): void {
rafQueue.unschedule(this);
}
}
/**
* Each command in the ExecutionQueue above can be scheduled for execution using command.schedule()
* .schedule() also accepts an optional parameter, a condition callback
* This is called when execution is due, to determine if the callback should still be called.
*
* usage example:
* ```
* let x = 1;
* const queue = new ExecutionQueue({
* a: () => {
* x = 2;
* console.log('a');
* };
* b: () => console.log('b');
* })
* // result of execution condition remains true
* queue.commands.b.schedule(() => x === 1);
*
* 'b'
* // x is 1 now, but will be 2, once a has been called.
* queue.commands.a.schedule();
* queue.commands.b.schedule(() => x === 1);
*
* 'a'
* ```
*/
class Command {
protected conditions: ExecutionCondition[] = [];
constructor(
public readonly execute: Callback,
protected readonly onSchedule: () => void
) {}
public schedule(condition?: ExecutionCondition) {
if (undefined === condition) {
// if no condition is provided, conditions are considered always met. Any conditions added after this won't even be run
this.conditions = [];
condition = always;
}
this.conditions.push(condition);
this.onSchedule();
}
public resetConditions() {
this.conditions = [];
}
public get conditionsMet() {
return this.conditions.some(condition => condition());
}
}
================================================
FILE: src/Options.processors.ts
================================================
import {
PixelConverter,
Private,
PrivateUninferred,
Public,
inferredContainerDefaults,
defaults as optionDefaults,
} from './Options';
import { ScrollMagicError } from './ScrollMagicError';
import { agnosticValues } from './util/agnosticValues';
import { getScrollContainerDimensions } from './util/getScrollContainerDimensions';
import { PropertyProcessors, processProperties } from './util/processProperties';
import { sanitizeProperties } from './util/sanitizeProperties';
import { skipNull, toPixelConverter, toSvgOrHtmlElement, toValidContainer } from './util/transformers';
import { isHTMLElement, isSVGElement, isWindow } from './util/typeguards';
const transformers: PropertyProcessors, PrivateUninferred> = {
element: skipNull(toSvgOrHtmlElement),
elementStart: toPixelConverter,
elementEnd: toPixelConverter,
container: skipNull(toValidContainer),
containerStart: skipNull(toPixelConverter),
containerEnd: skipNull(toPixelConverter),
vertical: Boolean,
};
// removes unknown properties from supplied options
export const sanitizeOptions = (options: T): T => sanitizeProperties(options, optionDefaults);
// converts all public values to their corresponding private value, leaving null values untouched
const transform = (options: Public): Partial => processProperties(options, transformers);
// processes remaining null values
const infer = (options: PrivateUninferred): Private => {
const inferContainer = (container: Window | HTMLElement | null): Window | HTMLElement => container ?? window;
const inferElement = (elem: Element | null): HTMLElement | SVGElement => {
if (null !== elem) {
return elem as HTMLElement | SVGElement;
}
const resolved = inferContainer(options.container);
const child = isWindow(resolved) ? document.body : resolved.firstElementChild;
if (null === child || !(isHTMLElement(child) || isSVGElement(child))) {
throw new ScrollMagicError(`Could not autodetect element, as container has no valid children.`);
}
return child;
};
const inferContainerOffset = (val: PixelConverter | null): PixelConverter =>
val ?? (null === options.element ? inferredContainerDefaults.fallback : inferredContainerDefaults.default);
return processProperties(options, {
container: inferContainer,
element: inferElement,
containerStart: inferContainerOffset,
containerEnd: inferContainerOffset,
});
};
// checks if the options the user entered actually make sense
const sanityCheck = (options: Private): void => {
const { containerStart, containerEnd, elementStart, elementEnd, vertical, container, element } = options;
if (!isWindow(container) && !container.contains(element)) {
console?.error(
'ScrollMagic: element is not a descendant of container. The IntersectionObserver requires an ancestor relationship to function correctly.',
{ element, container }
);
}
const { size: elementSize } = getElementSize(options);
const { clientSize: containerSize } = agnosticValues(vertical, getScrollContainerDimensions(container));
const elementDistance = elementSize - elementStart(elementSize) - elementEnd(elementSize);
const trackDistance = -(containerSize - containerStart(containerSize) - containerEnd(containerSize));
const total = elementDistance + trackDistance;
if (total < 0) {
console?.warn(
'ScrollMagic Warning: Detected no overlap with the configured track options. This means ScrollMagic will not trigger unless this changes later on (i.e. due to resizes).',
{
...options,
containerStart: containerStart(containerSize),
containerEnd: containerEnd(containerSize),
elementStart: elementStart(elementSize),
elementEnd: elementEnd(elementSize),
}
);
}
};
export const processOptions = (
newOptions: T,
oldOptions?: Private
): { sanitized: T; processed: Private } => {
const sanitized = sanitizeOptions(newOptions);
const normalized = transform(sanitized);
const processed = infer({ ...oldOptions, ...normalized } as PrivateUninferred);
if (typeof process === 'undefined' || process.env.NODE_ENV !== 'production') {
sanityCheck(processed);
}
return { sanitized, processed };
};
// helpers
const getElementSize = ({ vertical, element }: Pick) =>
agnosticValues(vertical, element.getBoundingClientRect());
================================================
FILE: src/Options.ts
================================================
type NullableProperties = Omit & {
[X in K]: T[X] | null;
};
type UnitString = `${number}px` | `${number}%`;
type PositionShorthand = keyof typeof positionShorthands;
type CssSelector = string;
/** Converts an element's or container's current size (in pixels) to a pixel offset used for position calculations. */
export type PixelConverter = (size: number) => number;
/** Public configuration options accepted by the ScrollMagic constructor and `modify()`. */
export type Public = {
/** The tracked element (or CSS selector). Defaults to the first child of `container`. Set to `null` to reset. */
element?: Element | CssSelector | null;
/** Start **inset** on the element. Positive values shrink the tracked region from the leading edge. @default 0 */
elementStart?: number | UnitString | PositionShorthand | PixelConverter;
/** End **inset** on the element. Positive values shrink the tracked region from the trailing edge. @default 0 */
elementEnd?: number | UnitString | PositionShorthand | PixelConverter;
/** The scroll container (or CSS selector). Defaults to `window`. Set to `null` to reset. */
container?: Window | Element | CssSelector | null;
/** Start **inset** on the scroll container. Set to `null` to infer based on `element`. @default null (inferred) */
containerStart?: number | UnitString | PositionShorthand | PixelConverter | null;
/** End **inset** on the scroll container. Set to `null` to infer based on `element`. @default null (inferred) */
containerEnd?: number | UnitString | PositionShorthand | PixelConverter | null;
/** Scroll axis. `true` = vertical, `false` = horizontal. @default true */
vertical?: boolean;
};
// basically a normalized version of the options
export type Private = {
element: Element;
elementStart: PixelConverter;
elementEnd: PixelConverter;
container: Window | HTMLElement;
containerStart: PixelConverter;
containerEnd: PixelConverter;
vertical: boolean;
};
// values that can be null after processing and need to be inferred, if still null
export type PrivateUninferred = NullableProperties<
Private,
'element' | 'container' | 'containerStart' | 'containerEnd'
>;
/** Named position shorthands that resolve to percentage strings for element and container offsets. */
export const positionShorthands = {
here: '0%',
center: '50%',
opposite: '100%',
} as const satisfies Record;
/** Default values for all public options. Returned (and optionally overridden) by `ScrollMagic.defaultOptions()`. */
export const defaults: Required = {
element: null,
elementStart: 0,
elementEnd: 0,
container: null,
containerStart: null,
containerEnd: null,
vertical: true,
};
// applied during fallback inference. if containerStart or containerEnd is null this will apply default if element is present and fallback otherwise
export const inferredContainerDefaults: Record = {
default: (containerSize: number) => containerSize, // default 100%, starts at bottom, ends at top
fallback: () => 0, // if no element is supplied, it will fall back to the first child of the container (usually the body), so it starts at the top and ends at the bottom
};
================================================
FILE: src/ScrollMagic.ts
================================================
import type { ContainerEvent } from './Container';
import { ContainerProxy } from './ContainerProxy';
import { EventDispatcher, type ListenerOptions } from './EventDispatcher';
import { ExecutionQueue } from './ExecutionQueue';
import * as Options from './Options';
import { processOptions, sanitizeOptions } from './Options.processors';
import { EventLocation, EventType, ScrollDirection, ScrollMagicEvent } from './ScrollMagicEvent';
import { agnosticProps } from './util/agnosticValues';
import { getScrollPos } from './util/getScrollPos';
import { pickDifferencesFlat } from './util/pickDifferencesFlat';
import { observeResize } from './util/sharedResizeObserver';
import { numberToPercString } from './util/transformers';
import { isWindow } from './util/typeguards';
import { ViewportObserver } from './ViewportObserver';
const isBrowser = 'undefined' !== typeof window;
/** Cached layout measurements for the tracked element along the scroll axis. */
export type ElementBounds = {
/** Position relative to viewport. */
start: number;
/** Outer visible size of element (excluding margins). */
size: number;
/** Offset relative to top/left of element. */
offsetStart: number;
/** Offset relative to bottom/right of element. */
offsetEnd: number;
/** Effective track size including offsets. */
trackSize: number;
};
/** Cached layout measurements for the scroll container along the scroll axis. */
export type ContainerBounds = {
/** Inner visible area of scroll container (excluding scrollbars). */
clientSize: number;
/** Offset relative to top/left of container. */
offsetStart: number;
/** Offset relative to bottom/right of container. */
offsetEnd: number;
/** Effective track size including offsets. */
trackSize: number;
/** Total size of content of container. */
scrollSize: number;
};
/** Combined cached bounds for both the tracked element and its scroll container. */
export type ResolvedBounds = {
/** Cached bounds of the tracked element. */
element: Readonly;
/** Cached bounds of the scroll container. */
container: Readonly;
};
/**
* A ScrollMagic plugin. Plugins receive lifecycle callbacks bound to the ScrollMagic instance they are added to.
*
* All callbacks are optional. The `this` context inside each callback is the owning ScrollMagic instance.
*/
export interface Plugin {
/** Unique name identifying this plugin. */
name: string;
/** Called when the plugin is added via {@link ScrollMagic.addPlugin}. */
onAdd?(this: ScrollMagic): void;
/** Called when the plugin is removed via {@link ScrollMagic.removePlugin}. */
onRemove?(this: ScrollMagic): void;
/** Called when the instance is enabled via {@link ScrollMagic.enable}. */
onEnable?(this: ScrollMagic): void;
/** Called when the instance is disabled via {@link ScrollMagic.disable}. */
onDisable?(this: ScrollMagic): void;
/** Called when the instance is destroyed via {@link ScrollMagic.destroy}. */
onDestroy?(this: ScrollMagic): void;
/** Called when options change via {@link ScrollMagic.modify}. Receives only the changed options. */
onModify?(this: ScrollMagic, changesPublic: Options.Public): void;
}
/**
* Core class for scroll-based animations. Each instance tracks a single DOM element
* within a scroll container and reports scroll progress (0–1) through events.
*
* @example
* ```js
* const sm = new ScrollMagic({ element: '#hero' });
* sm.on('progress', (e) => console.log(e.target.progress));
* ```
*/
export class ScrollMagic {
public readonly name = 'ScrollMagic';
private resizeCleanup?: () => void;
private readonly dispatcher = new EventDispatcher();
private readonly containerProxy = new ContainerProxy(this);
private readonly viewportObserver = new ViewportObserver(this.onIntersectionChange.bind(this));
private readonly executionQueue = new ExecutionQueue({
// The order is important here! They will always be executed in exactly this order when scheduled for the same animation frame
elementBounds: this.updateElementBoundsCache.bind(this),
containerBounds: this.updateContainerBoundsCache.bind(this),
viewportObserver: this.updateViewportObserver.bind(this),
progress: this.updateProgress.bind(this),
});
private readonly update = this.executionQueue.commands;
private readonly plugins = new Set();
protected readonly elementBoundsCache: ElementBounds = {
// see typedef for details
start: 0,
size: 0,
offsetStart: 0,
offsetEnd: 0,
trackSize: 0,
};
protected readonly containerBoundsCache: ContainerBounds = {
// see typedef for details
clientSize: 0,
offsetStart: 0,
offsetEnd: 0,
trackSize: 0,
scrollSize: 0,
};
// all below options should only ever be changed by a dedicated method
protected optionsPublic!: Required; // set in modify in constructor
protected optionsPrivate!: Options.Private; // set in modify in constructor
protected currentProgress = 0;
protected intersecting?: boolean; // currently intersecting with the ViewportObserver?
private destroyed = false; // instance is destroyed and cannot be used anymore, true if destroy() was called
private enabled = true; // instance is enabled and can be used, false if disable() was called
/**
* Create a new ScrollMagic instance.
*
* @param options - Configuration for the tracked element, scroll container, offsets, and axis.
*
* @example
* ```js
* // Track vertical scroll progress for an element
* const sm = new ScrollMagic({ element: '.section', container: '#scroller' });
*
* // Horizontal scroll with custom offsets
* const sm = new ScrollMagic({
* element: '.panel',
* vertical: false,
* elementStart: '50%',
* containerStart: 'center',
* });
* ```
*/
constructor(options: Options.Public = {}) {
ScrollMagic.instances.add(this);
const initOptions: Required = {
...ScrollMagic.defaultOptionsPublic,
...options,
};
this.modify(initOptions);
}
private guardInert(): boolean {
if (this.destroyed && (typeof process === 'undefined' || process.env.NODE_ENV !== 'production')) {
console?.warn('ScrollMagic Warning: Method called on a destroyed instance.');
}
return this.destroyed || !isBrowser;
}
protected getViewportMargin(): { top: string; left: string; right: string; bottom: string } {
const { vertical } = this.optionsPrivate;
const axis = agnosticProps(vertical);
const cross = agnosticProps(!vertical);
const crossScrollSize = this.containerProxy.size[cross.scrollSize];
const crossClientSize = this.containerProxy.size[cross.clientSize];
const {
clientSize: containerSize,
offsetStart: containerOffsetStart,
offsetEnd: containerOffsetEnd,
} = this.containerBoundsCache;
const { offsetStart, offsetEnd } = this.elementBoundsCache; // from cache
const marginStart = containerSize - containerOffsetStart + offsetStart;
const marginEnd = containerSize - containerOffsetEnd + offsetEnd;
/**
** confusingly IntersectionObserver (and thus ViewportObserver) treat margins in the opposite direction (negative means towards the center)
** so we'll have to flip the signs here.
** Additionally we convert it to percentages and round, as this means they are less likely to change, meaning less refreshes for the observer
** (as the observer internally compares old values to new ones)
** This way it won't have to internally create new IntersectionObservers, just because the container's size changes.
*/
const decimals = 10;
const noSize = containerSize <= 0;
const relMarginStart = noSize ? 0 : -marginStart / containerSize;
const relMarginEnd = noSize ? 0 : -marginEnd / containerSize;
// adding available scrollspace in cross direction, so element never moves out of trackable area, even when scrolling horizontally on a vertically tracked element
const noCrossSize = crossClientSize <= 0;
const scrollableCross =
noCrossSize ? 0 : numberToPercString((crossScrollSize - crossClientSize) / crossClientSize, decimals);
return {
// the start and end values are intentionally flipped here (start value defines end margin and vice versa)
[axis.start]: numberToPercString(relMarginEnd, decimals),
[axis.end]: numberToPercString(relMarginStart, decimals),
[cross.start]: scrollableCross,
[cross.end]: scrollableCross,
} as Record<'top' | 'left' | 'bottom' | 'right', string>;
}
protected getTrackSize(): number {
return this.elementBoundsCache.trackSize + this.containerBoundsCache.trackSize;
}
// !update functions MUST NOT call any other functions causing side effects, with the exceptions of modify and event triggers in progress
protected updateIntersectingState(nextIntersecting: boolean | undefined): void {
// doesn't have to be a method, but I want to keep modifications obvious (only called from update... methods)
this.intersecting = nextIntersecting;
}
protected updateElementBoundsCache(): void {
// console.log(this.optionsPrivate.element.id, 'bounds', new Date().getMilliseconds());
// this should be called cautiously, getBoundingClientRect costs...
const { elementStart, elementEnd, element, vertical } = this.optionsPrivate;
const props = agnosticProps(vertical);
const rect = element.getBoundingClientRect();
const start = rect[props.start];
const size = rect[props.size];
this.elementBoundsCache.start = start;
// only update if size has changed, otherwise we're recalculating the offsetStart and offsetEnd for no reason
if (size !== this.elementBoundsCache.size) {
const offsetStart = elementStart(size);
const offsetEnd = elementEnd(size);
Object.assign(this.elementBoundsCache, {
size,
offsetStart,
offsetEnd,
trackSize: size - offsetStart - offsetEnd,
});
}
}
protected updateContainerBoundsCache(): void {
// console.log(this.optionsPrivate.element.id, 'container', new Date().getMilliseconds());
const { containerStart, containerEnd, vertical } = this.optionsPrivate;
const containerProps = agnosticProps(vertical);
const clientSize = this.containerProxy.size[containerProps.clientSize];
const scrollSize = this.containerProxy.size[containerProps.scrollSize];
const offsetStart = containerStart(clientSize);
const offsetEnd = containerEnd(clientSize);
Object.assign(this.containerBoundsCache, {
clientSize,
scrollSize,
offsetStart,
offsetEnd,
trackSize: -(clientSize - offsetStart - offsetEnd), // container track is inverted (start is usually below end)
});
}
protected updateProgress(): void {
// console.log(this.optionsPrivate.element.id, 'progress', new Date().getMilliseconds());
if (this.containerBoundsCache.clientSize <= 0) {
return; // container has no visible area, progress is meaningless
}
const { offsetStart: elementOffset, start: elementPosition } = this.elementBoundsCache;
const { offsetStart: containerOffset } = this.containerBoundsCache;
const containerPosition = this.containerProxy.position[agnosticProps(this.optionsPrivate.vertical).start];
const elementStart = elementPosition + elementOffset;
const containerStart = containerPosition + containerOffset;
const passed = containerStart - elementStart;
const total = this.getTrackSize();
if (total < 0) {
return; // no overlap of track and scroll distance
}
const previousProgress = this.currentProgress;
const nextProgress = Math.min(Math.max(passed / total, 0), 1); // when leaving, it will overshoot, this normalises to 0 / 1 (also when total is 0)
const deltaProgress = nextProgress - previousProgress;
if (deltaProgress === 0) {
return;
}
this.currentProgress = nextProgress;
const forward = deltaProgress > 0;
if (previousProgress === 0 || previousProgress === 1) {
this.triggerEvent(EventType.Enter, forward);
}
this.triggerEvent(EventType.Progress, forward);
if (nextProgress === 0 || nextProgress === 1) {
this.triggerEvent(EventType.Leave, forward);
}
}
protected updateViewportObserver(): void {
if (this.containerBoundsCache.clientSize <= 0) {
this.updateIntersectingState(undefined); // reset so intersection re-evaluates when container becomes visible
return;
}
const { container, vertical } = this.optionsPrivate;
const observerOptions = {
margin: this.getViewportMargin(),
root: isWindow(container) ? null : container,
vertical,
};
this.viewportObserver.modify(observerOptions);
}
protected onOptionChanges(changedOptions: Options.Public): void {
const changes = Object.keys(changedOptions) as Array;
if (changes.length === 0) {
return;
}
const isChanged = changes.includes.bind(changes);
const directionChanged = isChanged('vertical');
const elementBoundsInvalidated = directionChanged || isChanged('elementStart') || isChanged('elementEnd');
if (elementBoundsInvalidated) {
this.elementBoundsCache.size = NaN; // force converter recalculation (size guard in updateElementBoundsCache)
}
if (this.disabled) return;
const elementChanged = isChanged('element');
const containerChanged = isChanged('container');
const containerBoundsInvalidated =
containerChanged || directionChanged || isChanged('containerStart') || isChanged('containerEnd');
if (elementBoundsInvalidated || elementChanged) {
this.update.elementBounds.schedule();
}
if (elementChanged) {
this.updateIntersectingState(undefined);
const { element } = this.optionsPrivate;
this.viewportObserver.disconnect();
this.viewportObserver.observe(element);
this.resizeCleanup?.();
this.resizeCleanup = observeResize(element, this.onElementResize.bind(this));
}
if (containerBoundsInvalidated) {
this.update.containerBounds.schedule();
this.update.viewportObserver.schedule();
}
if (containerChanged) {
this.updateIntersectingState(undefined);
this.containerProxy.attach(this.optionsPrivate.container, this.onContainerUpdate.bind(this)); // container updates are already throttled
}
// if any options changes we always have to refresh the progress
this.update.progress.schedule();
}
protected onElementResize(): void {
/**
* * element resized
* updateContainerBounds => never
* updateElementBounds => schedule always (obviously), execute regardless.
* updateViewportObserver => schedule always, execute if start or end offset changed in trigger bounds update above
* updateProgress => schedule if currently intersecting, execute if bounds changed (offsets or size, since size affects trackSize)
*/
const { update } = this;
// Capture previous values for lazy condition checks.
// IMPORTANT: closures must reference `this.elementBoundsCache` (not a destructured local)
// because `updateElementBoundsCache()` replaces the entire object reference.
const { offsetStart: startPrevious, offsetEnd: endPrevious, size: sizePrevious } = this.elementBoundsCache;
const isOffsetChanged = () =>
startPrevious !== this.elementBoundsCache.offsetStart || endPrevious !== this.elementBoundsCache.offsetEnd;
const isBoundsChanged = () => isOffsetChanged() || sizePrevious !== this.elementBoundsCache.size;
update.elementBounds.schedule();
update.viewportObserver.schedule(isOffsetChanged);
if (this.intersecting) {
update.progress.schedule(isBoundsChanged);
}
}
protected onContainerUpdate(e: ContainerEvent): void {
/**
* * container resized
* updateContainerBounds => schedule always execute regardless
* updateElementBounds => schedule if currently intersecting, execute regardless (resizes are caught in onElementResize but position might change due to container resize, which wouldn't be)
* updateViewportObserver => schedule always (to get new margins), execute regardless.
* updateProgress => schedule if currently intersecting, execute if position changed in triggerBounds update
*/
const { update } = this;
if ('resize' === e.type) {
this.update.containerBounds.schedule();
if (this.intersecting) {
update.elementBounds.schedule();
}
update.viewportObserver.schedule();
const { start: startPrevious } = this.elementBoundsCache;
const { clientSize: sizePrevious } = this.containerBoundsCache;
const isChanged = () =>
startPrevious !== this.elementBoundsCache.start || sizePrevious !== this.containerBoundsCache.clientSize;
update.progress.schedule(isChanged);
return;
}
/**
* * container scrolled
* if relevant scrollDelta is 0, do nothing (scroll was in other direction)
* updateContainerBounds => never
* updateElementBounds => schedule if currently intersecting, execute regardless
* updateViewportObserver => never
* updateProgress => schedule if currently intersecting or potentially skipped, execute regardless (technically only execute if triggerBounds returned a new position, but that's implied, if there was a scoll move in the relevant direction)
*/
const scrollDelta = e.scrollDelta[agnosticProps(this.optionsPrivate.vertical).axis];
if (0 === scrollDelta) {
return; // scroll was in other direction
}
// in case the scroll position changes by more than the total track distance, the viewport observer might miss it.
// this means running the progress update more than we have to, but in this case we have no choice.
const potentiallySkipped = Math.abs(scrollDelta) > this.getTrackSize();
if (!this.intersecting && !potentiallySkipped) {
// if we're not intersecting and there's no danger we skipped the active range, we don't have to do anything...
return;
}
update.elementBounds.schedule();
update.progress.schedule();
}
protected onIntersectionChange(intersecting: boolean, target: Element): void {
// the check below should always be true, as we only ever observe one element, but you can never be too sure, I guess...
if (target === this.optionsPrivate.element) {
/**
* * intersection state changed
* updateContainerBounds => never
* updateElementBounds => schedule regardless, if intersection state changed, position likely did, too.
* updateViewportObserver => never
* updateProgress => schedule regardless, execute regardless
*/
this.updateIntersectingState(intersecting);
this.update.elementBounds.schedule();
this.update.progress.schedule();
}
}
protected triggerEvent(type: EventType, forward: boolean): void {
this.dispatcher.dispatchEvent(new ScrollMagicEvent(this, type, forward));
}
/**
* Update one or more options on this instance. Only changed values trigger internal recalculations.
*
* @param options - Partial set of public options to merge.
* @returns The instance, for chaining.
*
* @example
* ```js
* sm.modify({ elementStart: '25%', containerEnd: 100 });
* ```
*/
public modify(options: Options.Public): ScrollMagic {
if (this.guardInert()) {
return this;
}
const { sanitized, processed } = processOptions(options, this.optionsPrivate);
const changedOptions =
(
undefined === this.optionsPublic // not set on first run, so all changed
) ?
sanitized
: pickDifferencesFlat(sanitized, this.optionsPublic);
this.optionsPublic = { ...this.optionsPublic, ...changedOptions };
this.optionsPrivate = processed;
this.onOptionChanges(changedOptions);
this.plugins.forEach(plugin => plugin.onModify?.call(this, changedOptions));
return this;
}
/**
* Register a plugin on this instance. The plugin's `onAdd` callback is invoked immediately.
*
* @param plugin - The plugin to add.
* @returns The instance, for chaining.
*/
public addPlugin(plugin: Plugin): ScrollMagic {
if (this.guardInert()) {
return this;
}
this.plugins.add(plugin);
plugin.onAdd?.call(this);
return this;
}
/**
* Unregister a plugin from this instance. The plugin's `onRemove` callback is invoked immediately.
*
* @param plugin - The plugin to remove.
* @returns The instance, for chaining.
*/
public removePlugin(plugin: Plugin): ScrollMagic {
if (this.guardInert()) {
return this;
}
this.plugins.delete(plugin);
plugin.onRemove?.call(this);
return this;
}
// getter/setter public
/** Set the tracked element. Accepts an `Element`, a CSS selector, or `null` to reset. */
public set element(element: Required['element']) {
this.modify({ element });
}
/** The resolved tracked DOM element. */
public get element(): Options.Private['element'] {
return this.optionsPrivate.element;
}
/** Set the start inset on the tracked element. Positive values shrink the tracked region from the leading edge. */
public set elementStart(elementStart: Required['elementStart']) {
this.modify({ elementStart });
}
/** The current start inset value for the tracked element (as originally provided). */
public get elementStart(): Required['elementStart'] {
return this.optionsPublic.elementStart;
}
/** Set the end inset on the tracked element. Positive values shrink the tracked region from the trailing edge. */
public set elementEnd(elementEnd: Required['elementEnd']) {
this.modify({ elementEnd });
}
/** The current end inset value for the tracked element (as originally provided). */
public get elementEnd(): Required['elementEnd'] {
return this.optionsPublic.elementEnd;
}
/** Set the scroll container. Accepts a `Window`, `Element`, CSS selector, or `null` to reset. */
public set container(container: Required['container']) {
this.modify({ container });
}
/** The resolved scroll container (`Window` or `HTMLElement`). */
public get container(): Options.Private['container'] {
return this.optionsPrivate.container;
}
/** Set the start inset on the scroll container. Set to `null` to infer based on `element`. */
public set containerStart(containerStart: Required['containerStart']) {
this.modify({ containerStart });
}
/** The current start inset value for the scroll container (as originally provided). */
public get containerStart(): Required['containerStart'] {
return this.optionsPublic.containerStart;
}
/** Set the end inset on the scroll container. Set to `null` to infer based on `element`. */
public set containerEnd(containerEnd: Required['containerEnd']) {
this.modify({ containerEnd });
}
/** The current end inset value for the scroll container (as originally provided). */
public get containerEnd(): Required['containerEnd'] {
return this.optionsPublic.containerEnd;
}
/** Set the scroll axis. `true` = vertical, `false` = horizontal. */
public set vertical(vertical: Required['vertical']) {
this.modify({ vertical });
}
/** Whether this instance tracks vertical (`true`) or horizontal (`false`) scroll. */
public get vertical(): Options.Private['vertical'] {
return this.optionsPrivate.vertical;
}
/** Current scroll progress through the active zone, from 0 (before) to 1 (past). */
public get progress(): number {
return this.currentProgress;
}
/** Raw scroll velocity in pixels per second along the tracked axis (no smoothing). Returns 0 when disabled or not scrolling. */
public get scrollVelocity(): number {
if (this.disabled) {
return 0;
}
return this.containerProxy.scrollVelocity[agnosticProps(this.optionsPrivate.vertical).axis];
}
/** Returns the scroll container's scroll positions at which tracking starts and ends. Triggers a synchronous layout read (cached values when disabled). */
public get activeRange(): { start: number; end: number } {
if (this.guardInert()) {
return { start: 0, end: 0 };
}
if (!this.disabled) {
this.updateElementBoundsCache(); // need to get fresh position — skip when disabled to avoid mixing fresh element bounds with stale container bounds
}
const { container, vertical } = this.optionsPrivate;
const { start: elementPosition, offsetStart, trackSize } = this.elementBoundsCache;
const {
clientSize: containerSize,
offsetStart: containerOffsetStart,
offsetEnd: containerOffsetEnd,
} = this.containerBoundsCache;
const scrollOffset = getScrollPos(container)[agnosticProps(vertical).start];
const absolutePosition = elementPosition + scrollOffset;
const start = absolutePosition + offsetStart;
const end = start + trackSize;
return {
start: Math.floor(start - containerOffsetStart),
end: Math.ceil(end - containerSize + containerOffsetEnd),
};
}
/** Resolved pixel offsets for container zone and element boundaries, based on current layout. */
public get resolvedBounds(): Readonly {
return {
element: { ...this.elementBoundsCache },
container: { ...this.containerBoundsCache },
};
}
/** Snapshot of all currently registered plugins. */
public get pluginList(): Array {
return [...this.plugins];
}
/** Whether tracking is currently paused (via `disable()`) or the instance has been destroyed. */
public get disabled(): boolean {
return !this.enabled || this.destroyed;
}
public get [Symbol.toStringTag](): string {
return this.name;
}
/**
* Add an event listener.
*
* @param type - The event type to listen for.
* @param cb - Callback invoked with a {@link ScrollMagicEvent}.
* @param options - Optional settings. `once` auto-removes the listener after its first invocation; `signal` removes it when the given {@link AbortSignal} aborts. Both can be combined.
* @returns The instance, for chaining.
*
* @example
* ```js
* sm.on('progress', (e) => {
* console.log(e.target.progress); // 0–1
* });
*
* // Fire only once
* sm.on('enter', (e) => console.log('entered!'), { once: true });
*
* // Remove all listeners at once via AbortController
* const ac = new AbortController();
* sm.on('enter', onEnter, { signal: ac.signal });
* sm.on('leave', onLeave, { signal: ac.signal });
* // Later: ac.abort();
* ```
*/
public on(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void, options?: ListenerOptions): ScrollMagic {
if (this.guardInert()) {
return this;
}
this.dispatcher.addEventListener(type, cb, options);
return this;
}
/**
* Remove a previously registered event listener.
*
* @param type - The event type the listener was registered for.
* @param cb - The exact callback reference passed to {@link on}.
* @returns The instance, for chaining.
*/
public off(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void): ScrollMagic {
if (this.guardInert()) {
return this;
}
this.dispatcher.removeEventListener(type, cb);
return this;
}
/**
* Add an event listener and receive a disposer function to remove it.
* Unlike {@link on}, this is not chainable — it returns the unsubscribe function instead.
*
* @param type - The event type to listen for.
* @param cb - Callback invoked with a {@link ScrollMagicEvent}.
* @param options - Optional settings. `once` auto-removes the listener after its first invocation; `signal` removes it when the given {@link AbortSignal} aborts. Both can be combined.
* @returns A function that removes the listener when called.
*
* @example
* ```js
* const unsub = sm.subscribe('enter', (e) => console.log('entered!'));
* // Later:
* unsub();
* ```
*/
public subscribe(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void, options?: ListenerOptions): () => void {
if (this.guardInert()) {
return () => {};
}
return this.dispatcher.addEventListener(type, cb, options);
}
/**
* Schedule a full recalculation of element bounds, container bounds, viewport observer, and progress.
*
* @remarks Updates run asynchronously on the next animation frame.
* @returns The instance, for chaining.
*/
public refresh(): ScrollMagic {
if (this.guardInert() || this.disabled) {
return this;
}
this.update.elementBounds.schedule();
this.update.containerBounds.schedule();
this.update.viewportObserver.schedule();
this.update.progress.schedule();
return this;
}
/**
* Pause tracking — disconnects all observers and freezes progress.
* Options can still be modified while disabled.
*
* @returns The instance, for chaining.
* @see {@link enable} to resume tracking.
*
* @example
* ```js
* sm.disable();
* // progress and events are frozen
* sm.enable(); // resume
* ```
*/
public disable(): ScrollMagic {
if (this.guardInert() || this.disabled) return this;
this.enabled = false;
this.executionQueue.cancel();
this.resizeCleanup?.();
this.resizeCleanup = undefined;
this.viewportObserver.disconnect();
this.containerProxy.detach();
this.plugins.forEach(plugin => plugin.onDisable?.call(this));
return this;
}
/**
* Resume tracking — reconnects all observers and recalculates from current state.
*
* @returns The instance, for chaining.
* @see {@link disable} to pause tracking.
*
* @example
* ```js
* sm.enable();
* ```
*/
public enable(): ScrollMagic {
if (this.guardInert() || this.enabled) return this;
this.enabled = true;
const { element, container } = this.optionsPrivate;
this.updateIntersectingState(undefined);
this.viewportObserver.observe(element);
this.resizeCleanup = observeResize(element, this.onElementResize.bind(this));
this.containerProxy.attach(container, this.onContainerUpdate.bind(this));
this.elementBoundsCache.size = NaN; // force converter recalculation
this.update.elementBounds.schedule();
this.update.containerBounds.schedule();
this.update.viewportObserver.schedule();
this.update.progress.schedule();
this.plugins.forEach(plugin => plugin.onEnable?.call(this));
return this;
}
/**
* Permanently tear down this instance — disconnects all observers, removes all plugins,
* and deregisters from {@link ScrollMagic.refreshAll} / {@link ScrollMagic.destroyAll}.
* The instance cannot be used after calling this method.
*/
public destroy(): void {
if (this.destroyed || !isBrowser) {
return;
}
this.disable(); // tear down observers (no-ops if already disabled), fires onDisable
this.destroyed = true;
ScrollMagic.instances.delete(this);
this.plugins.forEach(plugin => plugin.onDestroy?.call(this));
this.plugins.clear();
}
// static options/methods
private static readonly instances = new Set();
/** Schedule a full recalculation on all active ScrollMagic instances. */
public static refreshAll(): void {
ScrollMagic.instances.forEach(instance => instance.refresh());
}
/** Destroy all active ScrollMagic instances. */
public static destroyAll(): void {
ScrollMagic.instances.forEach(instance => instance.destroy());
}
protected static defaultOptionsPublic = Options.defaults;
/**
* Get or update the default options applied to all future ScrollMagic instances.
*
* @param options - Partial options to merge into the current defaults.
* @returns The full set of current default options.
*
* @example
* ```js
* // Set a global default container
* ScrollMagic.defaultOptions({ container: '#main-scroller' });
*
* // Read current defaults
* const defaults = ScrollMagic.defaultOptions();
* ```
*/
public static defaultOptions(options: Options.Public = {}): Required {
this.defaultOptionsPublic = {
...this.defaultOptionsPublic,
...sanitizeOptions(options),
};
return this.defaultOptionsPublic;
}
/** Enum of event types: `Enter`, `Leave`, `Progress`. @see {@link EventType} */
public static readonly EventType = EventType;
/** Enum of event locations: `Start`, `Inside`, `End`. @see {@link EventLocation} */
public static readonly EventLocation = EventLocation;
/** Enum of scroll directions: `Forward`, `Reverse`. @see {@link ScrollDirection} */
public static readonly EventScrollDirection = ScrollDirection;
}
================================================
FILE: src/ScrollMagicError.ts
================================================
/** Base error class for all ScrollMagic errors. */
export class ScrollMagicError extends Error {
public override readonly name: string = 'ScrollMagicError';
public get [Symbol.toStringTag]() {
return this.name;
}
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
/** Error class for unexpected internal failures — indicates a bug in ScrollMagic itself. */
export class ScrollMagicInternalError extends ScrollMagicError {
public override readonly name = 'ScrollMagicInternalError';
constructor(message: string, options?: ErrorOptions) {
super(`Internal Error: ${message}`, options);
}
}
================================================
FILE: src/ScrollMagicEvent.ts
================================================
import type { DispatchableEvent } from './EventDispatcher';
import type { ScrollMagic } from './ScrollMagic';
/** Lifecycle event types dispatched by a ScrollMagic instance. */
export enum EventType {
/** Fired when the element enters the active scroll zone. */
Enter = 'enter',
/** Fired when the element leaves the active scroll zone. */
Leave = 'leave',
/** Fired on every scroll update while the element is inside the active zone. */
Progress = 'progress',
}
/** Where the event occurred relative to the active scroll zone. */
export enum EventLocation {
/** At the leading edge of the zone (entering forward or leaving in reverse). */
Start = 'start',
/** Between the start and end edges. */
Inside = 'inside',
/** At the trailing edge of the zone (entering in reverse or leaving forward). */
End = 'end',
}
/** Scroll direction at the time the event was dispatched. */
export enum ScrollDirection {
/** Scrolling toward higher scroll offsets (down or right). */
Forward = 'forward',
/** Scrolling toward lower scroll offsets (up or left). */
Reverse = 'reverse',
}
type EnumOrLiteral = T | `${T}`;
type ScrollMagicEventType = EnumOrLiteral;
type ScrollMagicEventLocation = EnumOrLiteral;
type ScrollMagicEventScrollDirection = EnumOrLiteral;
/**
* Event object dispatched by ScrollMagic on lifecycle transitions and progress updates.
*
* Instances are created internally and passed to listeners registered via {@link ScrollMagic.on} or {@link ScrollMagic.subscribe}.
*/
export class ScrollMagicEvent implements DispatchableEvent {
/** Where the event occurred relative to the active zone (`'start'`, `'inside'`, or `'end'`). */
public readonly location: ScrollMagicEventLocation;
/** Scroll direction at the time of the event (`'forward'` or `'reverse'`). */
public readonly direction: ScrollMagicEventScrollDirection;
constructor(
/** The ScrollMagic instance that dispatched this event. */
public readonly target: ScrollMagic,
/** The event type (`'enter'`, `'leave'`, or `'progress'`). */
public readonly type: ScrollMagicEventType,
movingForward: boolean
) {
this.location = (() => {
if (EventType.Progress === type) {
return EventLocation.Inside;
}
if ((EventType.Enter === type && movingForward) || (EventType.Leave === type && !movingForward)) {
return EventLocation.Start;
}
return EventLocation.End;
})();
this.direction = movingForward ? ScrollDirection.Forward : ScrollDirection.Reverse;
}
}
================================================
FILE: src/ViewportObserver.ts
================================================
import { pickDifferencesFlat } from './util/pickDifferencesFlat';
type Margin = {
top: string;
right: string;
bottom: string;
left: string;
};
interface Options {
root?: Element | null; // null is window
margin?: Margin;
vertical?: boolean;
}
type ObserverCallback = (isIntersecting: boolean, target: Element) => void;
// this ensures the order in the object doesn't matter
const marginObjToString = ({ top, right, bottom, left }: Margin) => [top, right, bottom, left].join(' ');
const none = '0px';
// resolves the combined state of enter/leave observers into a single boolean or undefined (if not yet fully initialized)
const resolveState = (hitEnter: boolean | undefined, hitLeave: boolean | undefined): boolean | undefined => {
if (hitEnter === undefined || hitLeave === undefined) return undefined;
return hitEnter && hitLeave;
};
export class ViewportObserver {
private observerEnter?: IntersectionObserver;
private observerLeave?: IntersectionObserver;
private options: Required = {
root: null,
margin: { top: none, right: none, bottom: none, left: none },
vertical: true,
};
private observedElements = new Map();
constructor(
private callback: ObserverCallback,
options?: Options
) {
if (undefined === options) {
return; // nothing will happen, until modify is called.
}
this.options = {
...this.options,
...options,
};
}
private observerCallback(entries: IntersectionObserverEntry[], observer: IntersectionObserver) {
entries.forEach(({ target, isIntersecting }) => {
let [hitEnter, hitLeave] = this.observedElements.get(target) ?? [];
const prevState = resolveState(hitEnter, hitLeave);
if (observer === this.observerEnter) {
hitEnter = isIntersecting;
} else {
hitLeave = isIntersecting;
}
this.observedElements.set(target, [hitEnter, hitLeave]);
const newState = resolveState(hitEnter, hitLeave);
if (undefined === newState || prevState === newState) {
return;
}
this.callback(newState, target);
});
}
private createObserver(rootMargin: string) {
const root = this.options.root;
const observer = new IntersectionObserver(this.observerCallback.bind(this), { root, rootMargin });
[...this.observedElements.keys()].forEach(elem => observer.observe(elem));
return observer;
}
private rebuildObserver() {
this.observerEnter?.disconnect();
this.observerLeave?.disconnect();
const { margin, vertical } = this.options;
const clampPositive = (val: string) => `${Math.max(0, parseFloat(val))}%`;
// The enter observer clips the "leave side" margin to >= 0 (so it doesn't shrink the viewport on that side).
// The leave observer clips the "enter side" margin to >= 0.
// For vertical: leave side = top, enter side = bottom.
// For horizontal: leave side = left, enter side = right.
// TODO: check what happens, if the opposite value still overlaps (due to offset / height ?)
// TODO! I know now: if effective duration exceeds available observer height it fails... -> BUG! -> FIX...
const marginEnter =
vertical ? { ...margin, top: clampPositive(margin.top) } : { ...margin, left: clampPositive(margin.left) };
const marginLeave =
vertical ?
{ ...margin, bottom: clampPositive(margin.bottom) }
: { ...margin, right: clampPositive(margin.right) };
this.observerEnter = this.createObserver(marginObjToString(marginEnter));
this.observerLeave = this.createObserver(marginObjToString(marginLeave));
}
private optionsChanged({ root, margin, vertical }: Options) {
if (undefined !== root && root !== this.options.root) {
return true;
}
if (undefined !== vertical && vertical !== this.options.vertical) {
return true;
}
if (undefined !== margin) {
return Object.keys(pickDifferencesFlat(margin, this.options.margin)).length > 0;
}
return false;
}
public modify(options: Options): ViewportObserver {
if (!this.optionsChanged(options)) {
return this;
}
this.options = {
...this.options,
...options,
};
this.rebuildObserver();
return this;
}
public observe(elem: Element): ViewportObserver {
if (!this.observedElements.has(elem)) {
this.observedElements.set(elem, [undefined, undefined]);
this.observerEnter?.observe(elem);
this.observerLeave?.observe(elem);
}
return this;
}
public unobserve(elem: Element): ViewportObserver {
if (this.observedElements.delete(elem)) {
this.observerEnter?.unobserve(elem);
this.observerLeave?.unobserve(elem);
}
return this;
}
public disconnect(): void {
this.observedElements.clear();
this.observerEnter?.disconnect();
this.observerLeave?.disconnect();
}
}
================================================
FILE: src/env.d.ts
================================================
// Ambient type for process.env.NODE_ENV — used for dev-only warnings.
// Bundlers replace process.env.NODE_ENV at build time; no Node.js dependency needed.
declare const process: undefined | { env: { NODE_ENV?: string } };
================================================
FILE: src/index.ts
================================================
import type { Public as ScrollMagicOptions } from './Options';
import type { Plugin as ScrollMagicPlugin } from './ScrollMagic';
import { EventLocation, EventType, ScrollDirection } from './ScrollMagicEvent';
// make literals from enums for export
type EventTypeLiteral = `${EventType}`;
type EventLocationLiteral = `${EventLocation}`;
type ScrollDirectionLiteral = `${ScrollDirection}`;
export { ScrollMagic as default } from './ScrollMagic';
// relevant types
export type { ScrollMagicError } from './ScrollMagicError';
export type { ScrollMagicEvent } from './ScrollMagicEvent';
export type { ListenerOptions } from './EventDispatcher';
export type { ScrollMagicPlugin };
export type { ScrollMagicOptions };
export type { ElementBounds, ContainerBounds, ResolvedBounds } from './ScrollMagic';
// less relevant enum types as literals
export type {
EventTypeLiteral as EventType,
EventLocationLiteral as EventLocation,
ScrollDirectionLiteral as ScrollDirection,
};
================================================
FILE: src/util/agnosticValues.ts
================================================
import { transformObject } from './transformObject';
// { agnosticProp: [verticalProp, horizontalProp] }
const translationMap = {
start: ['top', 'left'],
end: ['bottom', 'right'],
size: ['height', 'width'],
clientSize: ['clientHeight', 'clientWidth'],
scrollSize: ['scrollHeight', 'scrollWidth'],
axis: ['y', 'x'],
} as const;
type TranslationMap = typeof translationMap;
type AgnosticProps = keyof TranslationMap;
type TranslateProp = TranslationMap[K][V extends true ? 0 : 1];
type Vertical = { [K in AgnosticProps]: TranslateProp };
type Horizontal = { [K in AgnosticProps]: TranslateProp };
// cache props
const flat = (index: number) => transformObject(translationMap, ([key, value]) => [key, value[index]]);
const propsV = flat(0) as Vertical;
const propsH = flat(1) as Horizontal;
/**
* Returns a map of direction-agnostic property names to their orientation-specific counterparts.
* @param vertical - Scroll direction (`true` = vertical, `false` = horizontal).
* @returns A mapping like `{ start: 'top', size: 'height', ... }` for vertical, or `{ start: 'left', size: 'width', ... }` for horizontal.
*/
export function agnosticProps(vertical: true): Vertical;
export function agnosticProps(vertical: false): Horizontal;
export function agnosticProps(vertical: boolean): Vertical | Horizontal;
export function agnosticProps(vertical: boolean): Vertical | Horizontal {
return vertical ? propsV : propsH;
}
type MatchProp> = K extends keyof T ? T[K] : never;
type GetType> = {
[K in AgnosticProps]: MatchProp, T>;
};
/**
* Extracts direction-relevant values from an object using orientation-aware property names.
*
* For vertical: `top` → `start`, `height` → `size`, `y` → `axis`, etc.
* For horizontal: `left` → `start`, `width` → `size`, `x` → `axis`, etc.
*
* @param vertical - Scroll direction (`true` = vertical, `false` = horizontal).
* @param obj - Source object to extract values from (e.g. a `DOMRect` or scroll delta).
* @returns An object with agnostic keys (`start`, `end`, `size`, `clientSize`, `scrollSize`, `axis`) mapped to the corresponding values.
*/
export const agnosticValues = (
vertical: V,
obj: T
): GetType => transformObject(agnosticProps(vertical), ([key, value]) => [key, obj[value]]);
================================================
FILE: src/util/getScrollContainerDimensions.ts
================================================
import { isWindow } from './typeguards';
interface Dimensions {
readonly clientWidth: number;
readonly clientHeight: number;
readonly scrollWidth: number;
readonly scrollHeight: number;
}
// info limited to what we need...
export const getScrollContainerDimensions = (element: Window | Element): Dimensions => {
const elem = isWindow(element) ? document.documentElement : element;
const { clientWidth, scrollHeight, scrollWidth } = elem;
let { clientHeight } = elem;
if (isWindow(element) && null != window.visualViewport) {
// visualViewport.height accounts for mobile browser chrome (address bar show/hide)
// multiplying by scale compensates for pinch-zoom, giving us the layout viewport height
clientHeight = window.visualViewport.height * window.visualViewport.scale;
}
return {
clientWidth,
clientHeight,
scrollHeight,
scrollWidth,
};
};
================================================
FILE: src/util/getScrollPos.ts
================================================
import { isWindow } from './typeguards';
const scrollTop = (container: Window | Element): number =>
isWindow(container) ? (window.scrollY ?? window.pageYOffset) : container.scrollTop;
const scrollLeft = (container: Window | Element): number =>
isWindow(container) ? (window.scrollX ?? window.pageXOffset) : container.scrollLeft;
export const getScrollPos = (container: Window | Element): { left: number; top: number } => ({
left: scrollLeft(container),
top: scrollTop(container),
});
================================================
FILE: src/util/pickDifferencesFlat.ts
================================================
// checks an object against a reference object and returns a new object containing only differences in direct descendents (one way!)
export const pickDifferencesFlat = >(part: Partial, full: T): Partial =>
Object.fromEntries(Object.entries(part).filter(([key, value]) => value !== full[key])) as Partial;
================================================
FILE: src/util/processProperties.ts
================================================
import { ScrollMagicError } from '../ScrollMagicError';
// type to ensure there's an output processor for every input
export type PropertyProcessors = {
[X in keyof I]: (value: Required[X]) => O[X];
};
/**
* A function that can be used to validate the properties of an object based on predefined rules.
* @param obj the object that should be processed
* @param processors an object with matching keys, which defines how to normalize and or validate a property
* @param getErrorMessage A function that returns the format for the error message, should normalize or check fail.
* @returns the normalized and checked object
*/
export const processProperties = <
I extends { [X in keyof I]: any },
P extends { [X in K]?: (value: Required[X]) => any },
O extends { [X in K]: P[X] extends (...args: any[]) => infer R ? R : I[X] },
K extends keyof I,
>(
obj: I,
processors: P,
getErrorMessage: (value: unknown, prop: keyof I) => string = (value, prop) =>
`Invalid value ${String(value)} for ${String(prop)}.`
): O => {
return Object.keys(obj).reduce((result, key) => {
const prop = key as K;
const value = obj[prop];
const processor = processors[prop];
let processedValue: O[K];
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- generic processor output is intentionally any
processedValue = processor?.(value) ?? value;
} catch (e: unknown) {
const reason = e instanceof ScrollMagicError ? ` ${e.message}` : '';
throw new ScrollMagicError(getErrorMessage(value, prop) + reason, { cause: e });
}
result[prop] = processedValue;
return result;
}, {} as O);
};
================================================
FILE: src/util/rafQueue.ts
================================================
type Flushable = { execute(): void };
/**
* Batches execution of multiple Flushable items into a single requestAnimationFrame.
*
* Items marked dirty via `schedule()` are collected in a Set (deduped) and executed
* together — either when the rAF fires or when `flush()` is called explicitly.
*
* In the hot path (scroll/resize), callers invoke `flush()` directly after dispatching
* events, so all downstream work executes in the same frame. The rAF serves as a
* fallback for work scheduled outside of event dispatch (e.g. initial setup, programmatic
* option changes).
*/
class RafQueue {
private dirty = new Set();
private rafId = 0;
/** Mark an item for execution. Requests a rAF if none is pending. */
schedule(item: Flushable): void {
this.dirty.add(item);
if (0 === this.rafId) {
this.rafId = requestAnimationFrame(() => {
this.rafId = 0;
this.flush();
});
}
}
/** Remove an item from the dirty set, preventing its execution. */
unschedule(item: Flushable): void {
this.dirty.delete(item);
}
/** Execute all dirty items immediately and cancel any pending rAF. */
flush(): void {
if (0 !== this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
const items = [...this.dirty];
this.dirty.clear();
items.forEach(item => item.execute());
}
}
export const rafQueue = new RafQueue();
================================================
FILE: src/util/registerEvent.ts
================================================
/**
* Adds the passed listener as an event listener to the passed event target, and returns a function which reverses the
* effect of this function.
* @param {*} target object the listener should be attached to
* @param {*} type type of listener
* @param {*} listener callback
* @param {*} options Event listener options
*/
export const registerEvent = (
target: GlobalEventHandlers,
type: keyof (GlobalEventHandlersEventMap & WindowEventMap), // this does not catch if the wrong event is used on the wrong target, but should be stricter than 'string'
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): (() => void) => {
target.addEventListener(type, listener, options);
return target.removeEventListener.bind(target, type, listener, options);
};
================================================
FILE: src/util/sanitizeProperties.ts
================================================
export const sanitizeProperties = >(obj: T, defaults: Record): T =>
Object.entries(obj).reduce((res, [key, value]) => {
if (key in defaults === false) {
if (typeof process === 'undefined' || process.env.NODE_ENV !== 'production') {
console?.warn(`ScrollMagic Warning: Unknown property ${key} will be disregarded`);
}
return res;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- value from Object.entries of generic Record
res[key as keyof T] = value;
return res;
}, {} as T);
================================================
FILE: src/util/sharedResizeObserver.ts
================================================
/**
* Shared ResizeObserver — uses a single observer instance for all elements,
* routing entries to per-element callback sets via a WeakMap.
*
* After all callbacks for a batch of entries have fired, the RafQueue is
* flushed so downstream work (ExecutionQueues) executes in the same frame.
*
* Safe to call in non-browser environments — returns a no-op cleanup if
* ResizeObserver is unavailable.
*/
import { rafQueue } from './rafQueue';
type ResizeCallback = () => void;
const callbacks = new WeakMap>();
let observer: ResizeObserver | undefined; // undefined = not yet created, vs null from getObserver = unavailable
const handleResize: ResizeObserverCallback = entries => {
// Collect all affected callbacks first, then fire — avoids issues if a
// callback modifies the callback set (e.g. by calling observeResize/cleanup).
const affected = new Set();
for (const entry of entries) {
callbacks.get(entry.target)?.forEach(cb => affected.add(cb));
}
affected.forEach(cb => cb());
rafQueue.flush();
};
const noop = () => {};
/** Returns the shared observer, or null if ResizeObserver is unavailable (SSR). */
const getObserver = (): ResizeObserver | null => {
if ('undefined' === typeof ResizeObserver) return null;
if (undefined === observer) {
observer = new ResizeObserver(handleResize);
}
return observer;
};
/**
* Observe an element for resize. Returns a cleanup function that removes the
* callback and unobserves the element when no callbacks remain.
*/
export const observeResize = (element: Element, callback: ResizeCallback): (() => void) => {
const obs = getObserver();
if (null === obs) return noop;
let cbs = callbacks.get(element);
if (undefined === cbs) {
cbs = new Set();
callbacks.set(element, cbs);
obs.observe(element);
}
cbs.add(callback);
return () => {
const cbs = callbacks.get(element);
if (undefined === cbs) return;
cbs.delete(callback);
if (0 === cbs.size) {
callbacks.delete(element);
observer?.unobserve(element);
}
};
};
================================================
FILE: src/util/throttleRaf.ts
================================================
export const throttleRaf = any>(
func: F
): ((this: ThisParameterType, ...args: Parameters) => void) & {
cancel: () => void;
} => {
let requestId = 0; // rAF returns non-zero values, so 0 represents no request pending
const scheduled = function (this: ThisParameterType, ...args: Parameters) {
if (0 !== requestId) {
return;
}
requestId = requestAnimationFrame(() => {
requestId = 0;
func.apply(this, args);
});
};
scheduled.cancel = () => {
cancelAnimationFrame(requestId);
requestId = 0;
};
return scheduled;
};
================================================
FILE: src/util/transformObject.ts
================================================
/**
* Type-safe `Object.fromEntries(Object.entries(obj).map(fn))`.
* The generics preserve key/value types through the transformation, avoiding manual casts at call sites.
*/
export function transformObject<
T extends Record,
R extends [key: string | number | symbol, value: unknown],
>(object: T, transform: (entry: [key: keyof T, value: T[keyof T]]) => R) {
return Object.fromEntries(
Object.entries(object as Record).map(transform)
) as Record;
}
================================================
FILE: src/util/transformers.ts
================================================
import { positionShorthands } from '../Options';
import { ScrollMagicError } from '../ScrollMagicError';
import { isHTMLElement, isSVGElement, isWindow } from './typeguards';
type PixelConverter = (size: number) => number;
type UnitTuple = [number, 'px' | '%'];
export const numberToPercString = (val: number, decimals: number): string => `${(val * 100).toFixed(decimals)}%`;
const unitTupleToPixelConverter = ([value, unit]: UnitTuple): PixelConverter => {
return 'px' === unit || 0 === value ? () => value : (size: number) => (value / 100) * size;
};
export const unitStringToPixelConverter = (val: string): PixelConverter => {
const match = val.match(/^([+-])?(\d+|\d*[.]\d+)(%|px)$/);
if (null === match) {
const names = Object.keys(positionShorthands).join(', ');
throw new ScrollMagicError(
`String value must be a number with unit (e.g. 20px, 80%) or a named position (${names})`
);
}
const [, sign, digits, unit] = match as [string, '+' | '-' | null, string, 'px' | '%'];
return unitTupleToPixelConverter([parseFloat(`${sign ?? ''}${digits}`), unit]);
};
export const toPixelConverter = (val: number | string | PixelConverter): PixelConverter => {
if ('number' === typeof val) {
return () => val;
}
if ('string' === typeof val) {
const unitString = val in positionShorthands ? positionShorthands[val as keyof typeof positionShorthands] : val;
return unitStringToPixelConverter(unitString);
}
// ok, user passed in a function, let's see if it works.
let returnsNumber: boolean;
try {
returnsNumber = 'number' === typeof val(1);
} catch {
throw new ScrollMagicError('Unsupported value type');
}
if (!returnsNumber) {
throw new ScrollMagicError('Function must return a number');
}
return val;
};
export const selectorToSingleElement = (selector: string): Element => {
const elem = document.querySelector(selector);
if (null === elem) {
throw new ScrollMagicError(`No element found for selector ${selector}`);
}
if (typeof process === 'undefined' || process.env.NODE_ENV !== 'production') {
const all = document.querySelectorAll(selector);
if (all.length > 1) {
console?.warn(
`ScrollMagic Warning: Selector "${selector}" matched ${all.length} elements, using only the first. Create one ScrollMagic instance per element to track all of them.`
);
}
}
return elem;
};
export const toSvgOrHtmlElement = (reference: Element | string): HTMLElement | SVGElement => {
const elem = 'string' === typeof reference ? selectorToSingleElement(reference) : reference;
const { body } = document;
if (!(isHTMLElement(elem) || isSVGElement(elem)) || !body.contains(elem)) {
throw new ScrollMagicError('Invalid element supplied');
}
return elem;
};
export const toValidContainer = (container: Window | Element | string): HTMLElement | Window => {
if (isWindow(container)) {
return container;
}
const elem = toSvgOrHtmlElement(container);
if (isSVGElement(elem)) {
throw new ScrollMagicError(`Can't use SVG as container`);
}
return elem;
};
/** Wraps a function to pass `null` through without calling it. */
export const skipNull =
(fn: (val: T) => R) =>
(val: T | null): R | null =>
null === val ? null : fn(val);
================================================
FILE: src/util/typeguards.ts
================================================
export const isWindow = (val: unknown): val is Window => val instanceof Window;
export const isHTMLElement = (val: unknown): val is HTMLElement => val instanceof HTMLElement;
export const isSVGElement = (val: unknown): val is SVGElement => val instanceof SVGElement;
================================================
FILE: src/util.ts
================================================
export { agnosticProps, agnosticValues } from './util/agnosticValues';
================================================
FILE: tests/e2e/UNTESTED-KNOWN-BUGS.md
================================================
# Untested Known Bugs
Documentation for potential v3 issues derived from v2 bug reports that cannot be covered by automated e2e tests.
Testable cases are covered in `reported-issues.test.ts`.
## Require Real Mobile Devices
| Issue | Concern | Why untestable |
| ----- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| #789 | iOS Safari momentum scroll may not fire IO callbacks reliably during fast flings | Needs real iOS device; Playwright chromium can't reproduce momentum scroll physics |
| #479 | iOS momentum scrolling + toolbar resize compounding viewport measurement issues | Same as #789 — real iOS Safari + physical momentum scrolling required |
| #381 | Android virtual keyboard popup resizes viewport, may cause unexpected IO recalculations | Can't simulate keyboard appearance in headless/automated browsers |
## Require External Libraries or Specific Browser Features
| Issue | Concern | Why untestable |
| ----- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| #652 | Smooth scrollbar libraries using CSS transforms (no native scroll) bypass both IO and scroll events entirely | Known architectural limitation — transform-based scrolling is fundamentally incompatible with IO. Would need an actual smooth-scroll library to test. |
| #470 | Shadow DOM containers — IO `root` compatibility varies by browser | Requires cross-browser testing (Shadow DOM IO root support is inconsistent). Single-browser Playwright test wouldn't catch the real issue. |
## Not Assertable in Automated Tests
| Issue | Concern | Why untestable |
| ----- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| #817 | Sub-pixel rendering differences across browsers may cause jitter in progress-driven transform animations | Visual/rendering concern — no pass/fail assertion possible. Users applying scroll-driven transforms should use `Math.round()` or `will-change`. |
| #503 | Background tab throttling: rAF-driven progress events stall when tab is suspended, may cause state jumps on tab refocus | Can't suspend own tab programmatically. Inherent browser behavior outside library control. |
================================================
FILE: tests/e2e/caching.test.ts
================================================
/**
* PixelConverter caching and bounds invalidation.
* Tests for: converters not re-called on scroll (only on resize), modify() forcing recalculation,
* direction changes invalidating caches, stale containerBoundsCache after container option changes.
*/
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow, wait, waitForFrames } from './helpers';
describe('PixelConverter caching', () => {
afterEach(cleanup);
test('elementStart/elementEnd are not called on scroll when element size is unchanged', async () => {
await page.viewport(1024, 768);
// elementTop=300, height=200 — element is visible initially, stays intersecting as we scroll
const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });
let elementStartCalls = 0;
let elementEndCalls = 0;
const sm = new ScrollMagic({
element: target,
elementStart: () => {
elementStartCalls++;
return 0;
},
elementEnd: () => {
elementEndCalls++;
return 0;
},
});
await waitForFrames();
const callsAfterInit = elementStartCalls + elementEndCalls;
// scroll while element remains intersecting (no resize)
window.scrollTo(0, 100);
await waitForFrames();
window.scrollTo(0, 200);
await waitForFrames();
window.scrollTo(0, 250);
await waitForFrames();
expect(elementStartCalls + elementEndCalls).toBe(callsAfterInit);
sm.destroy();
});
test('elementStart/elementEnd are called when element resizes', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });
let elementStartCalls = 0;
const sm = new ScrollMagic({
element: target,
elementStart: () => {
elementStartCalls++;
return 0;
},
});
await waitForFrames();
const callsAfterInit = elementStartCalls;
target.style.height = '400px';
await waitForFrames();
expect(elementStartCalls).toBeGreaterThan(callsAfterInit);
sm.destroy();
});
test('containerStart/containerEnd are not called on scroll', async () => {
await page.viewport(1024, 768);
// element taller than viewport so containerStart/End returning 0 doesn't cause a no-overlap warning
const { target } = setupWindow({ elementTop: 300, elementHeight: 900 });
let containerStartCalls = 0;
let containerEndCalls = 0;
const sm = new ScrollMagic({
element: target,
containerStart: () => {
containerStartCalls++;
return 0;
},
containerEnd: () => {
containerEndCalls++;
return 0;
},
});
await waitForFrames();
const callsAfterInit = containerStartCalls + containerEndCalls;
window.scrollTo(0, 100);
await waitForFrames();
window.scrollTo(0, 200);
await waitForFrames();
window.scrollTo(0, 300);
await waitForFrames();
expect(containerStartCalls + containerEndCalls).toBe(callsAfterInit);
sm.destroy();
});
test('containerStart/containerEnd are called when container resizes', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 300 });
let containerStartCalls = 0;
const sm = new ScrollMagic({
element: target,
containerStart: () => {
containerStartCalls++;
return 0;
},
});
await waitForFrames();
const callsAfterInit = containerStartCalls;
await page.viewport(1024, 500);
await wait(50); // give ResizeObserver a moment to fire
await waitForFrames();
expect(containerStartCalls).toBeGreaterThan(callsAfterInit);
sm.destroy();
});
test('elementStart/elementEnd are re-called after modify() even if element size is unchanged', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });
const sm = new ScrollMagic({ element: target });
await waitForFrames();
let newConverterCalls = 0;
sm.modify({
elementStart: size => {
newConverterCalls++;
return size * 0.1;
},
});
await waitForFrames();
expect(newConverterCalls).toBeGreaterThan(0);
sm.destroy();
});
test('elementBounds are recalculated when direction changes via modify()', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });
let elementStartCalls = 0;
const sm = new ScrollMagic({
element: target,
elementStart: () => {
elementStartCalls++;
return 0;
},
});
await waitForFrames();
const callsAfterInit = elementStartCalls;
sm.modify({ vertical: false });
await waitForFrames();
expect(elementStartCalls).toBeGreaterThan(callsAfterInit);
sm.destroy();
});
test('containerBounds are recalculated when direction changes via modify()', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });
let containerStartCalls = 0;
const sm = new ScrollMagic({
element: target,
containerStart: () => {
containerStartCalls++;
return 0;
},
});
await waitForFrames();
const callsAfterInit = containerStartCalls;
sm.modify({ vertical: false });
await waitForFrames();
expect(containerStartCalls).toBeGreaterThan(callsAfterInit);
sm.destroy();
});
test('containerStart/containerEnd take effect after modify() — stale containerBoundsCache', async () => {
// Bug: containerBounds was not rescheduled when container options changed via modify(),
// leaving stale offsetStart/offsetEnd in the cache.
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });
const sm = new ScrollMagic({ element: target, containerStart: '0%' });
window.scrollTo(0, 200);
await waitForFrames();
const progressBefore = sm.progress;
sm.modify({ containerStart: '50%' });
await waitForFrames();
const progressAfter = sm.progress;
expect(progressAfter).not.toBe(progressBefore);
sm.destroy();
});
});
================================================
FILE: tests/e2e/containers.test.ts
================================================
/**
* Scroll container behavior: non-window scroll parents, container edge cases, viewport resize.
* Tests for: div containers, position:fixed containers, container position initialization,
* zero-size containers, viewport resize (mobile address bar).
*/
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import type { ScrollMagicEvent } from '../../src/index';
import { cleanup, setupContainer, setupWindow, wait, waitForFrames } from './helpers';
// #905, #1004: Non-window scroll containers with overflow:scroll.
describe('non-window scroll containers', () => {
afterEach(cleanup);
test('enter/leave/progress events fire in scrollable div container', async () => {
await page.viewport(1024, 768);
const { container, target } = setupContainer();
const events: string[] = [];
const sm = new ScrollMagic({ element: target, container });
sm.on('enter', () => events.push('enter'));
sm.on('progress', () => events.push('progress'));
sm.on('leave', () => events.push('leave'));
container.scrollTop = 700;
await waitForFrames(3);
expect(events).toContain('enter');
expect(sm.progress).toBeGreaterThan(0);
container.scrollTop = 1500;
await waitForFrames(3);
expect(events).toContain('leave');
expect(sm.progress).toBe(1);
sm.destroy();
});
test('scroll direction is correct in non-window container', async () => {
await page.viewport(1024, 768);
const { container, target } = setupContainer();
const directions: Array<{ type: string; direction: string }> = [];
const sm = new ScrollMagic({ element: target, container });
sm.on('enter', (e: ScrollMagicEvent) => directions.push({ type: 'enter', direction: e.direction }));
sm.on('leave', (e: ScrollMagicEvent) => directions.push({ type: 'leave', direction: e.direction }));
// Scroll forward past element
container.scrollTop = 1500;
await waitForFrames(3);
const forwardEnter = directions.find(d => 'enter' === d.type && 'forward' === d.direction);
const forwardLeave = directions.find(d => 'leave' === d.type && 'forward' === d.direction);
expect(forwardEnter).toBeDefined();
expect(forwardLeave).toBeDefined();
directions.length = 0;
// Scroll backward past element
container.scrollTop = 0;
await waitForFrames(3);
const reverseEnter = directions.find(d => 'enter' === d.type && 'reverse' === d.direction);
const reverseLeave = directions.find(d => 'leave' === d.type && 'reverse' === d.direction);
expect(reverseEnter).toBeDefined();
expect(reverseLeave).toBeDefined();
sm.destroy();
});
// #905: position:fixed containers as IO root.
test('works with position:fixed scroll container', async () => {
await page.viewport(1024, 768);
const { container, target } = setupContainer({
containerCss: {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
},
});
const sm = new ScrollMagic({ element: target, container });
container.scrollTop = 700;
await waitForFrames(3);
expect(sm.progress).toBeGreaterThan(0);
container.scrollTop = 1500;
await waitForFrames(3);
expect(sm.progress).toBe(1);
container.scrollTop = 0;
await waitForFrames(3);
expect(sm.progress).toBe(0);
sm.destroy();
});
});
// positionCache for non-window containers was initialized to {top:0,left:0} and only updated on
// window scroll/resize events (via subscribeMove). For a container offset from the viewport top,
// the initial progress calculation used containerPosition=0 instead of the actual position.
describe('container position initialization', () => {
afterEach(cleanup);
test('correct initial progress when container is offset from viewport top', async () => {
await page.viewport(1024, 768);
document.body.style.margin = '0';
document.body.style.padding = '0';
// Push the container down so it's not at y=0
const spacer = document.createElement('div');
spacer.style.height = '300px';
document.body.appendChild(spacer);
// Container is now at y=300, height=400; element at contentTop=800, height=100
const { container, target } = setupContainer({ elementTop: 800, elementHeight: 100 });
const sm = new ScrollMagic({ element: target, container });
await waitForFrames(3); // let initialization settle
// Scroll without triggering any window scroll/resize (which would fix positionCache via subscribeMove)
container.scrollTop = 600;
await waitForFrames(5);
// With fix: containerPosition=300, containerStart=700, elementStart=500, passed=200, progress=0.4
// Without fix: containerPosition=0, containerStart=400, elementStart=500, passed=-100, progress=0
expect(sm.progress).toBeGreaterThan(0);
sm.destroy();
});
});
// When container clientSize is 0 (hidden/collapsed), updateProgress() would compute wrong values:
// containerOffset collapsed to 0 and the calculation produced a different (incorrect) progress,
// firing spurious events. updateViewportObserver() also passed broken 0% margins to the observer.
describe('zero-size scroll container', () => {
afterEach(cleanup);
test('no events fire and progress stays frozen when container height becomes zero', async () => {
await page.viewport(1024, 768);
const { container, target } = setupContainer({ elementTop: 800, elementHeight: 100 });
const sm = new ScrollMagic({ element: target, container });
await waitForFrames(3);
container.scrollTop = 850;
await waitForFrames(5);
const progressBefore = sm.progress;
expect(progressBefore).toBeGreaterThan(0); // sanity check
const events: string[] = [];
sm.on('enter', () => events.push('enter'));
sm.on('leave', () => events.push('leave'));
sm.on('progress', () => events.push('progress'));
// Collapse the container
container.style.height = '0px';
await wait(50); // allow ResizeObserver to fire
await waitForFrames(5);
// Without fix: updateProgress() ran with containerSize=0, computed a different value
// and fired a spurious 'progress' event.
expect(Number.isNaN(sm.progress)).toBe(false);
expect(isFinite(sm.progress)).toBe(true);
expect(events).toHaveLength(0);
expect(sm.progress).toBe(progressBefore);
sm.destroy();
});
});
// #883, #372: Mobile address bar show/hide changes viewport dimensions.
// Tested by shrinking the viewport programmatically (simulates address bar appearing).
describe('viewport resize', () => {
afterEach(cleanup);
test('progress updates after viewport height change', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 600, elementHeight: 200 });
const sm = new ScrollMagic({ element: target });
// Scroll so element is partially in view
window.scrollTo(0, 500);
await waitForFrames(3);
const progressBefore = sm.progress;
expect(progressBefore).toBeGreaterThan(0);
expect(progressBefore).toBeLessThan(1);
// Shrink viewport (simulates mobile address bar appearing)
await page.viewport(1024, 400);
await wait(50); // give ResizeObserver / window resize event a moment to fire
await waitForFrames(5);
// Progress should have changed since viewport size affects the tracking calculation
expect(sm.progress).not.toBe(progressBefore);
sm.destroy();
});
});
================================================
FILE: tests/e2e/destroy.test.ts
================================================
import { describe, test, expect, afterEach, vi } from 'vitest';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow } from './helpers';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('destroy: idempotency', () => {
test('calling destroy twice does not warn', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.destroy();
sm.destroy();
expect(warnSpy).not.toHaveBeenCalled();
});
});
describe('destroy: post-destroy dev warnings', () => {
const makeScene = () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.destroy();
return { sm, target };
};
test('modify() warns', () => {
const { sm, target } = makeScene();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.modify({ element: target });
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
});
test('refresh() warns', () => {
const { sm } = makeScene();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.refresh();
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
});
test('addPlugin() warns', () => {
const { sm } = makeScene();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.addPlugin({ name: 'test' });
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
});
test('removePlugin() warns', () => {
const { sm } = makeScene();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.removePlugin({ name: 'test' });
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
});
test('on() warns', () => {
const { sm } = makeScene();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.on('progress', () => {});
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
});
test('off() warns', () => {
const { sm } = makeScene();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.off('progress', () => {});
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
});
test('subscribe() warns', () => {
const { sm } = makeScene();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.subscribe('progress', () => {});
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
});
test('activeRange warns', () => {
const { sm } = makeScene();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
void sm.activeRange;
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
});
});
describe('destroy: plugin cleanup', () => {
test('destroy() calls onDestroy (not onRemove) on all plugins', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
const plugin1 = { name: 'p1', onRemove: vi.fn(), onDestroy: vi.fn() };
const plugin2 = { name: 'p2', onRemove: vi.fn(), onDestroy: vi.fn() };
sm.addPlugin(plugin1);
sm.addPlugin(plugin2);
sm.destroy();
expect(plugin1.onDestroy).toHaveBeenCalledOnce();
expect(plugin2.onDestroy).toHaveBeenCalledOnce();
expect(plugin1.onRemove).not.toHaveBeenCalled();
expect(plugin2.onRemove).not.toHaveBeenCalled();
expect(sm.pluginList).toHaveLength(0);
});
test('destroy() calls onDisable before onDestroy (when enabled)', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
const order: string[] = [];
const plugin = {
name: 'order-test',
onDisable: vi.fn(() => order.push('disable')),
onDestroy: vi.fn(() => order.push('destroy')),
};
sm.addPlugin(plugin);
sm.destroy();
expect(order).toEqual(['disable', 'destroy']);
});
test('destroy() skips onDisable when already disabled', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
const plugin = {
name: 'test',
onDisable: vi.fn(),
onDestroy: vi.fn(),
};
sm.addPlugin(plugin);
sm.disable();
plugin.onDisable.mockClear(); // reset from the explicit disable() call
sm.destroy();
expect(plugin.onDisable).not.toHaveBeenCalled();
expect(plugin.onDestroy).toHaveBeenCalledOnce();
});
});
describe('destroy: post-destroy no-op behaviour', () => {
test('modify() does not change options', () => {
const { target } = setupWindow();
const otherElement = document.createElement('div');
document.body.appendChild(otherElement);
const sm = new ScrollMagic({ element: target });
sm.destroy();
vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.modify({ element: otherElement });
expect(sm.element).toBe(target);
});
test('addPlugin() does not register the plugin', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.destroy();
vi.spyOn(console, 'warn').mockImplementation(() => {});
const plugin = { name: 'test', onAdd: vi.fn() };
sm.addPlugin(plugin);
expect(plugin.onAdd).not.toHaveBeenCalled();
expect(sm.pluginList).toHaveLength(0);
});
test('subscribe() returns a no-op cleanup function', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.destroy();
vi.spyOn(console, 'warn').mockImplementation(() => {});
const unsub = sm.subscribe('progress', () => {});
expect(unsub).toBeTypeOf('function');
expect(() => unsub()).not.toThrow();
});
test('activeRange returns { start: 0, end: 0 }', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.destroy();
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(sm.activeRange).toEqual({ start: 0, end: 0 });
});
});
================================================
FILE: tests/e2e/dev-warnings.test.ts
================================================
import { describe, test, expect, afterEach, vi } from 'vitest';
import ScrollMagic from '../../src/index';
import { cleanup } from './helpers';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('Dev warnings: element / container relationship', () => {
test('logs error when element is not a descendant of container', () => {
const container = document.createElement('div');
container.style.height = '400px';
container.style.overflow = 'auto';
document.body.appendChild(container);
const outsideElement = document.createElement('div');
outsideElement.style.height = '100px';
document.body.appendChild(outsideElement);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
const sm = new ScrollMagic({ element: outsideElement, container });
sm.destroy(); // destroy before rAF fires to prevent IntersectionObserver errors from invalid config
expect(errorSpy).toHaveBeenCalledOnce();
expect(errorSpy.mock.calls[0][0]).toContain('not a descendant');
});
test('does not log error when element is a descendant of container', () => {
const container = document.createElement('div');
container.style.height = '400px';
container.style.overflow = 'auto';
const innerElement = document.createElement('div');
innerElement.style.height = '100px';
container.appendChild(innerElement);
document.body.appendChild(container);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
const sm = new ScrollMagic({ element: innerElement, container });
sm.destroy();
expect(errorSpy).not.toHaveBeenCalled();
});
test('does not log error when container is window (default)', () => {
const element = document.createElement('div');
element.style.height = '100px';
document.body.appendChild(element);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
const sm = new ScrollMagic({ element });
sm.destroy();
expect(errorSpy).not.toHaveBeenCalled();
});
test('logs error when container is updated via modify and element is not a descendant', () => {
const container = document.createElement('div');
container.style.height = '400px';
container.style.overflow = 'auto';
document.body.appendChild(container);
const outsideElement = document.createElement('div');
outsideElement.style.height = '100px';
document.body.appendChild(outsideElement);
vi.spyOn(console, 'warn').mockImplementation(() => {});
const sm = new ScrollMagic({ element: outsideElement }); // window — ok
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
sm.modify({ container }); // now non-descendant
sm.destroy();
expect(errorSpy).toHaveBeenCalledOnce();
expect(errorSpy.mock.calls[0][0]).toContain('not a descendant');
});
});
================================================
FILE: tests/e2e/element-tracking.test.ts
================================================
/**
* Element tracking edge cases: resize, DOM mutations, SVG elements.
* Tests for: layout shifts from element resize, content removal above element, SVG as tracked element.
*/
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow, wait, waitForFrames } from './helpers';
// #986: Lazy-loaded images changing layout may not trigger ResizeObserver or bounds recalculation in time.
// Tested by resizing the tracked element after initial scroll (simulates lazy image load).
describe('element resize / layout shifts', () => {
afterEach(cleanup);
test('progress recalculates when tracked element changes size', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });
const sm = new ScrollMagic({ element: target });
// Scroll and wait for IO to fire and set intersecting=true
window.scrollTo(0, 200);
await waitForFrames(3);
const progressBefore = sm.progress;
expect(progressBefore).toBeGreaterThan(0);
// Simulate lazy image loading — element grows taller
target.style.height = '600px';
// ResizeObserver fires before next paint; wait generously for it + scheduler flush
await wait(500);
await waitForFrames(5);
// Track size changed, so progress should differ
expect(sm.progress).not.toBe(progressBefore);
sm.destroy();
});
});
// #911: Major DOM mutations (removing large nodes) while scrolled could cause scroll position drift.
// Tested by removing a large block above the tracked element mid-scroll.
describe('DOM mutation', () => {
afterEach(cleanup);
test('handles removal of content above tracked element', async () => {
await page.viewport(1024, 768);
document.body.style.margin = '0';
document.body.style.padding = '0';
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
const aboveBlock = document.createElement('div');
aboveBlock.style.height = '800px';
const target = document.createElement('div');
target.style.height = '200px';
target.style.width = '100%';
target.style.background = 'red';
const belowBlock = document.createElement('div');
belowBlock.style.height = '2000px';
wrapper.appendChild(aboveBlock);
wrapper.appendChild(target);
wrapper.appendChild(belowBlock);
document.body.appendChild(wrapper);
const sm = new ScrollMagic({ element: target });
// Scroll to see the target
window.scrollTo(0, 500);
await waitForFrames(3);
expect(sm.progress).toBeGreaterThan(0);
// Remove the 800px block above — target shifts up in layout
aboveBlock.remove();
await waitForFrames(5);
// Should not crash; progress should be a valid number
expect(sm.progress).toBeGreaterThanOrEqual(0);
expect(sm.progress).toBeLessThanOrEqual(1);
expect(Number.isNaN(sm.progress)).toBe(false);
sm.destroy();
});
});
// #618, #460: SVG elements may report incorrect bounding boxes in Firefox.
// SVG child elements (, , ) as IO targets may behave unexpectedly.
describe('SVG elements', () => {
afterEach(cleanup);
test('tracks an SVG element', async () => {
await page.viewport(1024, 768);
document.body.style.margin = '0';
document.body.style.padding = '0';
const spacer = document.createElement('div');
spacer.style.height = '3000px';
spacer.style.position = 'relative';
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', '200');
svg.setAttribute('height', '200');
svg.style.position = 'absolute';
svg.style.top = '1000px';
const rect = document.createElementNS(svgNS, 'rect');
rect.setAttribute('width', '200');
rect.setAttribute('height', '200');
rect.setAttribute('fill', 'blue');
svg.appendChild(rect);
spacer.appendChild(svg);
document.body.appendChild(spacer);
const events: string[] = [];
const sm = new ScrollMagic({ element: svg });
sm.on('enter', () => events.push('enter'));
sm.on('leave', () => events.push('leave'));
// Scroll into view
window.scrollTo(0, 800);
await waitForFrames(3);
expect(events).toContain('enter');
expect(sm.progress).toBeGreaterThan(0);
// Scroll past
window.scrollTo(0, 1500);
await waitForFrames(3);
expect(sm.progress).toBe(1);
sm.destroy();
});
});
================================================
FILE: tests/e2e/enable-disable.test.ts
================================================
/**
* Enable/disable: pause and resume tracking without destroying.
* Tests for: state & getters, method guards, idempotency, chaining,
* tracking pause/resume, event suppression, modify-while-disabled.
*/
import { describe, test, expect, afterEach, vi } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow, waitForFrames } from './helpers';
describe('enable/disable: state & guards', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test('disabled is false by default', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
expect(sm.disabled).toBe(false);
sm.destroy();
});
test('disabled is true after disable(), false after enable()', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.disable();
expect(sm.disabled).toBe(true);
sm.enable();
expect(sm.disabled).toBe(false);
sm.destroy();
});
test('disable() and enable() return the instance (chaining)', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
expect(sm.disable()).toBe(sm);
expect(sm.enable()).toBe(sm);
sm.destroy();
});
test('double disable() does not throw', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.disable();
expect(() => sm.disable()).not.toThrow();
sm.destroy();
});
test('double enable() does not throw', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
expect(() => sm.enable()).not.toThrow();
sm.destroy();
});
test('modify() works when disabled (updates options)', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.disable();
sm.modify({ containerStart: 0.5 });
expect(sm.containerStart).toBe(0.5);
sm.destroy();
});
test('on()/off()/subscribe() work when disabled', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.disable();
const handler = () => {};
expect(() => sm.on('progress', handler)).not.toThrow();
expect(() => sm.off('progress', handler)).not.toThrow();
const unsub = sm.subscribe('progress', handler);
expect(typeof unsub).toBe('function');
sm.destroy();
});
test('addPlugin()/removePlugin() work when disabled', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.disable();
const plugin = { name: 'test', onAdd: vi.fn(), onRemove: vi.fn() };
sm.addPlugin(plugin);
expect(plugin.onAdd).toHaveBeenCalledOnce();
expect(sm.pluginList).toHaveLength(1);
sm.removePlugin(plugin);
expect(plugin.onRemove).toHaveBeenCalledOnce();
expect(sm.pluginList).toHaveLength(0);
sm.destroy();
});
test('disable() calls onDisable on all plugins', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
const plugin = { name: 'test', onDisable: vi.fn() };
sm.addPlugin(plugin);
sm.disable();
expect(plugin.onDisable).toHaveBeenCalledOnce();
sm.destroy();
});
test('enable() calls onEnable on all plugins', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
const plugin = { name: 'test', onEnable: vi.fn() };
sm.addPlugin(plugin);
sm.disable();
sm.enable();
expect(plugin.onEnable).toHaveBeenCalledOnce();
sm.destroy();
});
test('onDisable/onEnable are not called on idempotent calls', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
const plugin = { name: 'test', onEnable: vi.fn(), onDisable: vi.fn() };
sm.addPlugin(plugin);
// already enabled — enable() should no-op
sm.enable();
expect(plugin.onEnable).not.toHaveBeenCalled();
sm.disable();
expect(plugin.onDisable).toHaveBeenCalledOnce();
// already disabled — disable() should no-op
sm.disable();
expect(plugin.onDisable).toHaveBeenCalledOnce(); // still just once
sm.destroy();
});
test('progress getter returns last known value when disabled', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(sm.progress).toBe(1);
sm.disable();
expect(sm.progress).toBe(1);
sm.destroy();
});
test('activeRange still works when disabled', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
await waitForFrames(3);
const offsetBefore = sm.activeRange;
sm.disable();
const offsetWhileDisabled = sm.activeRange;
expect(offsetWhileDisabled).toEqual(offsetBefore);
sm.destroy();
});
test('refresh() is a no-op when disabled (no errors)', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.disable();
expect(() => sm.refresh()).not.toThrow();
sm.destroy();
});
test('destroy() fully tears down a disabled instance', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const events: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('enter', () => events.push('enter'));
sm.on('progress', () => events.push('progress'));
sm.disable();
sm.destroy();
// re-enable should be impossible (destroyed)
vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.enable();
expect(sm.disabled).toBe(true); // still disabled — enable was a no-op
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(events).toHaveLength(0);
});
test('disabled is true after destroy() (without prior disable())', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.destroy();
expect(sm.disabled).toBe(true);
});
test('enable() after destroy() warns and no-ops', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.destroy();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.enable();
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
expect(sm.disabled).toBe(true);
});
test('disable() after destroy() warns and no-ops', () => {
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
sm.destroy();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sm.disable();
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
});
});
describe('enable/disable: tracking behavior', () => {
afterEach(cleanup);
test('disable() stops events — scroll after disable fires nothing', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const events: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('enter', () => events.push('enter'));
sm.on('progress', () => events.push('progress'));
sm.on('leave', () => events.push('leave'));
sm.disable();
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(events).toHaveLength(0);
sm.destroy();
});
test('disable() freezes progress at pre-disable value', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(sm.progress).toBe(1);
sm.disable();
window.scrollTo(0, 0);
await waitForFrames(3);
expect(sm.progress).toBe(1);
sm.destroy();
});
test('enable() resumes tracking — progress updates after re-enable', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
sm.disable();
// Scroll past while disabled
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(sm.progress).toBe(0); // frozen at initial value
sm.enable();
await waitForFrames(3);
expect(sm.progress).toBe(1);
sm.destroy();
});
test('enable() fires events after resuming', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
sm.disable();
// Scroll past while disabled
window.scrollTo(0, 2000);
await waitForFrames(3);
const events: string[] = [];
sm.on('enter', () => events.push('enter'));
sm.on('progress', () => events.push('progress'));
sm.on('leave', () => events.push('leave'));
sm.enable();
await waitForFrames(3);
expect(events).toContain('enter');
expect(events).toContain('progress');
sm.destroy();
});
test('rapid toggle (disable → enable → disable) in one frame cancels scheduled work', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const events: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('enter', () => events.push('enter'));
sm.on('progress', () => events.push('progress'));
// Rapid toggle before rAF fires — enable() schedules work, disable() cancels it
sm.disable();
sm.enable();
sm.disable();
expect(sm.disabled).toBe(true);
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(events).toHaveLength(0);
expect(sm.progress).toBe(0);
sm.destroy();
});
test('modify({ element }) while disabled takes effect on enable()', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
// Create a second element further down
const newTarget = document.createElement('div');
newTarget.style.position = 'absolute';
newTarget.style.top = '1500px';
newTarget.style.height = '100px';
newTarget.style.width = '100%';
target.parentElement!.appendChild(newTarget);
sm.disable();
sm.modify({ element: newTarget });
expect(sm.element).toBe(newTarget);
// Scroll past the NEW element position
window.scrollTo(0, 2500);
await waitForFrames(3);
expect(sm.progress).toBe(0); // still frozen
sm.enable();
await waitForFrames(3);
expect(sm.progress).toBe(1); // tracking the new element
sm.destroy();
});
test('modify({ container }) while disabled takes effect on enable()', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
sm.disable();
sm.modify({ container: window }); // same container in this case, but exercises the code path
expect(sm.container).toBe(window);
sm.enable();
await waitForFrames(3);
// Should be tracking normally after re-enable with new container
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(sm.progress).toBe(1);
sm.destroy();
});
test('modify() while disabled takes effect on enable()', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
sm.disable();
sm.modify({ containerStart: 0.5 });
sm.enable();
await waitForFrames(3);
expect(sm.containerStart).toBe(0.5);
// Verify the new containerStart is actually in effect by checking resolved offsets
expect(sm.resolvedBounds.container.offsetStart).toBeGreaterThan(0);
sm.destroy();
});
});
================================================
FILE: tests/e2e/helpers.ts
================================================
export const waitForFrame = () => new Promise(resolve => requestAnimationFrame(() => resolve()));
export const waitForFrames = async (n = 3) => {
for (let i = 0; i < n; i++) await waitForFrame();
};
export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const cleanup = () => {
document.body.innerHTML = '';
window.scrollTo(0, 0);
};
/** Standard setup: scrollable page with a positioned target element (window scroll) */
export const setupWindow = (opts: { contentHeight?: number; elementTop?: number; elementHeight?: number } = {}) => {
const { contentHeight = 3000, elementTop = 1000, elementHeight = 200 } = opts;
document.body.style.margin = '0';
document.body.style.padding = '0';
const spacer = document.createElement('div');
spacer.style.height = `${contentHeight}px`;
spacer.style.position = 'relative';
const target = document.createElement('div');
target.style.position = 'absolute';
target.style.top = `${elementTop}px`;
target.style.height = `${elementHeight}px`;
target.style.width = '100%';
spacer.appendChild(target);
document.body.appendChild(spacer);
return { spacer, target };
};
/** Setup: scrollable container div (non-window scroll parent) */
export const setupContainer = (
opts: {
containerHeight?: number;
contentHeight?: number;
elementTop?: number;
elementHeight?: number;
containerCss?: Partial;
} = {}
) => {
const { containerHeight = 400, contentHeight = 2000, elementTop = 800, elementHeight = 100, containerCss = {} } = opts;
document.body.style.margin = '0';
document.body.style.padding = '0';
const container = document.createElement('div');
container.style.height = `${containerHeight}px`;
container.style.overflow = 'auto';
container.style.position = 'relative';
Object.assign(container.style, containerCss);
const content = document.createElement('div');
content.style.height = `${contentHeight}px`;
content.style.position = 'relative';
const target = document.createElement('div');
target.style.position = 'absolute';
target.style.top = `${elementTop}px`;
target.style.height = `${elementHeight}px`;
target.style.width = '100%';
content.appendChild(target);
container.appendChild(content);
document.body.appendChild(container);
return { container, content, target };
};
================================================
FILE: tests/e2e/refresh.test.ts
================================================
/**
* Manual refresh API: refresh(), refreshAll(), destroyAll().
* Tests for: position change detection via refresh, class-based changes,
* chaining, multi-instance refreshAll, destroyed instance exclusion, destroyAll.
*/
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow, waitForFrames } from './helpers';
describe('refresh', () => {
afterEach(cleanup);
// Note on shift magnitude: position shifts must be small enough that the element stays
// well within the IntersectionObserver's margin region. If the shift moves the element
// outside the IO boundary, IO fires automatically and progress updates without refresh(),
// defeating the test's purpose. With default options (IO margins ≈ 0% on a 768px viewport),
// the element must stay clearly visible in the viewport after the shift.
test('refresh() picks up position changes from inline style', async () => {
await page.viewport(1024, 768);
// element at visual ~150px after scroll — centered in viewport
const { target } = setupWindow({ elementTop: 550, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
window.scrollTo(0, 400);
await waitForFrames();
const progressBefore = sm.progress;
expect(progressBefore).toBeGreaterThan(0);
// small shift (50px) keeps element well within viewport (visual 150px → 100px)
// ResizeObserver won't fire (size unchanged), IO margins aren't crossed
target.style.top = '500px';
await waitForFrames();
const progressWithoutRefresh = sm.progress;
sm.refresh();
await waitForFrames();
const progressAfterRefresh = sm.progress;
// element moved closer to top → progress should increase
expect(progressAfterRefresh).toBeGreaterThan(progressBefore);
expect(progressAfterRefresh).not.toBe(progressWithoutRefresh);
sm.destroy();
});
test('refresh() picks up class-based position changes', async () => {
await page.viewport(1024, 768);
// element at visual ~150px after scroll
const { target } = setupWindow({ elementTop: 550, elementHeight: 100 });
// CSS class that shifts position by 50px (keeps element in viewport)
const style = document.createElement('style');
style.textContent = '.shifted { top: 500px !important; }';
document.head.appendChild(style);
const sm = new ScrollMagic({ element: target });
window.scrollTo(0, 400);
await waitForFrames();
const progressBefore = sm.progress;
expect(progressBefore).toBeGreaterThan(0);
target.classList.add('shifted');
sm.refresh();
await waitForFrames();
expect(sm.progress).not.toBe(progressBefore);
sm.destroy();
style.remove();
});
test('refresh() is chainable', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
const result = sm.refresh();
expect(result).toBe(sm);
sm.destroy();
});
test('refreshAll() updates all active instances', async () => {
await page.viewport(1024, 768);
// elements well within viewport after scroll: visual ~150px and ~250px
const { spacer, target } = setupWindow({ elementTop: 550, elementHeight: 100 });
const target2 = document.createElement('div');
target2.style.position = 'absolute';
target2.style.top = '650px';
target2.style.height = '100px';
target2.style.width = '100%';
spacer.appendChild(target2);
const scene1 = new ScrollMagic({ element: target });
const scene2 = new ScrollMagic({ element: target2 });
window.scrollTo(0, 400);
await waitForFrames();
const p1Before = scene1.progress;
const p2Before = scene2.progress;
// small shifts (50px each) — stay within viewport
target.style.top = '500px';
target2.style.top = '600px';
ScrollMagic.refreshAll();
await waitForFrames();
expect(scene1.progress).not.toBe(p1Before);
expect(scene2.progress).not.toBe(p2Before);
scene1.destroy();
scene2.destroy();
});
test('refreshAll() skips disabled instances while refreshing enabled ones', async () => {
await page.viewport(1024, 768);
const { spacer, target } = setupWindow({ elementTop: 550, elementHeight: 100 });
const target2 = document.createElement('div');
target2.style.position = 'absolute';
target2.style.top = '650px';
target2.style.height = '100px';
target2.style.width = '100%';
spacer.appendChild(target2);
const scene1 = new ScrollMagic({ element: target });
const scene2 = new ScrollMagic({ element: target2 });
window.scrollTo(0, 400);
await waitForFrames();
const p1Before = scene1.progress;
const p2Before = scene2.progress;
scene1.disable();
// small shifts — stay within viewport
target.style.top = '500px';
target2.style.top = '600px';
ScrollMagic.refreshAll();
await waitForFrames();
// disabled instance: progress frozen
expect(scene1.progress).toBe(p1Before);
// enabled instance: progress updated
expect(scene2.progress).not.toBe(p2Before);
scene1.destroy();
scene2.destroy();
});
test('destroyed instances are excluded from refreshAll()', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 550, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
window.scrollTo(0, 400);
await waitForFrames();
const progressBefore = sm.progress;
sm.destroy();
target.style.top = '500px';
ScrollMagic.refreshAll();
await waitForFrames();
// progress is frozen at whatever it was before destroy
expect(sm.progress).toBe(progressBefore);
});
test('destroyAll() destroys all active instances', async () => {
await page.viewport(1024, 768);
const { spacer, target } = setupWindow({ elementTop: 550, elementHeight: 100 });
const target2 = document.createElement('div');
target2.style.position = 'absolute';
target2.style.top = '650px';
target2.style.height = '100px';
target2.style.width = '100%';
spacer.appendChild(target2);
const scene1 = new ScrollMagic({ element: target });
const scene2 = new ScrollMagic({ element: target2 });
window.scrollTo(0, 400);
await waitForFrames();
const p1Before = scene1.progress;
const p2Before = scene2.progress;
ScrollMagic.destroyAll();
// scroll should not update progress on destroyed instances
window.scrollTo(0, 600);
await waitForFrames();
expect(scene1.progress).toBe(p1Before);
expect(scene2.progress).toBe(p2Before);
});
});
================================================
FILE: tests/e2e/scroll-progress.test.ts
================================================
/**
* Core scroll progress tracking and event behavior.
* Tests for: progress 0→1 lifecycle, enter/leave/progress events, event direction,
* fast scrolling, programmatic scroll jumps, scroll state initialization, destroy,
* on() with { once: true }.
*/
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import type { ScrollMagicEvent } from '../../src/index';
import { cleanup, setupWindow, waitForFrames } from './helpers';
describe('progress lifecycle', () => {
afterEach(cleanup);
test('fires enter and progress events on scroll', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow();
const events: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('enter', () => events.push('enter'));
sm.on('progress', () => events.push('progress'));
sm.on('leave', () => events.push('leave'));
// Scroll to a position where the element should be intersecting
window.scrollTo(0, 600);
await waitForFrames(3);
expect(events).toContain('enter');
expect(events).toContain('progress');
expect(sm.progress).toBeGreaterThan(0);
sm.destroy();
});
test('progress reaches 1 when fully scrolled past', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
// Scroll well past the element
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(sm.progress).toBe(1);
sm.destroy();
});
test('progress is 0 before element enters viewport', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 2000 });
const sm = new ScrollMagic({ element: target });
await waitForFrames(3);
expect(sm.progress).toBe(0);
sm.destroy();
});
test('fires leave event when scrolling past', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const events: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('leave', () => events.push('leave'));
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(events).toContain('leave');
sm.destroy();
});
test('destroy stops event processing', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const events: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('progress', () => events.push('progress'));
sm.destroy();
window.scrollTo(0, 1000);
await waitForFrames(3);
expect(events).toHaveLength(0);
});
});
// #633: Fast scrolling could skip intermediate IO callbacks — elements scrolled past entirely in one frame.
describe('fast scrolling', () => {
afterEach(cleanup);
test('progress is correct after instant scroll past element and back', async () => {
await page.viewport(1024, 768);
// Element at 1500px — well below 768px viewport when scrolled to 0
const { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
// Instant scroll well past element
window.scrollTo(0, 2500);
await waitForFrames(3);
expect(sm.progress).toBe(1);
// Instant scroll back to top — element now below viewport
window.scrollTo(0, 0);
await waitForFrames(3);
expect(sm.progress).toBe(0);
sm.destroy();
});
test('all enter/leave events fire during instant scroll through', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const events: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('enter', () => events.push('enter'));
sm.on('leave', () => events.push('leave'));
// Single scroll that jumps completely past the element
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(events).toContain('enter');
expect(events).toContain('leave');
expect(sm.progress).toBe(1);
sm.destroy();
});
});
// #630, #596: Browser scroll restoration — instances created at non-zero scroll positions.
describe('scroll state initialization', () => {
afterEach(cleanup);
test('correct initial progress when instance created at non-zero scroll', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
// Scroll past element BEFORE creating instance
window.scrollTo(0, 2000);
await waitForFrames(3);
// Now create instance — should detect current position
const sm = new ScrollMagic({ element: target });
await waitForFrames(3);
expect(sm.progress).toBe(1);
sm.destroy();
});
test('correct initial progress when element is partially visible on creation', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 400 });
// Scroll to a position where element is partially visible
window.scrollTo(0, 500);
await waitForFrames(3);
const sm = new ScrollMagic({ element: target });
await waitForFrames(3);
expect(sm.progress).toBeGreaterThan(0);
expect(sm.progress).toBeLessThan(1);
sm.destroy();
});
test('fires enter event when created at position where element is visible', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });
// Scroll so element is in view
window.scrollTo(0, 200);
await waitForFrames(3);
const events: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('enter', () => events.push('enter'));
sm.on('progress', () => events.push('progress'));
await waitForFrames(3);
expect(events).toContain('enter');
expect(events).toContain('progress');
sm.destroy();
});
});
// #948: scrollDirection may be incorrect if no scroll has occurred yet.
describe('event direction', () => {
afterEach(cleanup);
test('direction is forward when element scrolled past from above', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
const enterDirections: string[] = [];
const leaveDirections: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('enter', (e: ScrollMagicEvent) => enterDirections.push(e.direction));
sm.on('leave', (e: ScrollMagicEvent) => leaveDirections.push(e.direction));
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(enterDirections).toContain('forward');
expect(leaveDirections).toContain('forward');
sm.destroy();
});
test('direction is reverse when scrolling back up past element', async () => {
await page.viewport(1024, 768);
// Element below viewport when scrolled to 0, so reverse scroll exits fully
const { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
// First scroll past
window.scrollTo(0, 2500);
await waitForFrames(3);
expect(sm.progress).toBe(1);
const enterDirections: string[] = [];
const leaveDirections: string[] = [];
sm.on('enter', (e: ScrollMagicEvent) => enterDirections.push(e.direction));
sm.on('leave', (e: ScrollMagicEvent) => leaveDirections.push(e.direction));
// Scroll back to top — element now below viewport
window.scrollTo(0, 0);
await waitForFrames(3);
expect(enterDirections).toContain('reverse');
expect(leaveDirections).toContain('reverse');
sm.destroy();
});
});
describe('on with { once: true }', () => {
afterEach(cleanup);
test('once listener fires exactly once then auto-removes', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
let enterCount = 0;
const sm = new ScrollMagic({ element: target });
sm.on('enter', () => enterCount++, { once: true });
// Scroll forward past element → enter fires
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(enterCount).toBe(1);
// Scroll back to top → element leaves, then scroll past again
window.scrollTo(0, 0);
await waitForFrames(3);
window.scrollTo(0, 2000);
await waitForFrames(3);
// Still 1 — listener was auto-removed after first fire
expect(enterCount).toBe(1);
sm.destroy();
});
test('off() cancels a once listener before it fires', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
let enterCount = 0;
const handler = () => enterCount++;
const sm = new ScrollMagic({ element: target });
sm.on('enter', handler, { once: true });
sm.off('enter', handler);
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(enterCount).toBe(0);
sm.destroy();
});
test('on with { once: true } is chainable', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow();
const sm = new ScrollMagic({ element: target });
const result = sm.on('enter', () => {}, { once: true });
expect(result).toBe(sm);
sm.destroy();
});
test('once on different event types works independently', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
let enterCount = 0;
let leaveCount = 0;
const sm = new ScrollMagic({ element: target });
sm.on('enter', () => enterCount++, { once: true });
sm.on('leave', () => leaveCount++, { once: true });
// Scroll forward past → enter + leave fire
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(enterCount).toBe(1);
expect(leaveCount).toBe(1);
// Scroll back and forward again → neither fires again
window.scrollTo(0, 0);
await waitForFrames(3);
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(enterCount).toBe(1);
expect(leaveCount).toBe(1);
sm.destroy();
});
test('subscribe with { once: true } fires once and returns working unsubscribe', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
let enterCount = 0;
const sm = new ScrollMagic({ element: target });
const unsub = sm.subscribe('enter', () => enterCount++, { once: true });
expect(typeof unsub).toBe('function');
// Scroll forward past element → enter fires
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(enterCount).toBe(1);
// Scroll back and forward again → auto-removed, doesn't fire
window.scrollTo(0, 0);
await waitForFrames(3);
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(enterCount).toBe(1);
sm.destroy();
});
test('subscribe with { once: true } can be cancelled via unsubscribe before firing', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
let enterCount = 0;
const sm = new ScrollMagic({ element: target });
const unsub = sm.subscribe('enter', () => enterCount++, { once: true });
unsub(); // cancel before it fires
window.scrollTo(0, 2000);
await waitForFrames(3);
expect(enterCount).toBe(0);
sm.destroy();
});
});
// Anchor links / scrollTo can skip the active range entirely in a single frame.
// The element may never have intersected — progress must still settle at 0 or 1.
describe('anchor-link style jumps', () => {
afterEach(cleanup);
test('progress reaches 1 when scrollTo jumps past a never-intersected element', async () => {
await page.viewport(1024, 768);
// Element well below viewport — not visible at scroll=0
const { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
// Element has never been intersecting — jump straight past it
await waitForFrames(3);
expect(sm.progress).toBe(0);
window.scrollTo(0, 2500);
await waitForFrames(3);
expect(sm.progress).toBe(1);
sm.destroy();
});
test('enter and leave both fire when jumping over a never-intersected element', async () => {
await page.viewport(1024, 768);
// Element well below viewport — not visible at scroll=0
const { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });
const events: string[] = [];
const sm = new ScrollMagic({ element: target });
sm.on('enter', () => events.push('enter'));
sm.on('leave', () => events.push('leave'));
await waitForFrames(3);
expect(events).toHaveLength(0);
window.scrollTo(0, 2500);
await waitForFrames(3);
expect(events).toContain('enter');
expect(events).toContain('leave');
sm.destroy();
});
test('progress reaches 0 when jumping back before a previously-passed element', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });
const sm = new ScrollMagic({ element: target });
// First jump past
window.scrollTo(0, 2500);
await waitForFrames(3);
expect(sm.progress).toBe(1);
// Jump all the way back — element now entirely below viewport
window.scrollTo(0, 0);
await waitForFrames(3);
expect(sm.progress).toBe(0);
sm.destroy();
});
});
// #397: Browser find (Cmd+F) triggers scroll-to-element — verify progress after programmatic scrolls.
describe('programmatic scroll jumps', () => {
afterEach(cleanup);
test('progress correct after multiple programmatic scrollTo jumps', async () => {
await page.viewport(1024, 768);
// Element below viewport when at scroll=0
const { target } = setupWindow({ elementTop: 1500, elementHeight: 200 });
const sm = new ScrollMagic({ element: target });
// Jump to where element is partially visible
window.scrollTo(0, 1200);
await waitForFrames(3);
expect(sm.progress).toBeGreaterThan(0);
expect(sm.progress).toBeLessThan(1);
// Jump far past
window.scrollTo(0, 2500);
await waitForFrames(3);
expect(sm.progress).toBe(1);
// Jump back to before element (element below viewport)
window.scrollTo(0, 0);
await waitForFrames(3);
expect(sm.progress).toBe(0);
// Jump directly into element again
window.scrollTo(0, 1300);
await waitForFrames(3);
expect(sm.progress).toBeGreaterThan(0);
sm.destroy();
});
});
================================================
FILE: tests/e2e/scroll-velocity.test.ts
================================================
/**
* Scroll velocity: per-container px/s computation exposed via ScrollMagic getter.
* Tests for: non-zero during scroll, sign (forward/backward), staleness decay,
* disabled/destroyed state, callback access via e.target, horizontal axis.
*/
import { describe, test, expect, afterEach, vi } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow, wait, waitForFrames } from './helpers';
describe('scrollVelocity', () => {
afterEach(cleanup);
test('non-zero during scroll', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 400 });
const sm = new ScrollMagic({ element: target });
await waitForFrames(3); // let initial setup complete
let velocityDuringScroll = 0;
sm.on('progress', () => {
velocityDuringScroll = sm.scrollVelocity;
});
window.scrollTo(0, 600);
await waitForFrames(3);
expect(velocityDuringScroll).not.toBe(0);
sm.destroy();
});
test('positive when scrolling forward', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 400 });
const sm = new ScrollMagic({ element: target });
await waitForFrames(3);
let velocityDuringScroll = 0;
sm.on('progress', () => {
velocityDuringScroll = sm.scrollVelocity;
});
window.scrollTo(0, 600);
await waitForFrames(3);
expect(velocityDuringScroll).toBeGreaterThan(0);
sm.destroy();
});
test('negative when scrolling backward', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 1500, elementHeight: 400 });
const sm = new ScrollMagic({ element: target });
// First scroll past the element and let it settle
window.scrollTo(0, 2500);
await waitForFrames(3);
expect(sm.progress).toBe(1);
let velocityOnReturn = 0;
sm.on('progress', () => {
velocityOnReturn = sm.scrollVelocity;
});
// Scroll back to top — element now below viewport, progress returns to 0
window.scrollTo(0, 0);
await waitForFrames(3);
expect(sm.progress).toBe(0);
expect(velocityOnReturn).toBeLessThan(0);
sm.destroy();
});
test('returns 0 after scrolling stops (staleness decay)', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 400 });
const sm = new ScrollMagic({ element: target });
window.scrollTo(0, 600);
await waitForFrames(3);
// Wait past the 100ms staleness threshold
await wait(200);
expect(sm.scrollVelocity).toBe(0);
sm.destroy();
});
test('returns 0 when disabled', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 400 });
const sm = new ScrollMagic({ element: target });
window.scrollTo(0, 600);
await waitForFrames(3);
sm.disable();
expect(sm.scrollVelocity).toBe(0);
sm.destroy();
});
test('returns 0 after destroy (no warning)', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 400 });
const sm = new ScrollMagic({ element: target });
window.scrollTo(0, 600);
await waitForFrames(3);
sm.destroy();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(sm.scrollVelocity).toBe(0);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
test('positive for horizontal scroll with vertical: false', async () => {
await page.viewport(1024, 768);
document.body.style.margin = '0';
document.body.style.padding = '0';
const spacer = document.createElement('div');
spacer.style.width = '5000px';
spacer.style.height = '768px';
spacer.style.position = 'relative';
const target = document.createElement('div');
target.style.position = 'absolute';
target.style.left = '1500px';
target.style.width = '400px';
target.style.height = '100%';
spacer.appendChild(target);
document.body.appendChild(spacer);
const sm = new ScrollMagic({ element: target, vertical: false });
await waitForFrames(3);
expect(sm.progress).toBe(0);
window.scrollTo(600, 0);
await waitForFrames(3);
// Check velocity directly — axis projection picks x, not y
expect(sm.scrollVelocity).toBeGreaterThan(0);
expect(sm.progress).toBeGreaterThan(0);
sm.destroy();
});
test('accessible via e.target.scrollVelocity in callbacks', async () => {
await page.viewport(1024, 768);
const { target } = setupWindow({ elementTop: 500, elementHeight: 400 });
const sm = new ScrollMagic({ element: target });
await waitForFrames(3);
let eventTargetVelocity = 0;
let directVelocity = 0;
sm.on('progress', e => {
eventTargetVelocity = e.target.scrollVelocity;
directVelocity = sm.scrollVelocity;
});
window.scrollTo(0, 600);
await waitForFrames(3);
expect(eventTargetVelocity).toBe(directVelocity);
expect(eventTargetVelocity).not.toBe(0);
sm.destroy();
});
});
================================================
FILE: tests/unit/ContainerProxy.test.ts
================================================
import { describe, test, expect, vi, beforeEach } from 'vitest';
import type { ScrollMagic } from '../../src/ScrollMagic';
const destroyMock = vi.fn();
const subscribeMock = vi.fn(() => vi.fn());
vi.mock('../../src/Container', () => ({
Container: class MockContainer {
containerElement: unknown;
subscribe = subscribeMock;
destroy = destroyMock;
size = Object.freeze({ clientWidth: 100, clientHeight: 200, scrollWidth: 300, scrollHeight: 400 });
position = Object.freeze({ top: 10, left: 20 });
scrollVelocity = Object.freeze({ x: 1, y: 2 });
constructor(containerElement: unknown) {
this.containerElement = containerElement;
}
},
}));
import { ContainerProxy } from '../../src/ContainerProxy';
const fakeSm = (id = 1) => ({ id }) as unknown as ScrollMagic;
describe('ContainerProxy', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('attach subscribes to resize and scroll events', () => {
const proxy = new ContainerProxy(fakeSm());
const cb = vi.fn();
proxy.attach(document.createElement('div'), cb);
expect(subscribeMock).toHaveBeenCalledWith('resize', cb);
expect(subscribeMock).toHaveBeenCalledWith('scroll', cb);
proxy.detach();
});
test('two proxies sharing the same container element share size/position', () => {
const el = document.createElement('div');
const proxy1 = new ContainerProxy(fakeSm(1));
const proxy2 = new ContainerProxy(fakeSm(2));
proxy1.attach(el, vi.fn());
proxy2.attach(el, vi.fn());
// Both see the same underlying Container state
expect(proxy1.size).toEqual(proxy2.size);
expect(proxy1.position).toEqual(proxy2.position);
proxy1.detach();
proxy2.detach();
});
test('Container is destroyed only when the last proxy detaches', () => {
const el = document.createElement('div');
const proxy1 = new ContainerProxy(fakeSm(1));
const proxy2 = new ContainerProxy(fakeSm(2));
proxy1.attach(el, vi.fn());
proxy2.attach(el, vi.fn());
proxy1.detach();
expect(destroyMock).not.toHaveBeenCalled();
proxy2.detach();
expect(destroyMock).toHaveBeenCalledOnce();
});
test('detach unsubscribes from container events', () => {
const unsubscribe1 = vi.fn();
const unsubscribe2 = vi.fn();
subscribeMock.mockReturnValueOnce(unsubscribe1).mockReturnValueOnce(unsubscribe2);
const proxy = new ContainerProxy(fakeSm());
proxy.attach(document.createElement('div'), vi.fn());
proxy.detach();
expect(unsubscribe1).toHaveBeenCalledOnce();
expect(unsubscribe2).toHaveBeenCalledOnce();
});
test('detach on unattached proxy is a no-op', () => {
const proxy = new ContainerProxy(fakeSm());
expect(() => proxy.detach()).not.toThrow();
});
test('attach to a new element detaches from the previous one', () => {
const el1 = document.createElement('div');
const el2 = document.createElement('div');
const proxy = new ContainerProxy(fakeSm());
proxy.attach(el1, vi.fn());
proxy.attach(el2, vi.fn());
// First container destroyed (sole user detached)
expect(destroyMock).toHaveBeenCalledOnce();
// Still functional on new container
expect(() => proxy.size).not.toThrow();
proxy.detach();
});
test('size delegates to the underlying Container', () => {
const proxy = new ContainerProxy(fakeSm());
proxy.attach(document.createElement('div'), vi.fn());
expect(proxy.size).toEqual({ clientWidth: 100, clientHeight: 200, scrollWidth: 300, scrollHeight: 400 });
proxy.detach();
});
test('position delegates to the underlying Container', () => {
const proxy = new ContainerProxy(fakeSm());
proxy.attach(document.createElement('div'), vi.fn());
expect(proxy.position).toEqual({ top: 10, left: 20 });
proxy.detach();
});
test('scrollVelocity delegates to the underlying Container', () => {
const proxy = new ContainerProxy(fakeSm());
proxy.attach(document.createElement('div'), vi.fn());
expect(proxy.scrollVelocity).toEqual({ x: 1, y: 2 });
proxy.detach();
});
test('scrollVelocity returns zero when not attached', () => {
const proxy = new ContainerProxy(fakeSm());
expect(proxy.scrollVelocity).toEqual({ x: 0, y: 0 });
});
test('size throws when not attached', () => {
const proxy = new ContainerProxy(fakeSm());
expect(() => proxy.size).toThrow("Can't get size when not attached");
});
test('position throws when not attached', () => {
const proxy = new ContainerProxy(fakeSm());
expect(() => proxy.position).toThrow("Can't get position when not attached");
});
});
================================================
FILE: tests/unit/EventDispatcher.test.ts
================================================
import { describe, test, expect, vi } from 'vitest';
import { EventDispatcher, type DispatchableEvent } from '../../src/EventDispatcher';
interface TestEvent extends DispatchableEvent {
readonly target: unknown;
readonly type: 'foo' | 'bar';
readonly value?: number;
}
const event = (type: TestEvent['type'], value?: number): TestEvent => ({ target: null, type, value });
describe('EventDispatcher', () => {
test('calls listener on dispatch', () => {
const d = new EventDispatcher();
const cb = vi.fn();
d.addEventListener('foo', cb);
d.dispatchEvent(event('foo', 1));
expect(cb).toHaveBeenCalledOnce();
expect(cb).toHaveBeenCalledWith(expect.objectContaining({ type: 'foo', value: 1 }));
});
test('does not call listener for different event type', () => {
const d = new EventDispatcher();
const cb = vi.fn();
d.addEventListener('foo', cb);
d.dispatchEvent(event('bar'));
expect(cb).not.toHaveBeenCalled();
});
test('supports multiple listeners for same type', () => {
const d = new EventDispatcher();
const cb1 = vi.fn();
const cb2 = vi.fn();
d.addEventListener('foo', cb1);
d.addEventListener('foo', cb2);
d.dispatchEvent(event('foo'));
expect(cb1).toHaveBeenCalledOnce();
expect(cb2).toHaveBeenCalledOnce();
});
test('allows duplicate registrations (both fire)', () => {
const d = new EventDispatcher();
const cb = vi.fn();
d.addEventListener('foo', cb);
d.addEventListener('foo', cb);
d.dispatchEvent(event('foo'));
expect(cb).toHaveBeenCalledTimes(2);
});
test('removeEventListener stops future calls', () => {
const d = new EventDispatcher();
const cb = vi.fn();
d.addEventListener('foo', cb);
d.removeEventListener('foo', cb);
d.dispatchEvent(event('foo'));
expect(cb).not.toHaveBeenCalled();
});
test('addEventListener returns unsubscribe function', () => {
const d = new EventDispatcher();
const cb = vi.fn();
const unsub = d.addEventListener('foo', cb);
unsub();
d.dispatchEvent(event('foo'));
expect(cb).not.toHaveBeenCalled();
});
test('removing non-existent listener is a no-op', () => {
const d = new EventDispatcher();
expect(() => d.removeEventListener('foo', vi.fn())).not.toThrow();
});
test('dispatch with no listeners is a no-op', () => {
const d = new EventDispatcher();
expect(() => d.dispatchEvent(event('foo'))).not.toThrow();
});
test('once listener fires once then auto-removes', () => {
const d = new EventDispatcher();
const cb = vi.fn();
d.addEventListener('foo', cb, { once: true });
d.dispatchEvent(event('foo'));
d.dispatchEvent(event('foo'));
expect(cb).toHaveBeenCalledOnce();
});
test('once listener is removable via removeEventListener before firing', () => {
const d = new EventDispatcher();
const cb = vi.fn();
d.addEventListener('foo', cb, { once: true });
d.removeEventListener('foo', cb);
d.dispatchEvent(event('foo'));
expect(cb).not.toHaveBeenCalled();
});
test('once listener is removable via returned unsubscribe function', () => {
const d = new EventDispatcher();
const cb = vi.fn();
const unsub = d.addEventListener('foo', cb, { once: true });
unsub();
d.dispatchEvent(event('foo'));
expect(cb).not.toHaveBeenCalled();
});
test('removeEventListener after once listener already fired is a safe no-op', () => {
const d = new EventDispatcher();
const cb = vi.fn();
d.addEventListener('foo', cb, { once: true });
d.dispatchEvent(event('foo'));
expect(() => d.removeEventListener('foo', cb)).not.toThrow();
});
test('unsubscribe after once listener already fired is a safe no-op', () => {
const d = new EventDispatcher();
const cb = vi.fn();
const unsub = d.addEventListener('foo', cb, { once: true });
d.dispatchEvent(event('foo'));
expect(() => unsub()).not.toThrow();
});
test('once does not affect other listeners for the same type', () => {
const d = new EventDispatcher();
const onceCb = vi.fn();
const regularCb = vi.fn();
d.addEventListener('foo', onceCb, { once: true });
d.addEventListener('foo', regularCb);
d.dispatchEvent(event('foo'));
d.dispatchEvent(event('foo'));
expect(onceCb).toHaveBeenCalledOnce();
expect(regularCb).toHaveBeenCalledTimes(2);
});
test('same callback registered as once and regular — only the once registration auto-removes', () => {
const d = new EventDispatcher();
const cb = vi.fn();
d.addEventListener('foo', cb); // regular
d.addEventListener('foo', cb, { once: true }); // once
d.dispatchEvent(event('foo'));
expect(cb).toHaveBeenCalledTimes(2); // both fire on first dispatch
d.dispatchEvent(event('foo'));
expect(cb).toHaveBeenCalledTimes(3); // only regular fires on second dispatch
});
test('listener added during dispatch does not fire in the same cycle', () => {
const d = new EventDispatcher();
const laterCb = vi.fn();
d.addEventListener('foo', () => {
d.addEventListener('foo', laterCb);
});
d.dispatchEvent(event('foo'));
expect(laterCb).not.toHaveBeenCalled(); // not fired in same dispatch
d.dispatchEvent(event('foo'));
expect(laterCb).toHaveBeenCalledOnce(); // fires on next dispatch
});
test('same callback registered as once and regular — removeEventListener removes first match', () => {
const d = new EventDispatcher();
const cb = vi.fn();
d.addEventListener('foo', cb); // regular (first)
d.addEventListener('foo', cb, { once: true }); // once (second)
d.removeEventListener('foo', cb); // removes the regular one (first match)
d.dispatchEvent(event('foo'));
expect(cb).toHaveBeenCalledOnce(); // once registration fires
d.dispatchEvent(event('foo'));
expect(cb).toHaveBeenCalledOnce(); // then auto-removed
});
test('signal: listener is removed when signal aborts', () => {
const d = new EventDispatcher();
const ac = new AbortController();
const cb = vi.fn();
d.addEventListener('foo', cb, { signal: ac.signal });
d.dispatchEvent(event('foo'));
expect(cb).toHaveBeenCalledOnce();
ac.abort();
d.dispatchEvent(event('foo'));
expect(cb).toHaveBeenCalledOnce(); // not called again
});
test('signal: listener not added if signal already aborted', () => {
const d = new EventDispatcher();
const ac = new AbortController();
ac.abort();
const cb = vi.fn();
d.addEventListener('foo', cb, { signal: ac.signal });
d.dispatchEvent(event('foo'));
expect(cb).not.toHaveBeenCalled();
});
test('signal: multiple listeners removed by single abort', () => {
const d = new EventDispatcher();
const ac = new AbortController();
const cb1 = vi.fn();
const cb2 = vi.fn();
d.addEventListener('foo', cb1, { signal: ac.signal });
d.addEventListener('bar', cb2, { signal: ac.signal });
ac.abort();
d.dispatchEvent(event('foo'));
d.dispatchEvent(event('bar'));
expect(cb1).not.toHaveBeenCalled();
expect(cb2).not.toHaveBeenCalled();
});
test('signal + once: both mechanisms coexist', () => {
const d = new EventDispatcher();
const ac = new AbortController();
const cb = vi.fn();
d.addEventListener('foo', cb, { once: true, signal: ac.signal });
d.dispatchEvent(event('foo'));
expect(cb).toHaveBeenCalledOnce();
// once already removed it — abort is a safe no-op
expect(() => ac.abort()).not.toThrow();
});
test('signal: abort before dispatch, once listener never fires', () => {
const d = new EventDispatcher();
const ac = new AbortController();
const cb = vi.fn();
d.addEventListener('foo', cb, { once: true, signal: ac.signal });
ac.abort();
d.dispatchEvent(event('foo'));
expect(cb).not.toHaveBeenCalled();
});
test('signal: abort during dispatch still fires remaining listeners in snapshot', () => {
const d = new EventDispatcher();
const ac = new AbortController();
const cb1 = vi.fn(() => ac.abort());
const cb2 = vi.fn();
d.addEventListener('foo', cb1, { signal: ac.signal });
d.addEventListener('foo', cb2, { signal: ac.signal });
d.dispatchEvent(event('foo'));
// both fire in the current cycle (snapshot iteration)
expect(cb1).toHaveBeenCalledOnce();
expect(cb2).toHaveBeenCalledOnce();
// but neither fires again — abort removed them
d.dispatchEvent(event('foo'));
expect(cb1).toHaveBeenCalledOnce();
expect(cb2).toHaveBeenCalledOnce();
});
test('signal: manual removal then abort is safe', () => {
const d = new EventDispatcher();
const ac = new AbortController();
const cb = vi.fn();
const unsub = d.addEventListener('foo', cb, { signal: ac.signal });
unsub();
expect(() => ac.abort()).not.toThrow();
d.dispatchEvent(event('foo'));
expect(cb).not.toHaveBeenCalled();
});
});
================================================
FILE: tests/unit/ExecutionQueue.test.ts
================================================
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExecutionQueue } from '../../src/ExecutionQueue';
import { rafQueue } from '../../src/util/rafQueue';
describe('ExecutionQueue', () => {
beforeEach(() => {
vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
test('commands execute in insertion order regardless of scheduling order', () => {
const order: string[] = [];
const queue = new ExecutionQueue({
a: () => order.push('a'),
b: () => order.push('b'),
c: () => order.push('c'),
});
queue.commands.c.schedule();
queue.commands.a.schedule();
queue.commands.b.schedule();
rafQueue.flush();
expect(order).toEqual(['a', 'b', 'c']);
});
test('unscheduled commands are not executed', () => {
const a = vi.fn();
const b = vi.fn();
const queue = new ExecutionQueue({ a, b });
queue.commands.a.schedule();
// b is not scheduled
rafQueue.flush();
expect(a).toHaveBeenCalledOnce();
expect(b).not.toHaveBeenCalled();
});
test('conditional execution: condition met executes, condition not met skips', () => {
const cb = vi.fn();
const queue = new ExecutionQueue({ a: cb });
queue.commands.a.schedule(() => false);
rafQueue.flush();
expect(cb).not.toHaveBeenCalled();
queue.commands.a.schedule(() => true);
rafQueue.flush();
expect(cb).toHaveBeenCalledOnce();
});
test('unconditional schedule clears previous conditions', () => {
const cb = vi.fn();
const queue = new ExecutionQueue({ a: cb });
// first schedule with a condition that would fail
queue.commands.a.schedule(() => false);
// second schedule without condition (unconditional) — should override
queue.commands.a.schedule();
rafQueue.flush();
expect(cb).toHaveBeenCalledOnce();
});
test('multiple conditions: any true condition causes execution', () => {
const cb = vi.fn();
const queue = new ExecutionQueue({ a: cb });
queue.commands.a.schedule(() => false);
queue.commands.a.schedule(() => true);
rafQueue.flush();
expect(cb).toHaveBeenCalledOnce();
});
test('cancel prevents execution', () => {
const cb = vi.fn();
const queue = new ExecutionQueue({ a: cb });
queue.commands.a.schedule();
queue.cancel();
rafQueue.flush();
expect(cb).not.toHaveBeenCalled();
});
test('scheduling triggers the rafQueue', () => {
const scheduleSpy = vi.spyOn(rafQueue, 'schedule');
const queue = new ExecutionQueue({ a: vi.fn() });
queue.commands.a.schedule();
expect(scheduleSpy).toHaveBeenCalledWith(queue);
});
test('command scheduled multiple times executes only once per flush', () => {
const cb = vi.fn();
const queue = new ExecutionQueue({ a: cb });
queue.commands.a.schedule();
queue.commands.a.schedule();
queue.commands.a.schedule();
rafQueue.flush();
expect(cb).toHaveBeenCalledOnce();
});
test('conditions are reset after execution', () => {
const cb = vi.fn();
const queue = new ExecutionQueue({ a: cb });
queue.commands.a.schedule();
rafQueue.flush();
expect(cb).toHaveBeenCalledOnce();
// second flush without re-scheduling — should not execute
cb.mockClear();
rafQueue.flush();
expect(cb).not.toHaveBeenCalled();
});
});
================================================
FILE: tests/unit/Options.processors.test.ts
================================================
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { sanitizeOptions, processOptions } from '../../src/Options.processors';
import { defaults } from '../../src/Options';
import type { Public } from '../../src/Options';
// NOTE: jsdom's window doesn't pass `instanceof Window`, so tests that rely on
// window as container (the default) are limited. Window-container behavior is
// covered by e2e tests. Here we focus on explicit HTMLElement containers.
// Mirrors what the ScrollMagic constructor does: spreads defaults before processing.
const fullOptions = (overrides: Public = {}): Required => ({
...defaults,
...overrides,
});
describe('sanitizeOptions', () => {
afterEach(() => {
vi.restoreAllMocks();
});
test('keeps known ScrollMagic options', () => {
const result = sanitizeOptions({ element: null, vertical: false });
expect(result).toEqual({ element: null, vertical: false });
});
test('strips unknown options', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = sanitizeOptions({ element: null, bogus: 42 } as never);
expect('bogus' in result).toBe(false);
});
test('warns about unknown options', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sanitizeOptions({ nonsense: true } as never);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonsense'));
});
});
describe('processOptions', () => {
let container: HTMLElement;
let element: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
element = document.createElement('div');
container.appendChild(element);
document.body.appendChild(container);
});
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
test('transforms valid options into processed form', () => {
const { processed } = processOptions(fullOptions({ element, container, vertical: true }));
expect(processed.element).toBe(element);
expect(processed.container).toBe(container);
expect(processed.vertical).toBe(true);
expect(typeof processed.elementStart).toBe('function');
expect(typeof processed.elementEnd).toBe('function');
expect(typeof processed.containerStart).toBe('function');
expect(typeof processed.containerEnd).toBe('function');
});
test('default elementStart and elementEnd return 0', () => {
const { processed } = processOptions(fullOptions({ element, container }));
expect(processed.elementStart(100)).toBe(0);
expect(processed.elementEnd(100)).toBe(0);
});
test('resolves CSS selector for element', () => {
element.id = 'tracked';
const { processed } = processOptions(fullOptions({ element: '#tracked', container }));
expect(processed.element).toBe(element);
});
test('resolves CSS selector for container', () => {
container.id = 'scroll-parent';
const { processed } = processOptions(fullOptions({ element, container: '#scroll-parent' }));
expect(processed.container).toBe(container);
});
test('null element defaults to first child of container', () => {
const { processed } = processOptions(fullOptions({ element: null, container }));
expect(processed.element).toBe(element);
});
test('throws when container has no valid children for element inference', () => {
const empty = document.createElement('div');
document.body.appendChild(empty);
expect(() => processOptions(fullOptions({ element: null, container: empty }))).toThrow(
'Could not autodetect element, as container has no valid children'
);
});
test('containerStart/End default to 100% when element is explicit', () => {
const { processed } = processOptions(fullOptions({ element, container }));
expect(processed.containerStart(800)).toBe(800);
expect(processed.containerEnd(800)).toBe(800);
});
test('containerStart/End default to 0 when element is null (first-child fallback)', () => {
const { processed } = processOptions(fullOptions({ element: null, container }));
expect(processed.containerStart(800)).toBe(0);
expect(processed.containerEnd(800)).toBe(0);
});
test('explicit containerStart/End values are preserved', () => {
const { processed } = processOptions(
fullOptions({
element,
container,
containerStart: '50%',
containerEnd: 100,
})
);
expect(processed.containerStart(400)).toBe(200);
expect(processed.containerEnd(400)).toBe(100);
});
test('preserves oldOptions for unspecified keys (modify scenario)', () => {
const { processed: initial } = processOptions(fullOptions({ element, container, vertical: false }));
// modify: only changing vertical, the rest comes from oldOptions
const { processed: modified } = processOptions({ vertical: true }, initial);
expect(modified.element).toBe(element);
expect(modified.container).toBe(container);
expect(modified.vertical).toBe(true);
});
test('returns sanitized options alongside processed ones', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const opts = { ...fullOptions({ element, container }), bogus: 1 };
const { sanitized } = processOptions(opts as unknown as Required);
expect('bogus' in sanitized).toBe(false);
expect(sanitized.element).toBe(element);
});
test('vertical defaults to true', () => {
const { processed } = processOptions(fullOptions({ element, container }));
expect(processed.vertical).toBe(true);
});
test('vertical false is preserved', () => {
const { processed } = processOptions(fullOptions({ element, container, vertical: false }));
expect(processed.vertical).toBe(false);
});
describe('sanity checks', () => {
test('warns when element is not a descendant of container', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const orphan = document.createElement('div');
document.body.appendChild(orphan);
const separate = document.createElement('div');
document.body.appendChild(separate);
processOptions(fullOptions({ element: orphan, container: separate }));
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('element is not a descendant of container'),
expect.any(Object)
);
});
test('does not warn when element is inside container', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processOptions(fullOptions({ element, container }));
expect(errorSpy).not.toHaveBeenCalled();
});
test('warns when configured offsets produce no overlap', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
processOptions(
fullOptions({
element,
container,
elementStart: 99999,
elementEnd: 99999,
})
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('no overlap'), expect.any(Object));
});
});
});
================================================
FILE: tests/unit/ScrollMagicError.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { ScrollMagicError, ScrollMagicInternalError } from '../../src/ScrollMagicError';
describe('ScrollMagicError', () => {
test('has correct name', () => {
const err = new ScrollMagicError('test');
expect(err.name).toBe('ScrollMagicError');
});
test('is instanceof Error', () => {
const err = new ScrollMagicError('test');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(ScrollMagicError);
});
test('has correct message', () => {
const err = new ScrollMagicError('something broke');
expect(err.message).toBe('something broke');
});
test('supports cause option', () => {
const cause = new Error('root');
const err = new ScrollMagicError('wrapped', { cause });
expect(err.cause).toBe(cause);
});
test('has Symbol.toStringTag', () => {
const err = new ScrollMagicError('test');
expect(Object.prototype.toString.call(err)).toBe('[object ScrollMagicError]');
});
});
describe('ScrollMagicInternalError', () => {
test('has correct name', () => {
const err = new ScrollMagicInternalError('oops');
expect(err.name).toBe('ScrollMagicInternalError');
});
test('prepends Internal Error to message', () => {
const err = new ScrollMagicInternalError('oops');
expect(err.message).toBe('Internal Error: oops');
});
test('is instanceof both error classes', () => {
const err = new ScrollMagicInternalError('oops');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(ScrollMagicError);
expect(err).toBeInstanceOf(ScrollMagicInternalError);
});
test('distinguishable from ScrollMagicError via instanceof', () => {
const regular = new ScrollMagicError('a');
const internal = new ScrollMagicInternalError('b');
expect(regular).not.toBeInstanceOf(ScrollMagicInternalError);
expect(internal).toBeInstanceOf(ScrollMagicError);
});
});
================================================
FILE: tests/unit/ScrollMagicEvent.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { ScrollMagicEvent, EventType, EventLocation, ScrollDirection } from '../../src/ScrollMagicEvent';
import type { ScrollMagic } from '../../src/ScrollMagic';
// minimal stub — only used as the `target` reference
const fakeTarget = {} as ScrollMagic;
describe('ScrollMagicEvent', () => {
describe('location', () => {
test('enter while scrolling forward → start', () => {
const event = new ScrollMagicEvent(fakeTarget, EventType.Enter, true);
expect(event.location).toBe('start');
});
test('enter while scrolling reverse → end', () => {
const event = new ScrollMagicEvent(fakeTarget, EventType.Enter, false);
expect(event.location).toBe('end');
});
test('leave while scrolling forward → end', () => {
const event = new ScrollMagicEvent(fakeTarget, EventType.Leave, true);
expect(event.location).toBe('end');
});
test('leave while scrolling reverse → start', () => {
const event = new ScrollMagicEvent(fakeTarget, EventType.Leave, false);
expect(event.location).toBe('start');
});
test('progress is always inside', () => {
expect(new ScrollMagicEvent(fakeTarget, EventType.Progress, true).location).toBe('inside');
expect(new ScrollMagicEvent(fakeTarget, EventType.Progress, false).location).toBe('inside');
});
});
describe('direction', () => {
test('forward when scrolling forward', () => {
const event = new ScrollMagicEvent(fakeTarget, EventType.Enter, true);
expect(event.direction).toBe('forward');
});
test('reverse when scrolling reverse', () => {
const event = new ScrollMagicEvent(fakeTarget, EventType.Enter, false);
expect(event.direction).toBe('reverse');
});
});
test('preserves target and type', () => {
const event = new ScrollMagicEvent(fakeTarget, EventType.Progress, true);
expect(event.target).toBe(fakeTarget);
expect(event.type).toBe('progress');
});
test('enum values match their string literals', () => {
expect(EventType.Enter).toBe('enter');
expect(EventType.Leave).toBe('leave');
expect(EventType.Progress).toBe('progress');
expect(EventLocation.Start).toBe('start');
expect(EventLocation.Inside).toBe('inside');
expect(EventLocation.End).toBe('end');
expect(ScrollDirection.Forward).toBe('forward');
expect(ScrollDirection.Reverse).toBe('reverse');
});
});
================================================
FILE: tests/unit/agnosticValues.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { agnosticProps, agnosticValues } from '../../src/util/agnosticValues';
describe('agnosticProps', () => {
test('returns vertical props when vertical=true', () => {
const props = agnosticProps(true);
expect(props.start).toBe('top');
expect(props.end).toBe('bottom');
expect(props.size).toBe('height');
expect(props.clientSize).toBe('clientHeight');
expect(props.scrollSize).toBe('scrollHeight');
expect(props.axis).toBe('y');
});
test('returns horizontal props when vertical=false', () => {
const props = agnosticProps(false);
expect(props.start).toBe('left');
expect(props.end).toBe('right');
expect(props.size).toBe('width');
expect(props.clientSize).toBe('clientWidth');
expect(props.scrollSize).toBe('scrollWidth');
expect(props.axis).toBe('x');
});
});
describe('agnosticValues', () => {
const rect = { top: 10, left: 20, bottom: 30, right: 40, height: 100, width: 200 };
test('extracts vertical values', () => {
const vals = agnosticValues(true, rect);
expect(vals.start).toBe(10); // top
expect(vals.end).toBe(30); // bottom
expect(vals.size).toBe(100); // height
});
test('extracts horizontal values', () => {
const vals = agnosticValues(false, rect);
expect(vals.start).toBe(20); // left
expect(vals.end).toBe(40); // right
expect(vals.size).toBe(200); // width
});
test('handles scroll container dimensions', () => {
const dims = { clientHeight: 500, clientWidth: 300, scrollHeight: 2000, scrollWidth: 600 };
const vVals = agnosticValues(true, dims);
expect(vVals.clientSize).toBe(500);
expect(vVals.scrollSize).toBe(2000);
const hVals = agnosticValues(false, dims);
expect(hVals.clientSize).toBe(300);
expect(hVals.scrollSize).toBe(600);
});
});
================================================
FILE: tests/unit/getScrollContainerDimensions.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { getScrollContainerDimensions } from '../../src/util/getScrollContainerDimensions';
// NOTE: jsdom's window doesn't pass `instanceof Window`, so window-branch behavior
// (documentElement fallback, visualViewport) is covered by e2e tests.
describe('getScrollContainerDimensions', () => {
test('returns all four dimension properties for an element', () => {
const el = document.createElement('div');
Object.defineProperty(el, 'clientWidth', { value: 400 });
Object.defineProperty(el, 'clientHeight', { value: 300 });
Object.defineProperty(el, 'scrollWidth', { value: 800 });
Object.defineProperty(el, 'scrollHeight', { value: 1200 });
const dims = getScrollContainerDimensions(el);
expect(dims).toEqual({
clientWidth: 400,
clientHeight: 300,
scrollWidth: 800,
scrollHeight: 1200,
});
});
test('does not use visualViewport for element containers', () => {
const el = document.createElement('div');
Object.defineProperty(el, 'clientHeight', { value: 300 });
const dims = getScrollContainerDimensions(el);
expect(dims.clientHeight).toBe(300);
});
});
================================================
FILE: tests/unit/getScrollPos.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { getScrollPos } from '../../src/util/getScrollPos';
// NOTE: jsdom's window doesn't pass `instanceof Window`, so the window branch
// of getScrollPos is covered by e2e tests. Here we test the element branch.
describe('getScrollPos', () => {
test('returns scroll position for element', () => {
const el = document.createElement('div');
Object.defineProperty(el, 'scrollTop', { value: 150, writable: true });
Object.defineProperty(el, 'scrollLeft', { value: 75, writable: true });
const pos = getScrollPos(el);
expect(pos).toEqual({ left: 75, top: 150 });
});
test('returns { left: 0, top: 0 } for element with no scroll', () => {
const el = document.createElement('div');
const pos = getScrollPos(el);
expect(pos).toEqual({ left: 0, top: 0 });
});
});
================================================
FILE: tests/unit/pickDifferencesFlat.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { pickDifferencesFlat } from '../../src/util/pickDifferencesFlat';
describe('pickDifferencesFlat', () => {
test('returns only changed properties', () => {
const full = { a: 1, b: 2, c: 3 };
const part = { a: 1, b: 99 };
expect(pickDifferencesFlat(part, full)).toEqual({ b: 99 });
});
test('returns empty object when nothing changed', () => {
const full = { a: 1, b: 2 };
const part = { a: 1, b: 2 };
expect(pickDifferencesFlat(part, full)).toEqual({});
});
test('returns all properties when everything changed', () => {
const full = { a: 1, b: 2 };
const part = { a: 10, b: 20 };
expect(pickDifferencesFlat(part, full)).toEqual({ a: 10, b: 20 });
});
test('uses strict equality (not deep)', () => {
const obj = { x: 1 };
const full = { a: obj };
const part = { a: { x: 1 } }; // different reference, same content
expect(pickDifferencesFlat(part, full)).toEqual({ a: { x: 1 } });
});
test('handles empty partial', () => {
expect(pickDifferencesFlat({}, { a: 1 })).toEqual({});
});
});
================================================
FILE: tests/unit/processProperties.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { processProperties } from '../../src/util/processProperties';
import { ScrollMagicError } from '../../src/ScrollMagicError';
describe('processProperties', () => {
test('applies processor to matching property', () => {
const result = processProperties({ count: '5' }, { count: (v: string) => parseInt(v, 10) });
expect(result).toEqual({ count: 5 });
});
test('passes through properties without a processor', () => {
const result = processProperties({ a: 1, b: 2 }, { a: (v: number) => v * 10 });
expect(result).toEqual({ a: 10, b: 2 });
});
test('throws ScrollMagicError when processor fails', () => {
const processors = {
val: () => {
throw new Error('nope');
},
};
expect(() => processProperties({ val: 'x' }, processors)).toThrow(ScrollMagicError);
});
test('error message includes property name and value', () => {
const processors = {
myProp: () => {
throw new Error('nope');
},
};
expect(() => processProperties({ myProp: 'bad' }, processors)).toThrow(/Invalid value bad for myProp/);
});
test('appends original message when processor throws ScrollMagicError', () => {
const processors = {
val: () => {
throw new ScrollMagicError('must be positive');
},
};
expect(() => processProperties({ val: -1 }, processors)).toThrow(/must be positive/);
});
test('uses custom error message formatter when provided', () => {
const processors = {
x: () => {
throw new Error('boom');
},
};
const formatter = (value: unknown, prop: unknown) => `Broken: ${String(prop)}=${String(value)}.`;
expect(() => processProperties({ x: 42 }, processors, formatter)).toThrow('Broken: x=42.');
});
test('chains original error as cause', () => {
const original = new Error('root cause');
const processors = {
x: () => {
throw original;
},
};
try {
processProperties({ x: 1 }, processors);
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(ScrollMagicError);
expect((e as ScrollMagicError).cause).toBe(original);
}
});
});
================================================
FILE: tests/unit/rafQueue.test.ts
================================================
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { rafQueue } from '../../src/util/rafQueue';
const flushable = (fn = vi.fn()) => ({ execute: fn });
describe('Scheduler', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
test('schedule requests a single rAF regardless of item count', () => {
const spy = vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);
const a = flushable();
const b = flushable();
rafQueue.schedule(a);
rafQueue.schedule(b);
expect(spy).toHaveBeenCalledTimes(1);
// cleanup
rafQueue.flush();
});
test('flush executes all dirty items', () => {
const a = flushable();
const b = flushable();
vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);
rafQueue.schedule(a);
rafQueue.schedule(b);
rafQueue.flush();
expect(a.execute).toHaveBeenCalledOnce();
expect(b.execute).toHaveBeenCalledOnce();
});
test('flush cancels pending rAF', () => {
const cancelSpy = vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {});
vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(42);
rafQueue.schedule(flushable());
rafQueue.flush();
expect(cancelSpy).toHaveBeenCalledWith(42);
});
test('after flush, dirty set is empty — subsequent rAF is a no-op', () => {
const a = flushable();
vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);
rafQueue.schedule(a);
rafQueue.flush();
a.execute.mockClear();
// simulate rAF callback (would have been cancelled, but let's verify no-op)
rafQueue.flush();
expect(a.execute).not.toHaveBeenCalled();
});
test('unschedule prevents execution', () => {
const a = flushable();
vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);
rafQueue.schedule(a);
rafQueue.unschedule(a);
rafQueue.flush();
expect(a.execute).not.toHaveBeenCalled();
});
test('multiple schedule calls for same item execute it only once', () => {
const a = flushable();
vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);
rafQueue.schedule(a);
rafQueue.schedule(a);
rafQueue.schedule(a);
rafQueue.flush();
expect(a.execute).toHaveBeenCalledOnce();
});
test('items scheduled during flush get a new rAF', () => {
const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);
const b = flushable();
const a = flushable(vi.fn(() => rafQueue.schedule(b)));
rafQueue.schedule(a);
rafSpy.mockClear();
rafQueue.flush();
expect(a.execute).toHaveBeenCalledOnce();
// b was scheduled during flush — should NOT have been executed in the same flush
expect(b.execute).not.toHaveBeenCalled();
// but a new rAF should have been requested
expect(rafSpy).toHaveBeenCalledTimes(1);
// cleanup
rafQueue.flush();
});
test('rAF callback triggers flush', () => {
let rafCallback: FrameRequestCallback | undefined;
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallback = cb;
return 1;
});
const a = flushable();
rafQueue.schedule(a);
expect(a.execute).not.toHaveBeenCalled();
rafCallback!(0);
expect(a.execute).toHaveBeenCalledOnce();
});
});
================================================
FILE: tests/unit/registerEvent.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { registerEvent } from '../../src/util/registerEvent';
describe('registerEvent', () => {
test('listener receives dispatched events', () => {
const target = document.createElement('div');
let received = false;
registerEvent(target, 'click', () => {
received = true;
});
target.dispatchEvent(new Event('click'));
expect(received).toBe(true);
});
test('returned function removes the listener', () => {
const target = document.createElement('div');
let callCount = 0;
const remove = registerEvent(target, 'click', () => {
callCount++;
});
target.dispatchEvent(new Event('click'));
remove();
target.dispatchEvent(new Event('click'));
expect(callCount).toBe(1);
});
test('respects listener options', () => {
const target = document.createElement('div');
let callCount = 0;
registerEvent(
target,
'click',
() => {
callCount++;
},
{ once: true }
);
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('click'));
expect(callCount).toBe(1);
});
});
================================================
FILE: tests/unit/sanitizeProperties.test.ts
================================================
import { describe, test, expect, vi, afterEach } from 'vitest';
import { sanitizeProperties } from '../../src/util/sanitizeProperties';
describe('sanitizeProperties', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const defaults = { name: '', age: 0, active: false };
test('keeps properties that exist in defaults', () => {
const result = sanitizeProperties({ name: 'Alice', age: 30 }, defaults);
expect(result).toEqual({ name: 'Alice', age: 30 });
});
test('removes properties not in defaults', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = sanitizeProperties({ name: 'Alice', unknown: 'value' } as never, defaults);
expect(result).toEqual({ name: 'Alice' });
expect('unknown' in result).toBe(false);
});
test('warns about unknown properties in dev mode', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
sanitizeProperties({ name: 'Alice', foo: 1, bar: 2 } as never, defaults);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('foo'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('bar'));
});
test('returns empty object when all properties are unknown', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = sanitizeProperties({ x: 1 } as never, defaults);
expect(result).toEqual({});
});
test('returns empty object for empty input', () => {
const result = sanitizeProperties({} as never, defaults);
expect(result).toEqual({});
});
});
================================================
FILE: tests/unit/sharedResizeObserver.test.ts
================================================
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { rafQueue } from '../../src/util/rafQueue';
import { observeResize } from '../../src/util/sharedResizeObserver';
// Mock ResizeObserver — the shared observer creates it lazily, so this runs before first use.
const observeMock = vi.fn();
const unobserveMock = vi.fn();
let roCallback: ResizeObserverCallback;
class MockResizeObserver {
constructor(cb: ResizeObserverCallback) {
roCallback = cb;
}
observe = observeMock;
unobserve = unobserveMock;
disconnect = vi.fn();
}
vi.stubGlobal('ResizeObserver', MockResizeObserver);
const makeElement = () => document.createElement('div');
// Helper to simulate a resize entry for an element
const simulateResize = (...elements: Element[]) => {
const entries = elements.map(target => ({ target }) as ResizeObserverEntry);
roCallback(entries, {} as ResizeObserver);
};
describe('sharedResizeObserver', () => {
beforeEach(() => {
vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {});
observeMock.mockClear();
unobserveMock.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('observeResize starts observing the element', () => {
const el = makeElement();
const cleanup = observeResize(el, vi.fn());
expect(observeMock).toHaveBeenCalledWith(el);
cleanup();
});
test('multiple callbacks for same element: element observed only once', () => {
const el = makeElement();
const c1 = observeResize(el, vi.fn());
const c2 = observeResize(el, vi.fn());
expect(observeMock).toHaveBeenCalledTimes(1);
c1();
c2();
});
test('all callbacks for an element fire on resize', () => {
const el = makeElement();
const cbA = vi.fn();
const cbB = vi.fn();
const c1 = observeResize(el, cbA);
const c2 = observeResize(el, cbB);
simulateResize(el);
expect(cbA).toHaveBeenCalledOnce();
expect(cbB).toHaveBeenCalledOnce();
c1();
c2();
});
test('cleanup removes callback; element unobserved when last callback removed', () => {
const el = makeElement();
const cbA = vi.fn();
const cbB = vi.fn();
const cleanupA = observeResize(el, cbA);
const cleanupB = observeResize(el, cbB);
cleanupA();
expect(unobserveMock).not.toHaveBeenCalled(); // still has cbB
simulateResize(el);
expect(cbA).not.toHaveBeenCalled(); // removed
expect(cbB).toHaveBeenCalledOnce(); // still active
cleanupB();
expect(unobserveMock).toHaveBeenCalledWith(el); // last callback removed
});
test('cleanup is idempotent', () => {
const el = makeElement();
const cleanup = observeResize(el, vi.fn());
cleanup();
expect(unobserveMock).toHaveBeenCalledTimes(1);
cleanup(); // second call should be no-op
expect(unobserveMock).toHaveBeenCalledTimes(1);
});
test('rafQueue.flush is called after all callbacks', () => {
const flushSpy = vi.spyOn(rafQueue, 'flush');
const el = makeElement();
const cb = vi.fn();
const cleanup = observeResize(el, cb);
simulateResize(el);
expect(cb).toHaveBeenCalledOnce();
expect(flushSpy).toHaveBeenCalledOnce();
cleanup();
});
test('resize entries for multiple elements route correctly', () => {
const el1 = makeElement();
const el2 = makeElement();
const cb1 = vi.fn();
const cb2 = vi.fn();
const c1 = observeResize(el1, cb1);
const c2 = observeResize(el2, cb2);
simulateResize(el1);
expect(cb1).toHaveBeenCalledOnce();
expect(cb2).not.toHaveBeenCalled();
cb1.mockClear();
simulateResize(el1, el2);
expect(cb1).toHaveBeenCalledOnce();
expect(cb2).toHaveBeenCalledOnce();
c1();
c2();
});
});
================================================
FILE: tests/unit/throttleRaf.test.ts
================================================
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { throttleRaf } from '../../src/util/throttleRaf';
describe('throttleRaf', () => {
let rafCallbacks: Map;
let nextId: number;
beforeEach(() => {
rafCallbacks = new Map();
nextId = 1;
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => {
const id = nextId++;
rafCallbacks.set(id, cb);
return id;
});
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => {
rafCallbacks.delete(id);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
const flushRaf = () => {
const pending = [...rafCallbacks.values()];
rafCallbacks.clear();
for (const cb of pending) {
cb(performance.now());
}
};
test('does not call function synchronously', () => {
const fn = vi.fn();
const throttled = throttleRaf(fn);
throttled();
expect(fn).not.toHaveBeenCalled();
});
test('calls function on next animation frame', () => {
const fn = vi.fn();
const throttled = throttleRaf(fn);
throttled();
flushRaf();
expect(fn).toHaveBeenCalledOnce();
});
test('collapses multiple calls into a single execution per frame', () => {
const fn = vi.fn();
const throttled = throttleRaf(fn);
throttled();
throttled();
throttled();
flushRaf();
expect(fn).toHaveBeenCalledOnce();
});
test('can schedule again after frame fires', () => {
const fn = vi.fn();
const throttled = throttleRaf(fn);
throttled();
flushRaf();
throttled();
flushRaf();
expect(fn).toHaveBeenCalledTimes(2);
});
test('cancel prevents pending execution', () => {
const fn = vi.fn();
const throttled = throttleRaf(fn);
throttled();
throttled.cancel();
flushRaf();
expect(fn).not.toHaveBeenCalled();
});
test('can schedule again after cancel', () => {
const fn = vi.fn();
const throttled = throttleRaf(fn);
throttled();
throttled.cancel();
throttled();
flushRaf();
expect(fn).toHaveBeenCalledOnce();
});
test('passes arguments from the first call in the batch', () => {
const fn = vi.fn();
const throttled = throttleRaf(fn);
throttled('first');
throttled('second'); // dropped — already scheduled
flushRaf();
expect(fn).toHaveBeenCalledWith('first');
});
test('preserves this context', () => {
const context = { name: 'ctx', called: false };
const throttled = throttleRaf(function (this: typeof context) {
this.called = true;
});
throttled.call(context);
flushRaf();
expect(context.called).toBe(true);
});
});
================================================
FILE: tests/unit/transformObject.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { transformObject } from '../../src/util/transformObject';
describe('transformObject', () => {
test('transforms values using the provided function', () => {
const input = { a: 1, b: 2, c: 3 };
const result = transformObject(input, ([key, value]) => [key, value * 10]);
expect(result).toEqual({ a: 10, b: 20, c: 30 });
});
test('transforms keys using the provided function', () => {
const input = { a: 1, b: 2 };
const result = transformObject(input, ([key, value]) => [`${String(key)}_new`, value]);
expect(result).toEqual({ a_new: 1, b_new: 2 });
});
test('returns empty object for empty input', () => {
const result = transformObject({}, ([key, value]) => [key, value]);
expect(result).toEqual({});
});
test('can transform both keys and values simultaneously', () => {
const input = { x: 'hello', y: 'world' };
const result = transformObject(input, ([key, value]) => [
String(key).toUpperCase(),
String(value).toUpperCase(),
]);
expect(result).toEqual({ X: 'HELLO', Y: 'WORLD' });
});
});
================================================
FILE: tests/unit/transformers.test.ts
================================================
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import {
numberToPercString,
unitStringToPixelConverter,
toPixelConverter,
selectorToSingleElement,
toSvgOrHtmlElement,
toValidContainer,
skipNull,
} from '../../src/util/transformers';
describe('numberToPercString', () => {
test('converts decimal to percentage string', () => {
expect(numberToPercString(0.5, 2)).toBe('50.00%');
expect(numberToPercString(1, 0)).toBe('100%');
expect(numberToPercString(0, 2)).toBe('0.00%');
});
test('handles negative values', () => {
expect(numberToPercString(-0.25, 1)).toBe('-25.0%');
});
});
describe('unitStringToPixelConverter', () => {
test('parses px values', () => {
const conv = unitStringToPixelConverter('20px');
expect(conv(100)).toBe(20);
expect(conv(500)).toBe(20); // px is absolute
});
test('parses percentage values', () => {
const conv = unitStringToPixelConverter('50%');
expect(conv(200)).toBe(100);
expect(conv(400)).toBe(200);
});
test('parses negative values', () => {
const conv = unitStringToPixelConverter('-10px');
expect(conv(100)).toBe(-10);
});
test('throws on invalid string', () => {
expect(() => unitStringToPixelConverter('abc')).toThrow();
});
});
describe('toPixelConverter', () => {
test('wraps number as constant converter', () => {
const conv = toPixelConverter(42);
expect(conv(0)).toBe(42);
expect(conv(999)).toBe(42);
});
test('parses unit string', () => {
const conv = toPixelConverter('25%');
expect(conv(200)).toBe(50);
});
test('handles "here" shorthand (0%)', () => {
const conv = toPixelConverter('here');
expect(conv(200)).toBe(0);
expect(conv(400)).toBe(0);
});
test('handles "center" shorthand (50%)', () => {
const conv = toPixelConverter('center');
expect(conv(200)).toBe(100);
expect(conv(400)).toBe(200);
});
test('handles "opposite" shorthand (100%)', () => {
const conv = toPixelConverter('opposite');
expect(conv(200)).toBe(200);
expect(conv(400)).toBe(400);
});
test('accepts valid function', () => {
const fn = (size: number) => size * 2;
const conv = toPixelConverter(fn);
expect(conv(50)).toBe(100);
});
test('throws on function that does not return number', () => {
const fn = () => 'nope' as unknown as number;
expect(() => toPixelConverter(fn)).toThrow('Function must return a number');
});
test('throws on function that throws', () => {
const fn = () => {
throw new Error('boom');
};
expect(() => toPixelConverter(fn)).toThrow('Unsupported value type');
});
});
describe('selectorToSingleElement', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
vi.restoreAllMocks();
});
test('returns the first matching element', () => {
const div = document.createElement('div');
div.className = 'target';
document.body.appendChild(div);
expect(selectorToSingleElement('.target')).toBe(div);
});
test('throws when no element matches', () => {
expect(() => selectorToSingleElement('.nonexistent')).toThrow('No element found for selector .nonexistent');
});
test('warns when selector matches multiple elements', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
document.body.innerHTML = '
';
const result = selectorToSingleElement('.multi');
expect(result).toBe(document.querySelector('.multi'));
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('matched 3 elements, using only the first')
);
});
test('does not warn when selector matches exactly one element', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
document.body.innerHTML = '
';
selectorToSingleElement('#unique');
expect(warnSpy).not.toHaveBeenCalled();
});
});
describe('toSvgOrHtmlElement', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
vi.restoreAllMocks();
});
test('accepts an HTMLElement that is in the document', () => {
const div = document.createElement('div');
document.body.appendChild(div);
expect(toSvgOrHtmlElement(div)).toBe(div);
});
test('accepts an SVGElement that is in the document', () => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
document.body.appendChild(svg);
expect(toSvgOrHtmlElement(svg)).toBe(svg);
});
test('resolves a CSS selector to the matching element', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const div = document.createElement('div');
div.id = 'target';
document.body.appendChild(div);
expect(toSvgOrHtmlElement('#target')).toBe(div);
});
test('throws for an element not in the document', () => {
const detached = document.createElement('div');
expect(() => toSvgOrHtmlElement(detached)).toThrow('Invalid element');
});
test('throws for a non-HTML/SVG element', () => {
// Comment nodes, etc. — anything not HTML or SVG
const comment = document.createComment('hi') as unknown as Element;
expect(() => toSvgOrHtmlElement(comment)).toThrow('Invalid element');
});
});
describe('toValidContainer', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
// NOTE: window pass-through relies on `instanceof Window` which fails in jsdom.
// That path is covered by e2e tests.
test('accepts an HTMLElement in the document', () => {
const div = document.createElement('div');
document.body.appendChild(div);
expect(toValidContainer(div)).toBe(div);
});
test('rejects an SVGElement as container', () => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
document.body.appendChild(svg);
expect(() => toValidContainer(svg)).toThrow("Can't use SVG as container");
});
test('resolves a CSS selector to the container element', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
expect(toValidContainer('#container')).toBe(div);
});
});
describe('skipNull', () => {
test('calls function when value is not null', () => {
const double = skipNull((n: number) => n * 2);
expect(double(5)).toBe(10);
});
test('returns null when value is null', () => {
const double = skipNull((n: number) => n * 2);
expect(double(null)).toBeNull();
});
});
================================================
FILE: tests/unit/typeguards.test.ts
================================================
import { describe, test, expect } from 'vitest';
import { isWindow, isHTMLElement, isSVGElement } from '../../src/util/typeguards';
describe('isWindow', () => {
// NOTE: jsdom's window does not pass `instanceof Window`, so the positive case
// is covered by e2e tests in a real browser. Here we only test rejection.
test('returns false for non-window values', () => {
expect(isWindow(null)).toBe(false);
expect(isWindow(undefined)).toBe(false);
expect(isWindow(document.createElement('div'))).toBe(false);
expect(isWindow({})).toBe(false);
});
});
describe('isHTMLElement', () => {
test('returns true for HTML elements', () => {
expect(isHTMLElement(document.createElement('div'))).toBe(true);
expect(isHTMLElement(document.createElement('span'))).toBe(true);
expect(isHTMLElement(document.body)).toBe(true);
});
test('returns false for non-HTML values', () => {
expect(isHTMLElement(null)).toBe(false);
expect(isHTMLElement(window)).toBe(false);
expect(isHTMLElement({})).toBe(false);
expect(isHTMLElement(document.createElementNS('http://www.w3.org/2000/svg', 'rect'))).toBe(false);
});
});
describe('isSVGElement', () => {
test('returns true for SVG elements', () => {
expect(isSVGElement(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))).toBe(true);
expect(isSVGElement(document.createElementNS('http://www.w3.org/2000/svg', 'rect'))).toBe(true);
});
test('returns false for non-SVG values', () => {
expect(isSVGElement(null)).toBe(false);
expect(isSVGElement(document.createElement('div'))).toBe(false);
expect(isSVGElement(window)).toBe(false);
});
});
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"outDir": "./dist",
"target": "ES6",
"lib": ["ES2020", "ES2022.Error", "DOM"],
"module": "ES2020",
"moduleResolution": "bundler",
"strict": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true
},
"include": ["src/**/*", "tests/**/*"]
}
================================================
FILE: typedoc.json
================================================
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": ["src/index.ts", "src/util.ts"],
"out": "docs/tsdoc",
"tsconfig": "./tsconfig.json",
"name": "ScrollMagic",
"excludePrivate": true,
"excludeInternal": true
}
================================================
FILE: vitest.config.ts
================================================
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
// `vitest` (watch mode) → show browser; `vitest run` (single-run) → headless
declare const process: { argv: string[] };
const isSingleRun = process.argv.includes('run');
export default defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: [`tests/unit/**/*.test.ts`],
environment: 'jsdom',
},
},
{
test: {
name: 'e2e',
include: [`tests/e2e/**/*.test.ts`],
browser: {
enabled: true,
headless: isSingleRun,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
},
],
},
});