npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@happyprime/line-observer

v0.0.1

Published

A performant scroll interaction observer that triggers handlers when elements cross a configurable trigger line in the viewport

Downloads

95

Readme

LineObserver

A performant scroll interaction observer that triggers handlers when elements cross a configurable "trigger line" in the viewport.

  • Single shared requestAnimationFrame loop (only runs when instances are active)
  • Batched DOM read/write operations via IntersectionObserver + RAF
  • Configurable trigger line per instance (px, %, vh, vw)
  • Bidirectional scroll support with consistent offset values
  • Sets a --scroll-offset CSS custom property on active elements by default

Installation

npm

npm install @happyprime/line-observer
import LineObserver from '@happyprime/line-observer';

Script tag

<script src="path/to/line-observer.js" type="module"></script>
<script>
  const observer = new LineObserver();
</script>

When loaded via script tag, LineObserver is available as window.LineObserver.

Quick start

const observer = new LineObserver({ triggerLine: '50vh' });

document.querySelectorAll('.observed').forEach((el) => {
  observer.register(el, {
    activeClass: 'is-active',
    onActivate: (element) => console.log('Activated', element),
    onDeactivate: (element) => console.log('Deactivated', element),
    onScroll: (offset, element, instance) => {
      const progress = offset / instance.elementHeight;
      element.style.opacity = Math.min(1, progress);
    },
  });
});

Constructor options

Options passed to the constructor set defaults for all registered elements. Each option can be overridden per element in register().

const observer = new LineObserver({
  triggerLine: '50vh',        // Position of the trigger line (default: '50vh')
  activeClass: 'is-active',  // Class added when element is active (default: 'is-active')
  activateFrom: 'both',      // Where activation triggers from: 'both', 'below', 'above' (default: 'both')
  nearMargin: 100,           // IO detection band half-width in px (default: 100)
  cssCustomProperty: true,   // Set --scroll-offset on active elements (default: true)
  onActivate: null,          // Callback when element becomes active (default: null)
  onDeactivate: null,        // Callback when element becomes inactive (default: null)
  onScroll: null,            // Callback each frame while element is active (default: null)
});

| Option | Type | Default | Description | | --- | --- | --- | --- | | triggerLine | string \| number | '50vh' | Where the trigger line sits in the viewport. Accepts px, %, vh, vw units or a raw number (treated as px). | | activeClass | string | 'is-active' | CSS class added to the element while it is in the active zone. | | activateFrom | string | 'both' | Which side the element crosses the trigger line from to activate: 'below' (scrolling down), 'above' (scrolling up), or 'both'. | | nearMargin | number | 100 | Half-width (in px) of the IntersectionObserver detection band around the trigger line. Larger values detect elements earlier but keep the animation loop running longer. A safety net in the loop handles elements that skip the band during very fast scrolling. | | cssCustomProperty | boolean | true | When true, sets --scroll-offset on active elements every frame. Set to false if you only use callbacks. | | onActivate | function \| null | null | Called when an element crosses the trigger line and becomes active. Receives (element, instance). | | onDeactivate | function \| null | null | Called when an element leaves the active zone. Receives (element, instance). | | onScroll | function \| null | null | Called every animation frame while the element is active. Receives (offset, element, instance). |

Methods

register(element, options?)

Register an element for observation. Options override the constructor defaults for this element. Returns the LineObserver instance for chaining.

observer
  .register(el1, { triggerLine: '30vh' })
  .register(el2, { activateFrom: 'below' })
  .register(el3);

unregister(element)

Stop observing an element. Removes the active class and cleans up the --scroll-offset property. Returns the instance for chaining.

observer.unregister(el1);

destroy()

Tear down everything: stops the RAF loop, disconnects all IntersectionObservers, removes classes and custom properties from all elements, and removes the resize listener.

observer.destroy();

getActiveCount()

Returns the number of elements currently in the active zone.

observer.getActiveCount(); // 3

getTotalCount()

Returns the total number of registered elements.

observer.getTotalCount(); // 10

isActive()

Returns true if the internal RAF loop is currently running.

observer.isActive(); // true

getDirection()

Returns the current scroll direction: 'up' or 'down'.

observer.getDirection(); // 'down'

Instance data in callbacks

The instance object passed to onActivate, onDeactivate, and onScroll callbacks contains:

| Property | Type | Description | | --- | --- | --- | | element | HTMLElement | The observed DOM element. | | options | object | Merged options (constructor defaults + per-element overrides). | | state | string | Current state: 'inactive', 'active', or 'passed'. | | activationScrollY | number | The window.scrollY value when the element was activated. | | elementHeight | number | The element's offsetHeight, captured at activation. | | triggerLinePx | number | The trigger line position in pixels (computed from the triggerLine option). |

observer.register(el, {
  onScroll: (offset, element, instance) => {
    // offset = scrollY - activationScrollY (clamped to >= 0)
    const progress = offset / instance.elementHeight;
    element.style.setProperty('--progress', Math.min(1, progress));
  },
  onActivate: (element, instance) => {
    console.log('State:', instance.state);             // 'active'
    console.log('Trigger at:', instance.triggerLinePx); // e.g. 400
    console.log('Height:', instance.elementHeight);     // e.g. 800
  },
});

How the trigger line works

The trigger line is an invisible horizontal line across the viewport. An element becomes active when its top edge scrolls above the line and remains active until its bottom edge also scrolls above it.

Each registered element can be in one of three states:

  • 'inactive' -- element is entirely below the trigger line.
  • 'active' -- element spans the trigger line (top above, bottom below).
  • 'passed' -- element is entirely above the trigger line.

Supported units

| Value | Meaning | | --- | --- | | '50vh' | 50% of viewport height (default -- middle of screen) | | '100px' | 100 pixels from top of viewport | | '25%' | 25% of viewport height (equivalent to '25vh') | | '50vw' | 50% of viewport width | | 200 | Raw number, treated as 200px |

// Trigger near the top of the viewport
new LineObserver({ triggerLine: '10vh' });

// Trigger at a fixed pixel position
new LineObserver({ triggerLine: '200px' });

// Override per element
observer.register(el, { triggerLine: '75vh' });

Activation filtering

The activateFrom option controls which direction the element must cross the trigger line from to activate.

// Only activate when element crosses trigger line from below
observer.register(el, { activateFrom: 'below' });

// Only activate when element crosses trigger line from above
observer.register(el, { activateFrom: 'above' });

// Activate from either direction (default)
observer.register(el, { activateFrom: 'both' });

When activateFrom is 'below':

  • Elements activate only when they first touch the trigger line from below (user scrolling down).
  • Elements will not activate when crossing from above.
  • Once active, deactivation by scrolling back up (element returning below the trigger line) is suppressed.

When activateFrom is 'above':

  • Elements activate only when they first touch the trigger line from above (user scrolling up).
  • Elements will not activate when crossing from below.
  • Once active, deactivation by scrolling back down (element passing above the trigger line) is suppressed.

The --scroll-offset CSS custom property

When cssCustomProperty is true (the default), LineObserver sets --scroll-offset on each active element every animation frame. The value is the number of pixels scrolled since the element was activated.

.observed {
  transform: translateY(calc(var(--scroll-offset, 0) * -0.5px));
}

.observed.is-active {
  opacity: calc(var(--scroll-offset, 0) / 500);
}

The property is reset to 0 when the element deactivates and removed entirely when the element is unregistered or the observer is destroyed.

To disable this behavior, set cssCustomProperty to false:

observer.register(el, {
  cssCustomProperty: false,
  onScroll: (offset) => {
    // handle offset in JS instead
  },
});

License

MIT License. See LICENSE for details.