Skip to content

Instantly share code, notes, and snippets.

@joshuacerbito
Last active January 8, 2024 13:44
Show Gist options
  • Star 81 You must be signed in to star a gist
  • Fork 14 You must be signed in to fork a gist
  • Save joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4 to your computer and use it in GitHub Desktop.
Save joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4 to your computer and use it in GitHub Desktop.
Custom React hook for listening to scroll events
/**
* useScroll React custom hook
* Usage:
* const { scrollX, scrollY, scrollDirection } = useScroll();
*/
import { useState, useEffect } from "react";
export function useScroll() {
const [lastScrollTop, setLastScrollTop] = useState(0);
const [bodyOffset, setBodyOffset] = useState(
document.body.getBoundingClientRect()
);
const [scrollY, setScrollY] = useState(bodyOffset.top);
const [scrollX, setScrollX] = useState(bodyOffset.left);
const [scrollDirection, setScrollDirection] = useState();
const listener = e => {
setBodyOffset(document.body.getBoundingClientRect());
setScrollY(-bodyOffset.top);
setScrollX(bodyOffset.left);
setScrollDirection(lastScrollTop > -bodyOffset.top ? "down" : "up");
setLastScrollTop(-bodyOffset.top);
};
useEffect(() => {
window.addEventListener("scroll", listener);
return () => {
window.removeEventListener("scroll", listener);
};
});
return {
scrollY,
scrollX,
scrollDirection
};
}
@martin2844
Copy link

Thank you so much for this hook!

@martin2844
Copy link

martin2844 commented Feb 10, 2020

Okay, sorry to double post.... if Any Gatsby or React SSR users come across this, of course, your gonna run into a document is undefined error.
For gatsby build time I have solved it by using a ternary when using the document element:

import { useState, useEffect } from "react";

export function useScroll() {

  

  const [lastScrollTop, setLastScrollTop] = useState(0);
  const [bodyOffset, setBodyOffset] = useState(
    typeof window === "undefined" || !window.document ? 0 : document.body.getBoundingClientRect()
  );
  const [scrollY, setScrollY] = useState(bodyOffset.top);
  const [scrollX, setScrollX] = useState(bodyOffset.left);
  const [scrollDirection, setScrollDirection] = useState();

  const listener = e => {
    setBodyOffset(typeof window === "undefined" || !window.document ? 0 : document.body.getBoundingClientRect());
    setScrollY(-bodyOffset.top);
    setScrollX(bodyOffset.left);
    setScrollDirection(lastScrollTop > -bodyOffset.top ? "down" : "up");
    setLastScrollTop(-bodyOffset.top);
  };

  useEffect(() => {
    
    window.addEventListener("scroll", listener);
    return () => {
      window.removeEventListener("scroll", listener);
    };
  });

  return {
    scrollY,
    scrollX,
    scrollDirection
  };
}

@theskillwithin
Copy link

If your debouncing the listener, I think you are removing it wrong. you should correctly remove it by doing something like:

  useEffect(() => {
    const debounceWrapper = debounce(listener, 300)
    window.addEventListener('scroll', debounceWrapper)
    return () => {
      window.removeEventListener('scroll', debounceWrapper)
    }
  }, [])

otherwise you'll notice that if u do a console log in the listener and change pages it will still be firing.

@gusfune
Copy link

gusfune commented Jun 30, 2020

This hook is extremely useful and worked better than most libraries for such. I did some changes on the original one and also converted it to Typescript. Be free to use it or make improvements:

https://gist.github.com/gusfune/5ee7d6815db966ab16d88dda7cf414da

/**
 * useScroll React custom hook
 * Usage:
 *    const { scrollX, scrollY, scrollDirection } = useScroll();
 * Original Source: https://gist.github.com/joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4
 */
import { useState, useEffect } from "react"

