Skip to content

Instantly share code, notes, and snippets.

@TarVK
Last active April 5, 2024 10:55
Show Gist options
  • Save TarVK/4cc89772e606e57f268d479605d7aded to your computer and use it in GitHub Desktop.
Save TarVK/4cc89772e606e57f268d479605d7aded to your computer and use it in GitHub Desktop.
Smooth scroll + horizontal scroll
import Bezier from "bezier-js";
/**
* A simple bezier curve wrapper that can be used for transitions (x axis represents time)
*/
export class TransitionBezier {
protected bezier: Bezier;
protected start: number;
protected duration: number;
/**
* Creates a new transition bezier curve with the given easing
* @param data The transition data
*/
public constructor({
bezier,
start = 0,
duration,
inEase = 0.5,
outEase = 0.5,
}: {
/** A custom bezier to use */
bezier?: Bezier;
/** The start time of the transition */
start?: number;
/** The duration of the transition */
duration: number;
/** The amount of easing into the transition (0-1) */
inEase?: number;
/** The amount of easing out of the transition (0-1) */
outEase?: number;
}) {
this.bezier =
bezier ||
new Bezier(
{x: 0, y: 0},
{x: inEase, y: 0},
{x: 1 - outEase, y: 1},
{x: 1, y: 1}
);
this.duration = duration;
this.start = start;
}
/**
* Retrieves the value for the given time
* @param time The time
* @returns The value
*/
public get(time: number): number {
if (time < this.start) return 0;
if (time > this.start + this.duration) return 1;
return this.bezier.get((time - this.start) / this.duration).y;
}
/**
* Creates a new bezier that branches from this bezier with the same velocity
* @param time The time to branch at
* @param data The new bezier data
* @returns The created bezier
*/
public branch(
time: number,
data: {
/** The start time of the transition */
start?: number;
/** The duration of the transition */
duration: number;
/** The amount of easing out of the transition (0-1) */
outEase?: number;
}
): TransitionBezier {
time -= this.start;
const bezier = this.bezier.split(time / this.duration).right;
const [p1, p2] = bezier.points;
return new TransitionBezier({
bezier: new Bezier(
{x: 0, y: 0},
{x: p2.x - p1.x, y: p2.y - p1.y},
{x: 1 - (data?.outEase || 0.5), y: 1},
{x: 1, y: 1}
),
...data,
});
}
}
import {useRef, useEffect, useCallback} from "react";
import {useSmoothScroll} from "./useSmoothScroll";
/**
* A hook that can be used to control horizontal scroll position of an element
* @param scrollDuration How long it takes to scroll to the next click (transition)
* @returns A ref to pass to the container
*/
export function useHorizontalScroll<T extends HTMLElement = HTMLElement>(
scrollDuration: number = 200
) {
const elRef = useRef<T | null>(null);
const [scrollRef, scrollTo] = useSmoothScroll();
const setRef = useCallback((el: T | null) => {
scrollRef(el);
elRef.current = el;
}, []);
useEffect(() => {
const el = elRef.current;
if (el) {
const onWheel = (e: WheelEvent) => {
if (e.deltaY == 0) return;
e.preventDefault();
scrollTo({addLeft: e.deltaY}, scrollDuration);
};
el.addEventListener("wheel", onWheel);
return () => el.removeEventListener("wheel", onWheel);
}
}, []);
return setRef;
}
import {useRef, useCallback} from "react";
import {TransitionBezier} from "./TransitionBezier";
/** Information used for the current scroll transition target */
type Target = {
start: {
left: number;
top: number;
};
end: {
left: number;
top: number;
};
curve: TransitionBezier;
time: {
start: number;
end: number;
};
resolve: (canceled: boolean) => void;
};
/**
* Creates a ref registrar that can be added to an element, and a function that then can be used to scroll said element
* @returns The ref and scroll functions
*/
export function useSmoothScroll<T extends HTMLElement = HTMLElement>(): [
/**
* Registers the element to control
* @param el The element to control scroll of
*/
(el: T | null) => void,
/**
* Scrolls to the specified position
* @param newTarget The position that should be scroll to
* @param duration How long it takes to reach the specified position
* @param ease The easing factor ([0,1], 0 will be linear)
* @returns A promise that resolves when moving to the target stopped, resolves true if move was canceled since a new target was specified, or false if it successfully finished
*/
(
newTarget: {left?: number; top?: number; addLeft?: number; addTop?: number},
duration?: number,
ease?: number
) => Promise<boolean>
] {
const elRef = useRef(null as HTMLElement | null);
const setRef = useCallback((el: T | null) => (elRef.current = el), []); // Ref setter to help with intellisense issues (compared to passing a ref)
const animating = useRef(false);
const targetPoint = useRef(null as null | Target);
const updateTarget = useCallback(
(
newTarget: {left?: number; top?: number; addLeft?: number; addTop?: number},
duration: number = 200,
ease: number = 0.5
) => {
const el = elRef.current;
if (el) {
// Make a promise to track finish of the animation
let resolve: (canceled: boolean) => void = () => {};
const promise = new Promise<boolean>(res => (resolve = res));
// Create the transition curve and base data object
const now = Date.now();
let oldTarget = targetPoint.current;
let curve: TransitionBezier;
if (oldTarget) {
oldTarget.resolve(true);
curve = oldTarget.curve.branch(now, {
start: now,
duration,
outEase: ease,
});
} else {
curve = new TransitionBezier({
start: now,
duration,
inEase: ease,
outEase: ease,
});
}
const target = {
start: {
left: el.scrollLeft,
top: el.scrollTop,
},
end: {
left: oldTarget?.end.left ?? el.scrollLeft,
top: oldTarget?.end.top ?? el.scrollTop,
},
curve,
time: {
start: now,
end: now + duration,
},
resolve,
};
// Update the target pos using the arguments
if (newTarget.addLeft !== undefined)
newTarget.left =
(oldTarget?.end.left ?? el.scrollLeft) + newTarget.addLeft;
if (newTarget.addTop !== undefined)
newTarget.top =
(oldTarget?.end.top ?? el.scrollTop) + newTarget.addTop;
if (newTarget.left !== undefined)
target.end.left = Math.min(
Math.max(0, newTarget.left),
el.scrollWidth - el.clientWidth
);
if (newTarget.top !== undefined)
target.end.top = Math.min(
Math.max(0, newTarget.top),
el.scrollHeight - el.clientHeight
);
// Make sure there is movement to animate (otherwise we just drag out a transition)
if (
target.end.left == oldTarget?.end.left &&
target.end.top == oldTarget?.end.top
)
return Promise.resolve(false);
targetPoint.current = target;
// Start an animation if not running already
if (!animating.current) {
animating.current = true;
// Create an animation update function
const move = () => {
// Make sure there is a valid target
const target = targetPoint.current;
if (!target) {
animating.current = false;
return;
}
// Update the position
const now = Date.now();
const per = target.curve.get(now);
el.scrollLeft =
(target.end.left - target.start.left) * per +
target.start.left;
el.scrollTop =
(target.end.top - target.start.top) * per + target.start.top;
// Check whether transition finished
const finished = now > target.time.end;
if (finished) {
animating.current = false;
target.resolve(false);
targetPoint.current = null;
return;
} else requestAnimationFrame(move);
};
//Start the animation
requestAnimationFrame(move);
}
return promise;
}
return Promise.resolve(false);
},
[]
);
// Return the el ref and scroll function
return [setRef, updateTarget];
}
import {useRef, useEffect, useCallback} from "react";
import {useSmoothScroll} from "./useSmoothScroll";
/**
* A hook that can be used to control vertical scroll position of an element using smooth scrolling
* @param scrollDuration How long it takes to scroll to the next click (transition)
* @returns A ref to pass to the container
*/
export function useVerticalScroll<T extends HTMLElement = HTMLElement>(
scrollDuration: number = 200
) {
const elRef = useRef<T | null>(null);
const [scrollRef, scrollTo] = useSmoothScroll();
const setRef = useCallback((el: T | null) => {
scrollRef(el);
elRef.current = el;
}, []);
useEffect(() => {
const el = elRef.current;
if (el) {
const onWheel = (e: WheelEvent) => {
if (e.deltaY == 0) return;
e.preventDefault();
scrollTo({addTop: e.deltaY}, scrollDuration);
};
el.addEventListener("wheel", onWheel);
return () => el.removeEventListener("wheel", onWheel);
}
}, []);
return setRef;
}
@TarVK
Copy link
Author

TarVK commented Jan 27, 2021

Above are my TS files used in my project to achieve smooth scrolling + horizontal scrolling. It does use 1 library, since I didn't want to figure out how to split a bezier curve: https://github.com/Pomax/bezierjs

See my stackoverflow.com answer for usage.

This achieves buttery smooth scrolling that doesn't rely on the browser's smooth scroll (Since that isn't super stable, it for instance doesn't seem to work in electron for me).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment