Skip to content

Commit 00eb656

Browse files
committed
Initial.
0 parents  commit 00eb656

15 files changed

Lines changed: 12217 additions & 0 deletions

.browserslistrc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
> 1%
2+
last 1 Android versions
3+
last 1 ChromeAndroid versions
4+
last 2 Chrome versions
5+
last 2 Firefox versions
6+
last 2 Safari versions
7+
last 2 iOS versions
8+
last 2 Edge versions
9+
last 2 Opera versions

.editorconfig

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
end_of_line = lf
6+
insert_final_newline = true
7+
trim_trailing_whitespace = true
8+
indent_style = tab
9+
10+
[*.{yml,yaml}]
11+
indent_style = space
12+
indent_size = 2

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
coverage/

.prettierrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('@happyprime/eslint-config/prettier');

README.md

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# LineObserver
2+
3+
A performant scroll interaction observer that triggers handlers when elements cross a configurable "trigger line" in the viewport.
4+
5+
- Single shared `requestAnimationFrame` loop (only runs when instances are active)
6+
- Batched DOM read/write operations via IntersectionObserver + RAF
7+
- Configurable trigger line per instance (px, %, vh, vw)
8+
- Bidirectional scroll support with consistent offset values
9+
- Sets a `--scroll-offset` CSS custom property on active elements by default
10+
11+
## Installation
12+
13+
### npm
14+
15+
```bash
16+
npm install @happyprime/line-observer
17+
```
18+
19+
```js
20+
import LineObserver from '@happyprime/line-observer';
21+
```
22+
23+
### Script tag
24+
25+
```html
26+
<script src="path/to/line-observer.js" type="module"></script>
27+
<script>
28+
const observer = new LineObserver();
29+
</script>
30+
```
31+
32+
When loaded via script tag, `LineObserver` is available as `window.LineObserver`.
33+
34+
## Quick start
35+
36+
```js
37+
const observer = new LineObserver({ triggerLine: '50vh' });
38+
39+
document.querySelectorAll('.observed').forEach((el) => {
40+
observer.register(el, {
41+
activeClass: 'is-active',
42+
onActivate: (element) => console.log('Activated', element),
43+
onDeactivate: (element) => console.log('Deactivated', element),
44+
onScroll: (offset, element, instance) => {
45+
const progress = offset / instance.elementHeight;
46+
element.style.opacity = Math.min(1, progress);
47+
},
48+
});
49+
});
50+
```
51+
52+
## Constructor options
53+
54+
Options passed to the constructor set defaults for all registered elements. Each option can be overridden per element in `register()`.
55+
56+
```js
57+
const observer = new LineObserver({
58+
triggerLine: '50vh', // Position of the trigger line (default: '50vh')
59+
activeClass: 'is-active', // Class added when element is active (default: 'is-active')
60+
activateFrom: 'both', // Where activation triggers from: 'both', 'below', 'above' (default: 'both')
61+
nearMargin: 100, // IO detection band half-width in px (default: 100)
62+
cssCustomProperty: true, // Set --scroll-offset on active elements (default: true)
63+
onActivate: null, // Callback when element becomes active (default: null)
64+
onDeactivate: null, // Callback when element becomes inactive (default: null)
65+
onScroll: null, // Callback each frame while element is active (default: null)
66+
});
67+
```
68+
69+
| Option | Type | Default | Description |
70+
| --- | --- | --- | --- |
71+
| `triggerLine` | `string \| number` | `'50vh'` | Where the trigger line sits in the viewport. Accepts px, %, vh, vw units or a raw number (treated as px). |
72+
| `activeClass` | `string` | `'is-active'` | CSS class added to the element while it is in the active zone. |
73+
| `activateFrom` | `string` | `'both'` | Which side the element crosses the trigger line from to activate: `'below'` (scrolling down), `'above'` (scrolling up), or `'both'`. |
74+
| `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. |
75+
| `cssCustomProperty` | `boolean` | `true` | When true, sets `--scroll-offset` on active elements every frame. Set to false if you only use callbacks. |
76+
| `onActivate` | `function \| null` | `null` | Called when an element crosses the trigger line and becomes active. Receives `(element, instance)`. |
77+
| `onDeactivate` | `function \| null` | `null` | Called when an element leaves the active zone. Receives `(element, instance)`. |
78+
| `onScroll` | `function \| null` | `null` | Called every animation frame while the element is active. Receives `(offset, element, instance)`. |
79+
80+
## Methods
81+
82+
### register(element, options?)
83+
84+
Register an element for observation. Options override the constructor defaults for this element. Returns the `LineObserver` instance for chaining.
85+
86+
```js
87+
observer
88+
.register(el1, { triggerLine: '30vh' })
89+
.register(el2, { activateFrom: 'below' })
90+
.register(el3);
91+
```
92+
93+
### unregister(element)
94+
95+
Stop observing an element. Removes the active class and cleans up the `--scroll-offset` property. Returns the instance for chaining.
96+
97+
```js
98+
observer.unregister(el1);
99+
```
100+
101+
### destroy()
102+
103+
Tear down everything: stops the RAF loop, disconnects all IntersectionObservers, removes classes and custom properties from all elements, and removes the resize listener.
104+
105+
```js
106+
observer.destroy();
107+
```
108+
109+
### getActiveCount()
110+
111+
Returns the number of elements currently in the active zone.
112+
113+
```js
114+
observer.getActiveCount(); // 3
115+
```
116+
117+
### getTotalCount()
118+
119+
Returns the total number of registered elements.
120+
121+
```js
122+
observer.getTotalCount(); // 10
123+
```
124+
125+
### isActive()
126+
127+
Returns `true` if the internal RAF loop is currently running.
128+
129+
```js
130+
observer.isActive(); // true
131+
```
132+
133+
### getDirection()
134+
135+
Returns the current scroll direction: `'up'` or `'down'`.
136+
137+
```js
138+
observer.getDirection(); // 'down'
139+
```
140+
141+
## Instance data in callbacks
142+
143+
The `instance` object passed to `onActivate`, `onDeactivate`, and `onScroll` callbacks contains:
144+
145+
| Property | Type | Description |
146+
| --- | --- | --- |
147+
| `element` | `HTMLElement` | The observed DOM element. |
148+
| `options` | `object` | Merged options (constructor defaults + per-element overrides). |
149+
| `state` | `string` | Current state: `'inactive'`, `'active'`, or `'passed'`. |
150+
| `activationScrollY` | `number` | The `window.scrollY` value when the element was activated. |
151+
| `elementHeight` | `number` | The element's `offsetHeight`, captured at activation. |
152+
| `triggerLinePx` | `number` | The trigger line position in pixels (computed from the `triggerLine` option). |
153+
154+
```js
155+
observer.register(el, {
156+
onScroll: (offset, element, instance) => {
157+
// offset = scrollY - activationScrollY (clamped to >= 0)
158+
const progress = offset / instance.elementHeight;
159+
element.style.setProperty('--progress', Math.min(1, progress));
160+
},
161+
onActivate: (element, instance) => {
162+
console.log('State:', instance.state); // 'active'
163+
console.log('Trigger at:', instance.triggerLinePx); // e.g. 400
164+
console.log('Height:', instance.elementHeight); // e.g. 800
165+
},
166+
});
167+
```
168+
169+
## How the trigger line works
170+
171+
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.
172+
173+
Each registered element can be in one of three states:
174+
175+
- `'inactive'` -- element is entirely below the trigger line.
176+
- `'active'` -- element spans the trigger line (top above, bottom below).
177+
- `'passed'` -- element is entirely above the trigger line.
178+
179+
### Supported units
180+
181+
| Value | Meaning |
182+
| --- | --- |
183+
| `'50vh'` | 50% of viewport height (default -- middle of screen) |
184+
| `'100px'` | 100 pixels from top of viewport |
185+
| `'25%'` | 25% of viewport height (equivalent to `'25vh'`) |
186+
| `'50vw'` | 50% of viewport width |
187+
| `200` | Raw number, treated as 200px |
188+
189+
```js
190+
// Trigger near the top of the viewport
191+
new LineObserver({ triggerLine: '10vh' });
192+
193+
// Trigger at a fixed pixel position
194+
new LineObserver({ triggerLine: '200px' });
195+
196+
// Override per element
197+
observer.register(el, { triggerLine: '75vh' });
198+
```
199+
200+
## Activation filtering
201+
202+
The `activateFrom` option controls which direction the element must cross the trigger line from to activate.
203+
204+
```js
205+
// Only activate when element crosses trigger line from below
206+
observer.register(el, { activateFrom: 'below' });
207+
208+
// Only activate when element crosses trigger line from above
209+
observer.register(el, { activateFrom: 'above' });
210+
211+
// Activate from either direction (default)
212+
observer.register(el, { activateFrom: 'both' });
213+
```
214+
215+
When `activateFrom` is `'below'`:
216+
- Elements activate only when they first touch the trigger line from below (user scrolling down).
217+
- Elements will not activate when crossing from above.
218+
- Once active, deactivation by scrolling back up (element returning below the trigger line) is suppressed.
219+
220+
When `activateFrom` is `'above'`:
221+
- Elements activate only when they first touch the trigger line from above (user scrolling up).
222+
- Elements will not activate when crossing from below.
223+
- Once active, deactivation by scrolling back down (element passing above the trigger line) is suppressed.
224+
225+
## The --scroll-offset CSS custom property
226+
227+
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.
228+
229+
```css
230+
.observed {
231+
transform: translateY(calc(var(--scroll-offset, 0) * -0.5px));
232+
}
233+
234+
.observed.is-active {
235+
opacity: calc(var(--scroll-offset, 0) / 500);
236+
}
237+
```
238+
239+
The property is reset to `0` when the element deactivates and removed entirely when the element is unregistered or the observer is destroyed.
240+
241+
To disable this behavior, set `cssCustomProperty` to `false`:
242+
243+
```js
244+
observer.register(el, {
245+
cssCustomProperty: false,
246+
onScroll: (offset) => {
247+
// handle offset in JS instead
248+
},
249+
});
250+
```
251+
252+
## License
253+
254+
MIT

build/line-observer.js

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)