Scroll Handler
Create scroll effects with ease and performance in mind
Install
npm i @ryfylke-react/scroll-handler
You can also alternatively copy/paste the source directly from here.
Use
Creating a new scroll handler
import { ScrollHandler } from "@ryfylke-react/scroll-handler";
const scrollHandler = new ScrollHandler({
target: document.querySelector("#app")!, // default: `document.body`
})
.onScroll((event, { disable }) => {
// Do something when scroll event is triggered
})
.enable();
You can call the disable
util to disable the scroll handler.
Adding conditional effects and breakpoints
scrollHandler
.between(0, 500, (event, { getPercent }) => {
// Do something when scroll position is between 0 and 500
const percent = getPercent();
})
.onceOver(500, () => {
// Called once whenever scroll position goes from less than 500 to more than 500
});
You can also pass elements as arguments to between
, onceOver
and onceUnder
:
const element = document.querySelector("#element")!;
scrollHandler
.between(0, element, () => {
// ...
})
.onceOver(element, () => {
// ...
});
This essentially uses the elements getBoundingClientRect().top
to determine the value. This value
is cached, and uses a MutationObserver
and debounced resize-listener to do it's best to update the
value whenever the element could have changed position.
If you know that the element will not change position, you could just pass the value.
const elTop = document
.querySelector("#element")!
.getBoundingClientRect().top;
scrollHandler
.between(0, elTop, () => {
// ...
})
.onceOver(elTop, () => {
// ...
});
Reference
ScrollHandler arguments
const options: {
target?: HTMLElement; // default: document.body
} = {}; // optional
const handler = new ScrollHandler(options);
ScrollHandler instance methods
- onScroll
(effect: ScrollEffect) => this
Send event to channel - between
(after: ScrollEventTarget, before: ScrollEventTarget, effect: ScrollEffect) => this
Sets up conditional effect that runs between two scroll positions - onceOver
(after: ScrollEventTarget, effect: ScrollEffect) => this
Sets up conditional effect that runs once when scroll position goes from less than to more than a given position - onceUnder
(before: ScrollEventTarget, effect: ScrollEffect) => this
Sets up conditional effect that runs once when scroll position goes from more than to less than a given position - when
(condition: () => boolean, effect: ScrollEffect) => this
Sets up conditional effect that runs when a given condition is met. Condition is checked on every scroll event. - enable
() => void
Enables the scroll handler - disable
() => void
Disables the scroll handler - goTo
(opts: GoToOptions) => this
Scrolls to a given position
ScrollHandler instance properties
- scrollTop
number
The current scroll position - target
HTMLElement
The target element
ScrollEffect
type ScrollEffect = (
event: Event,
utils: {
getPercent: () => number; // <- Only available when using `between`
disable: () => void;
}
) => void;
ScrollEventTarget
type ScrollEventTarget = HTMLElement | number;
Use with React
If you are going to use this with React, you can install the @ryfylke-react/scroll-handler-react
package.
const App = () => {
const targetElementRef = useRef<HTMLElement>(null);
useScrollHandler({
onScroll: [() => {
// ...
}]
between: [
{
after: 0,
before: targetElementRef,
effect: () => {
// ...
}
}
]
})
}
This makes it easier to use ScrollHandler with React.
ScrollEventTarget
is also extended to accept React refs, which is how you should pass elements to the hook.
Q/A
My events are not firing when I scroll
Make sure that you have called theenable
method on the scroll handler instance.It could also be that you are not targetting the correct element. By default, the scroll handler targets
document.documentElement || document.body
. If you are using a custom scroll container, you should pass it as an argument when initializing the scroll handler.If you want to listen on scroll on one element, but get the
scrollTop
from another, you should usetarget
to specify the listener-element, andgetScrollTop
function to specify how to get the scrollTop:const scrollHandler = new ScrollHandler({
target: document.querySelector("main")!,
getScrollTop: () =>
document.querySelector("#app")!.scrollTop,
});This is only for very specific use-cases, and you should probably not do this.
If you want the target to be the document body, you should not pass the
target
argument, or pass it asundefined
. This is because the scroll handler will usedocument.documentElement || document.body
to get thescrollTop
(unless you specify agetScrollTop
function), butdocument
to add the event listener. This is the most consistent way to do it for the "page scroll", and should work in all browsers.My events are firing too often
Make sure that you are not creating a new scroll handler instance on every render. This will cause the scroll handler to add a new event listener on every render, which will cause the events to fire more often than they should.Additionally, you can use a debounce util to debounce the events.
Source
The following is the entire library source, if you prefer - you can copy/paste this into a file in your project.
export type ScrollEffect = (
event: Event,
options: {
disable: () => void;
}
) => void;
export type BetweenScrollEffect = (
event: Event,
options: {
disable: () => void;
getPercent: () => number;
}
) => void;
export type ScrollHandlerOptions = {
target?: HTMLElement;
getScrollTop?: () => number;
enable?: boolean;
};
export type ScrollEventTarget = HTMLElement | number;
type ConditionalEffect = {
condition: () => boolean;
effect: ScrollEffect;
};
function relativeOffset(elem: HTMLElement, parent: HTMLElement) {
if (!elem) return { left: 0, top: 0 };
var x = elem.offsetLeft;
var y = elem.offsetTop; // for testing
while (((elem as Element | null) = elem.offsetParent)) {
x += elem.offsetLeft;
y += elem.offsetTop; // for testing
if (elem === parent) break;
}
return { left: x, top: y };
}
const debounce = (fn: Function, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
};
};
export class ScrollHandler {
#effects: Set<ScrollEffect>;
#conditionalEffects: Set<ConditionalEffect>;
#listener: (event: Event) => void;
#prevScrollTop: number;
#positionCache: Map<HTMLElement, number>;
#resizeObserver: ResizeObserver;
#mutationObserver: MutationObserver;
target: HTMLElement | Document;
opts: ScrollHandlerOptions;
get scrollTop() {
if (this.opts.getScrollTop) {
return this.opts.getScrollTop();
}
const el =
this.target instanceof Document
? document.documentElement || document.body
: this.target;
return el.scrollTop;
}
constructor(opts?: ScrollHandlerOptions) {
this.target = opts?.target ?? document;
this.#effects = new Set();
this.#conditionalEffects = new Set();
this.#positionCache = new Map();
this.#prevScrollTop = 0;
this.#listener = (event: Event) => {
this.#triggerEffects(event);
this.#prevScrollTop = this.scrollTop;
};
this.opts = opts ?? {};
const observerCallback = debounce(() => {
this.#positionCache.clear();
}, 200);
this.#resizeObserver = new ResizeObserver(observerCallback);
this.#mutationObserver = new MutationObserver(
observerCallback
);
}
#getHTMLTarget() {
return this.target instanceof Document
? document.body
: this.target;
}
#triggerEffects(event: Event) {
this.#effects.forEach((effect) => {
effect(event, {
disable: () => {
this.#effects.delete(effect);
},
});
});
this.#conditionalEffects.forEach(({ condition, effect }) => {
if (condition()) {
effect(event, {
disable: () => {
this.#effects.delete(effect);
},
});
}
});
}
#resolveScrollTarget(target: ScrollEventTarget) {
if (typeof target === "number") {
return target;
}
try {
if (this.#positionCache.has(target)) {
return this.#positionCache.get(target)!;
}
const position = relativeOffset(
target,
this.#getHTMLTarget()
);
this.#positionCache.set(target, position.top);
return position.top;
} catch (error) {
console.error(
`ScrollHandler: Could not resolve target ${target}`,
{
error,
}
);
return 0;
}
}
enable() {
this.target.addEventListener("scroll", this.#listener);
this.#resizeObserver.observe(this.#getHTMLTarget());
this.#mutationObserver.observe(this.#getHTMLTarget(), {
childList: true,
subtree: true,
});
return this;
}
disable() {
this.target.removeEventListener("scroll", this.#listener);
this.#mutationObserver.disconnect();
this.#resizeObserver.disconnect();
return this;
}
onScroll(effect: ScrollEffect) {
this.#effects.add(effect);
return this;
}
goTo(opts: ScrollToOptions) {
const el = this.#getHTMLTarget();
el.scrollTo(opts);
return this;
}
when(condition: () => boolean, effect: ScrollEffect) {
this.#conditionalEffects.add({
condition,
effect,
});
return this;
}
between(
after: ScrollEventTarget,
before: ScrollEventTarget,
effect: BetweenScrollEffect
) {
this.when(
() => {
const afterPx = this.#resolveScrollTarget(after);
const beforePx = this.#resolveScrollTarget(before);
const scrollTop = this.scrollTop;
return scrollTop > afterPx && scrollTop < beforePx;
},
(event, { disable }) =>
effect(event, {
getPercent: () => {
const afterPx = this.#resolveScrollTarget(after);
const beforePx = this.#resolveScrollTarget(before);
const totalDistance = beforePx - afterPx;
const scrolledDistance = this.scrollTop - afterPx;
return (scrolledDistance / totalDistance) * 100;
},
disable,
})
);
return this;
}
onceOver(target: ScrollEventTarget, effect: ScrollEffect) {
let wasOver = false;
this.when(() => {
const px = this.#resolveScrollTarget(target);
const isOver =
this.#prevScrollTop <= px && this.scrollTop > px;
const scrolledFromUnderToOver = isOver && !wasOver;
wasOver = isOver;
return scrolledFromUnderToOver;
}, effect);
return this;
}
onceUnder(target: ScrollEventTarget, effect: ScrollEffect) {
let wasUnder = false;
this.when(() => {
const px = this.#resolveScrollTarget(target);
const isUnder =
this.#prevScrollTop >= px && this.scrollTop < px;
const scrolledFromOverToUnder = isUnder && !wasUnder;
wasUnder = isUnder;
return scrolledFromOverToUnder;
}, effect);
return this;
}
}