type SSRRect = {
  bottom: number
  height: number
  left: number
  right: number
  top: number
  width: number
  x: number
  y: number
}
const EmptySSRRect: SSRRect = {
  bottom: 0,
  height: 0,
  left: 0,
  right: 0,
  top: 0,
  width: 0,
  x: 0,
  y: 0,
}

const useScroll = () => {
  const [lastScrollTop, setLastScrollTop] = useState<number>(0)
  const [bodyOffset, setBodyOffset] = useState<DOMRect | SSRRect>(
    typeof window === "undefined" || !window.document
      ? EmptySSRRect
      : document.body.getBoundingClientRect()
  )
  const [scrollY, setScrollY] = useState<number>(bodyOffset.top)
  const [scrollX, setScrollX] = useState<number>(bodyOffset.left)
  const [scrollDirection, setScrollDirection] = useState<
    "down" | "up" | undefined
  >()

  const listener = () => {
    setBodyOffset(
      typeof window === "undefined" || !window.document
        ? EmptySSRRect
        : document.body.getBoundingClientRect()
    )
    setScrollY(-bodyOffset.top)
    setScrollX(bodyOffset.left)
    setScrollDirection(lastScrollTop > -bodyOffset.top ? "down" : "up")
    setLastScrollTop(-bodyOffset.top)
  }

  useEffect(() => {
    window.addEventListener("scroll", listener)
    return () => {
      window.removeEventListener("scroll", listener)
    }
  })

  return {
    scrollY,
    scrollX,
    scrollDirection,
  }
}

export { useScroll }

@inoumen
Copy link

inoumen commented Aug 14, 2020

Can you pls add a license? Because if you don't add it, unfortunately, no one does not have a right to use it.

@csandman
Copy link

csandman commented Oct 7, 2020

Here is my version that takes inspiration from a combination of the versions posted here but I've also included optional callbacks that can be added to the hook when using it.

https://gist.github.com/csandman/289787f26ae14566963ba611bf999c1f

// inspired by:
// https://gist.github.com/joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4
import { useEffect, useState } from 'react';

const isValidFunction = (func) => {
  return func && typeof func === 'function';
};

