Skip to main content

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 the enable 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 use target to specify the listener-element, and getScrollTop 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 as undefined. This is because the scroll handler will use document.documentElement || document.body to get the scrollTop (unless you specify a getScrollTop function), but document 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.

↗ Open in GitHub

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;
}
}