DEV Community

Otar Natsvaladze
Otar Natsvaladze

Posted on

How I tackled horizontal scroll when scrolling vertically

I was writing a web3 application and I had an idea, how about making the page scroll horizontally when scrolling vertically.

First approach

It did sound easy, but had me thinking for some time. The approach I used was something like this:
I used things like resizeObserver and framer-motion, but I won't get into the details.

Get the horizontally scrollable div's full width (let's call this x1):
Make an empty div with the height of the x1's length.
I made the x1 div position: fixed; and then used translateX whenever the empty div was scrolled.
There were a few problems associated with this, for example it didn't work on mobile, aside from the events, phones that have display ratios like 20:10 won't scroll when the x1 div is only 2 vw wide. Because the empty div would be 2vw high and thus there would be no scroll because 2vw = 1vh.


I decided to use the wheel event, which is not the same as scroll, because the page doesn't have to be scrollable.
But it also didn't work on mobile, good thing is that, there is also a touchmove event for mobile.

import { RefObject, useEffect, useState } from "react";
import checkIfInBoundaries from "$utils/scroll/checkIfInBoundaries";
import throttle from "lodash.throttle";
export default function useHorizontalScroll(
  scrollableRef: RefObject<Element>
) {
  const [x, setX] = useState(0);
  useEffect(() => {
    let scrollWidth = scrollableRef?.current?.scrollWidth;
    if (!scrollWidth) return;
    function handleResize() {
      scrollWidth = scrollableRef?.current?.scrollWidth;
    let touchStart: number;
    function handleScroll({ deltaY }: WheelEvent) {
      setX((x) => checkIfInBoundaries(scrollWidth!, x + deltaY));
    function setTouchStart({ touches }: TouchEvent) {
      touchStart = touches[0].clientY;
    function handleSwipe({ touches }: TouchEvent) {
      const delta = Math.round(touches[0].clientY - touchStart);
      setX((x) => checkIfInBoundaries(scrollWidth!, x - delta));
    function handleTouchEnd() {
      touchStart = 0;
    const move = throttle(handleSwipe, 16);
    document.addEventListener("wheel", handleScroll);
    document.addEventListener("touchmove", move);
    document.addEventListener("touchstart", setTouchStart);
    document.addEventListener("touchend", handleTouchEnd);
    window.addEventListener("resize", handleResize);
    return () => {
      document.removeEventListener("wheel", handleScroll);
      document.removeEventListener("touchmove", move);
      document.removeEventListener("touchstart", setTouchStart);
      document.removeEventListener("touchend", handleTouchEnd);
      window.removeEventListener("resize", handleResize);
  }, [scrollableRef, setX]);
  return x;
Enter fullscreen mode Exit fullscreen mode

The checkIfInBoundaries function:

export default function checkIfInBoundaries(width: number, deltaY: number) {
  const maxWidth = width - window.innerWidth;
  if (deltaY > maxWidth) return maxWidth;
  if (0 > deltaY) return 0;
  return deltaY;
Enter fullscreen mode Exit fullscreen mode

And this is how I implemented it. Quite simple, every time user scrolls down it returns a certain pixel count, it can fire quite rapidly and for me, it logged around 100px every little scroll. Then I check if I can increment the translateX value and do if it is in bounds.
I throttled the swipe event as mobiles are slower than PC/laptops. I used 16ms because in a 60fps animation every frame is displayed 16 times (1000 / 60).
(The returned state is actually the negative version, you can modify that in the function or in the component like I did)
I also used framer-motion's useAnimation hook

  const x = useHorizontalScroll(scrollableRef);
  const controls = useAnimation();
  controls.start({ x: -x });
Enter fullscreen mode Exit fullscreen mode

To add a little animation, you can also just detect which way the page is scrolling and snap, it's quite easy, so I won't explain it.

I wanted to make a node module with this, but I encountered some errors that I couldn't solve, I think it is because babel only works on certain files and not on my custom hook.
If you can make one, feel free to do so.
Thanks for reading!

Top comments (0)