export default function useScroll({ onScroll, onScrollUp, onScrollDown }) {
  const [scroll, setScroll] = useState(
    typeof window === 'undefined' || !window.document
      ? { x: 0, y: 0, direction: '' }
      : {
          x: document.body.getBoundingClientRect().left,
          y: -document.body.getBoundingClientRect().top,
          direction: '',
        }
  );

  useEffect(() => {
    const handleScroll = () => {
      setScroll((prevScroll) => {
        const rect =
          typeof window === 'undefined' || !window.document
            ? { left: 0, top: 0 }
            : document.body.getBoundingClientRect();
        const x = rect.left;
        const y = -rect.top;
        const direction = prevScroll.y > y ? 'up' : 'down';
        const newScroll = { x, y, direction };

        if (isValidFunction(onScroll)) {
          onScroll(newScroll);
        }
        if (direction === 'up' && isValidFunction(onScrollUp)) {
          onScrollUp(newScroll);
        }
        if (direction === 'down' && isValidFunction(onScrollDown)) {
          onScrollDown(newScroll);
        }

        return newScroll;
      });
    };

    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [onScroll, onScrollDown, onScrollUp]);

  return scroll;
}

@chancity
Copy link

chancity commented Oct 9, 2020

import {useEffect, useState} from 'react';

export const DIRECTION = {
  down: 'DOWN',
  up: 'UP',
  unset: 'UNSET',
};

const getDocumentBoundingClientRect = (documentElement) =>
  typeof documentElement.getBoundingClientRect === 'function' ?
    documentElement.getBoundingClientRect() :
    {
      top: 0,
      left: 0,
    };

const getDocumentElement = (isServer) =>
  !isServer ?
    document.documentElement
    : {
      scrollHeight: 0,
      scrollWidth: 0,
      getBoundingClientRect: getDocumentBoundingClientRect,
    };

const getWindowSize = (isServer) => ({
  innerHeight: !isServer ? window.innerHeight : 0,
  innerWidth: !isServer ? window.innerWidth : 0,
});

const createScrollState = (lastScrollTop) => {
  const isServer = !process.browser;
  const documentElement = getDocumentElement(isServer);
  const bodyBoundingRect = documentElement.getBoundingClientRect();
  const windowSize = getWindowSize(isServer);

  const scrollY = bodyBoundingRect.top;
  const scrollX = bodyBoundingRect.left;
  const scrollYMax = documentElement.scrollHeight - windowSize.innerHeight;
  const scrollXMax = documentElement.scrollWidth - windowSize.innerWidth;
  const scrollDirection = lastScrollTop > bodyBoundingRect.top ? DIRECTION.down : DIRECTION.up;

  return {
    scrollY,
    scrollX,
    scrollDirection,
    scrollYMax,
    scrollXMax,
  }
};

const useWindowScroll = () => {
  const [state, setState] = useState(createScrollState(0));

  useEffect(() => {
    const listener = () =>
      setState(previousState => 
        createScrollState(previousState.scrollY)
      );

    window.addEventListener('scroll', listener);
    return () => {
      window.removeEventListener('scroll', listener);
    };
  }, []);

  return state;
};

export default useWindowScroll;

@1kvsn
Copy link

1kvsn commented Oct 10, 2020

Anyone tried any of above code for infinite scroll ?

@chancity
Copy link

chancity commented Oct 10, 2020

@1kvsn

import React, {useRef, useEffect} from 'react';

const useComponentScrollHook = (callBack) => {
  const ref = useRef(null);

  useEffect(() => {
    if (ref.current && callBack) {
      ref.current.addEventListener('scroll', callBack);
    }

    return () => {
      if (ref.current && callBack) {
        ref.current.removeEventListener('scroll', callBack);
      }
    };
  }, [ref, callBack]);

  return ref;
};

export default useComponentScrollHook;
 const scrollCallback = useCallback((e) => {
    const maxScroll = e.target.scrollHeight - e.target.offsetHeight;
    const scrollTop = e.target.scrollTop;
    const difference = maxScroll - scrollTop;
    if (difference <= 0 && !finished) {
      fetchData();
    }
  }, [finished, fetchData])

  const ref = useComponentScrollHook(scrollCallback);```

@nhuanhoangduc
Copy link

Only register 'scroll' event one time:

import { useState, useEffect, useCallback } from 'react'

export const useScroll = () => {
  const [state, setState] = useState({
    lastScrollTop: 0,
    bodyOffset: document.body.getBoundingClientRect(),
    scrollY: document.body.getBoundingClientRect().top,
    scrollX: document.body.getBoundingClientRect().left,
    scrollDirection: '', // down, up
  })

  const handleScrollEvent = useCallback((e) => {
    setState((prevState) => {
      const prevLastScrollTop = prevState.lastScrollTop
      const bodyOffset = document.body.getBoundingClientRect()

      return {
        setBodyOffset: bodyOffset,
        scrollY: -bodyOffset.top,
        scrollX: bodyOffset.left,
        scrollDirection: prevLastScrollTop > -bodyOffset.top ? 'down' : 'up',
        lastScrollTop: -bodyOffset.top,
      }
    })
  }, [])

  useEffect(() => {
    const scrollListener = (e) => {
      handleScrollEvent(e)
    }
    window.addEventListener('scroll', scrollListener)

    return () => {
      window.removeEventListener('scroll', scrollListener)
    }
  }, [handleScrollEvent])

  return {
    scrollY: state.scrollY,
    scrollX: state.scrollX,
    scrollDirection: state.scrollDirection,
  }
}

export default useScroll

@Mihai-github
Copy link

Mihai-github commented Oct 13, 2021

Hi,
Really nice what you all did above, my question is the first post made by @joshuacerbito is the updated version after all comments made by the community or anyone came with let's say a "better solution".

Thanks 👍

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