Skip to content

Instantly share code, notes, and snippets.

@benknight
Last active August 16, 2023 04:59
Show Gist options
  • Select an option

  • Save benknight/3bbf8dbcbb0dfef9adc611be74538f67 to your computer and use it in GitHub Desktop.

Select an option

Save benknight/3bbf8dbcbb0dfef9adc611be74538f67 to your computer and use it in GitHub Desktop.

Revisions

  1. benknight revised this gist Nov 20, 2020. 1 changed file with 13 additions and 10 deletions.
    23 changes: 13 additions & 10 deletions useCarousel.js
    Original file line number Diff line number Diff line change
    @@ -1,13 +1,13 @@
    import React from 'react';
    import { useRef, useState, useCallback, useEffect } from 'react';

    export default function useCarousel() {
    const scrollArea = React.useRef();
    const [isTouchDevice, setIsTouchDevice] = React.useState(null);
    const [scrollBy, setScrollBy] = React.useState(null);
    const [scrollPosition, setScrollPosition] = React.useState(null);
    const [showNav, setShowNav] = React.useState(null);
    const scrollArea = useRef();
    const [isTouchDevice, setIsTouchDevice] = useState(null);
    const [scrollBy, setScrollBy] = useState(null);
    const [scrollPosition, setScrollPosition] = useState(null);
    const [showNav, setShowNav] = useState(null);

    const navigate = React.useCallback(
    const navigate = useCallback(
    delta => {
    const { scrollLeft } = scrollArea.current;
    scrollArea.current.scroll({
    @@ -18,15 +18,18 @@ export default function useCarousel() {
    [scrollBy],
    );

    React.useEffect(() => {
    useEffect(() => {
    const scrollAreaNode = scrollArea.current;

    const calculateScrollPosition = () => {
    if (!scrollAreaNode) return;
    const { width } = scrollAreaNode.getBoundingClientRect();
    if (scrollAreaNode.scrollLeft === 0) {
    setScrollPosition('start');
    } else if (scrollAreaNode.scrollLeft + width === scrollAreaNode.scrollWidth) {
    } else if (
    scrollAreaNode.scrollLeft + width ===
    scrollAreaNode.scrollWidth
    ) {
    setScrollPosition('end');
    } else {
    setScrollPosition('between');
    @@ -71,7 +74,7 @@ export default function useCarousel() {
    return detachListeners;
    }, [isTouchDevice, navigate]);

    React.useEffect(() => {
    useEffect(() => {
    const mql = window.matchMedia('(pointer: fine)');
    const handleMql = ({ matches }) => {
    setIsTouchDevice(!matches);
  2. benknight revised this gist Nov 9, 2020. 2 changed files with 9 additions and 31 deletions.
    12 changes: 9 additions & 3 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -16,11 +16,17 @@ const {
    } = useCarousel();
    ```

    I recommend adding the following style to avoid weird elastic pull effect:
    Use CSS to avoid unwanted scrollbars using the following technique:

    ```css
    body {
    overscroll-behavior-x: none;
    parent {
    overflow: hidden;
    }

    child {
    overflow-x: auto;
    margin-bottom: -16px;
    padding-bottom: 16px;
    }
    ```

    28 changes: 0 additions & 28 deletions useCarousel.js
    Original file line number Diff line number Diff line change
    @@ -1,12 +1,10 @@
    import _debounce from 'lodash/debounce';
    import React from 'react';

    export default function useCarousel() {
    const scrollArea = React.useRef();
    const [isTouchDevice, setIsTouchDevice] = React.useState(null);
    const [scrollBy, setScrollBy] = React.useState(null);
    const [scrollPosition, setScrollPosition] = React.useState(null);
    const [swipeDirection, setSwipeDirection] = React.useState(null);
    const [showNav, setShowNav] = React.useState(null);

    const navigate = React.useCallback(
    @@ -46,34 +44,17 @@ export default function useCarousel() {
    setScrollBy(childWidth * Math.floor(containerWidth / childWidth));
    };

    // Swipe behavior for non-touch devices
    const resetSwipeDirection = _debounce(() => setSwipeDirection(null), 50, {
    leading: true,
    trailing: false,
    });

    const onWheel = event => {
    if (event.deltaX > 20) {
    setSwipeDirection('right');
    } else if (event.deltaX < -20) {
    setSwipeDirection('left');
    }
    resetSwipeDirection();
    };

    const observer = new MutationObserver(calculateScrollBy);

    const attachListeners = () => {
    if (scrollAreaNode) observer.observe(scrollAreaNode, { childList: true });
    scrollAreaNode.addEventListener('scroll', calculateScrollPosition);
    scrollAreaNode.addEventListener('wheel', onWheel);
    window.addEventListener('resize', calculateScrollBy);
    };

    const detachListeners = () => {
    observer.disconnect();
    scrollAreaNode.removeEventListener('scroll', calculateScrollPosition);
    scrollAreaNode.removeEventListener('wheel', onWheel);
    window.removeEventListener('resize', calculateScrollBy);
    };

    @@ -94,7 +75,6 @@ export default function useCarousel() {
    const mql = window.matchMedia('(pointer: fine)');
    const handleMql = ({ matches }) => {
    setIsTouchDevice(!matches);
    scrollArea.current.style.overflow = matches ? 'hidden' : 'auto';
    };
    handleMql(mql);
    mql.addEventListener('change', handleMql);
    @@ -103,14 +83,6 @@ export default function useCarousel() {
    };
    }, []);

    React.useEffect(() => {
    if (swipeDirection === 'right') {
    navigate(1);
    } else if (swipeDirection === 'left') {
    navigate(-1);
    }
    }, [navigate, swipeDirection]);

    return {
    getLeftNavProps: () => ({
    onClick: () => navigate(-1),
  3. benknight revised this gist Oct 28, 2020. 1 changed file with 2 additions and 4 deletions.
    6 changes: 2 additions & 4 deletions useCarousel.js
    Original file line number Diff line number Diff line change
    @@ -13,8 +13,8 @@ export default function useCarousel() {
    delta => {
    const { scrollLeft } = scrollArea.current;
    scrollArea.current.scroll({
    left: scrollLeft + scrollBy * delta,
    behavior: 'smooth',
    left: scrollLeft + scrollBy * delta,
    });
    },
    [scrollBy],
    @@ -24,9 +24,7 @@ export default function useCarousel() {
    const scrollAreaNode = scrollArea.current;

    const calculateScrollPosition = () => {
    if (!scrollAreaNode) {
    return;
    }
    if (!scrollAreaNode) return;
    const { width } = scrollAreaNode.getBoundingClientRect();
    if (scrollAreaNode.scrollLeft === 0) {
    setScrollPosition('start');
  4. benknight revised this gist Oct 28, 2020. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -1,5 +1,7 @@
    # [use-carousel] Headless UI React hook for building a scroll-based carousel

    BYO-UI. No CSS necessary. Inspired by react-table.

    ## Usage:

    ```js
  5. benknight revised this gist Oct 28, 2020. 1 changed file with 13 additions and 1 deletion.
    14 changes: 13 additions & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -20,4 +20,16 @@ I recommend adding the following style to avoid weird elastic pull effect:
    body {
    overscroll-behavior-x: none;
    }
    ```
    ```

    ## Instance properties

    | Property | Type | Description |
    | --------- | ---- | ----------- |
    | getLeftNavProps | `function` | Returns props for left arrow button |
    | getRightNavProps | `function` | Returns props for right arrow button |
    | isTouchDevice | `Boolean` | Whether the user is using a touch device or not, useful for hiding the navigate for touch users who can just swipe |
    | navigate | `function(delta: Int)` | Navigates by a specified delta e.g. 1 or -1 |
    | scrollAreaRef | `ref` | Reference to be assigned to the scroll parent |
    | scrollPosition | `string` | Describes current scroll position as "start", "end", or "between" |
    | showNav | `Boolean` | `false` if there aren't enough items to scroll |
  6. benknight revised this gist Oct 28, 2020. 2 changed files with 23 additions and 17 deletions.
    23 changes: 23 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,23 @@
    # [use-carousel] Headless UI React hook for building a scroll-based carousel

    ## Usage:

    ```js
    const {
    getLeftNavProps,
    getRightNavProps,
    isTouchDevice,
    navigate,
    scrollAreaRef,
    scrollPosition,
    showNav,
    } = useCarousel();
    ```

    I recommend adding the following style to avoid weird elastic pull effect:

    ```css
    body {
    overscroll-behavior-x: none;
    }
    ```
    17 changes: 0 additions & 17 deletions useCarousel.js
    Original file line number Diff line number Diff line change
    @@ -1,20 +1,3 @@
    /*
    Source: https://gist.github.com/benknight/3bbf8dbcbb0dfef9adc611be74538f67
    Usage:
    const {
    getLeftNavProps,
    getRightNavProps,
    isTouchDevice,
    navigate,
    scrollAreaRef,
    scrollPosition,
    showNav,
    } = useCarousel();
    */
    import _debounce from 'lodash/debounce';
    import React from 'react';

  7. benknight revised this gist Oct 28, 2020. 1 changed file with 39 additions and 20 deletions.
    59 changes: 39 additions & 20 deletions useCarousel.js
    Original file line number Diff line number Diff line change
    @@ -1,62 +1,76 @@
    /*
    I recommend adding the following style to avoid weird elastic pull effect:
    Source: https://gist.github.com/benknight/3bbf8dbcbb0dfef9adc611be74538f67
    body {
    overscroll-behavior-x: none;
    }
    Usage:
    */
    const {
    getLeftNavProps,
    getRightNavProps,
    isTouchDevice,
    navigate,
    scrollAreaRef,
    scrollPosition,
    showNav,
    } = useCarousel();
    */
    import _debounce from 'lodash/debounce';
    import React from 'react';

    export default function useCarousel() {
    const scrollArea = React.useRef();
    const [isTouchDevice, setIsTouchDevice] = React.useState(null);
    const [scrollBy, setScrollBy] = React.useState(null);
    const [swipeDirection, setSwipeDirection] = React.useState(null);
    const [scrollPosition, setScrollPosition] = React.useState(null);
    const [swipeDirection, setSwipeDirection] = React.useState(null);
    const [showNav, setShowNav] = React.useState(null);

    const navigate = React.useCallback(
    delta => {
    const { scrollLeft } = scrollArea.current;
    scrollArea.current.scroll({
    left: scrollArea.current.scrollLeft + scrollBy * delta,
    left: scrollLeft + scrollBy * delta,
    behavior: 'smooth',
    });
    },
    [scrollBy],
    );

    React.useEffect(() => {
    const scrollAreaNode = scrollArea.current;

    const calculateScrollPosition = () => {
    if (!scrollArea.current) {
    if (!scrollAreaNode) {
    return;
    }
    const { width } = scrollArea.current.getBoundingClientRect();
    if (scrollArea.current.scrollLeft === 0) {
    const { width } = scrollAreaNode.getBoundingClientRect();
    if (scrollAreaNode.scrollLeft === 0) {
    setScrollPosition('start');
    } else if (
    scrollArea.current.scrollLeft + width ===
    scrollArea.current.scrollWidth
    ) {
    } else if (scrollAreaNode.scrollLeft + width === scrollAreaNode.scrollWidth) {
    setScrollPosition('end');
    } else {
    setScrollPosition('between');
    }
    };

    // Calculate scrollBy offset
    const scrollAreaNode = scrollArea.current;
    const calculateScrollBy = () => {
    if (!scrollAreaNode) return;
    const { width: containerWidth } = scrollAreaNode.getBoundingClientRect();
    const { width: itemWidth } = scrollAreaNode
    .querySelector(':scope > *')
    .getBoundingClientRect();
    setScrollBy(itemWidth * Math.floor(containerWidth / itemWidth));
    setShowNav(scrollAreaNode.scrollWidth > containerWidth);
    const childNode = scrollAreaNode.querySelector(':scope > *');
    if (!childNode) return;
    const { width: childWidth } = childNode.getBoundingClientRect();
    setScrollBy(childWidth * Math.floor(containerWidth / childWidth));
    };

    // Swipe behavior for non-touch devices
    const resetSwipeDirection = _debounce(() => setSwipeDirection(null), 40);
    const resetSwipeDirection = _debounce(() => setSwipeDirection(null), 50, {
    leading: true,
    trailing: false,
    });

    const onWheel = event => {
    if (event.deltaX > 20) {
    setSwipeDirection('right');
    @@ -66,13 +80,17 @@ export default function useCarousel() {
    resetSwipeDirection();
    };

    const observer = new MutationObserver(calculateScrollBy);

    const attachListeners = () => {
    if (scrollAreaNode) observer.observe(scrollAreaNode, { childList: true });
    scrollAreaNode.addEventListener('scroll', calculateScrollPosition);
    scrollAreaNode.addEventListener('wheel', onWheel);
    window.addEventListener('resize', calculateScrollBy);
    };

    const detachListeners = () => {
    observer.disconnect();
    scrollAreaNode.removeEventListener('scroll', calculateScrollPosition);
    scrollAreaNode.removeEventListener('wheel', onWheel);
    window.removeEventListener('resize', calculateScrollBy);
    @@ -123,5 +141,6 @@ export default function useCarousel() {
    navigate,
    scrollAreaRef: scrollArea,
    scrollPosition,
    showNav,
    };
    }
  8. benknight revised this gist Oct 24, 2020. 1 changed file with 24 additions and 2 deletions.
    26 changes: 24 additions & 2 deletions useCarousel.js
    Original file line number Diff line number Diff line change
    @@ -15,6 +15,7 @@ export default function useCarousel() {
    const [isTouchDevice, setIsTouchDevice] = React.useState(null);
    const [scrollBy, setScrollBy] = React.useState(null);
    const [swipeDirection, setSwipeDirection] = React.useState(null);
    const [scrollPosition, setScrollPosition] = React.useState(null);

    const navigate = React.useCallback(
    delta => {
    @@ -27,6 +28,23 @@ export default function useCarousel() {
    );

    React.useEffect(() => {
    const calculateScrollPosition = () => {
    if (!scrollArea.current) {
    return;
    }
    const { width } = scrollArea.current.getBoundingClientRect();
    if (scrollArea.current.scrollLeft === 0) {
    setScrollPosition('start');
    } else if (
    scrollArea.current.scrollLeft + width ===
    scrollArea.current.scrollWidth
    ) {
    setScrollPosition('end');
    } else {
    setScrollPosition('between');
    }
    };

    // Calculate scrollBy offset
    const scrollAreaNode = scrollArea.current;
    const calculateScrollBy = () => {
    @@ -49,11 +67,13 @@ export default function useCarousel() {
    };

    const attachListeners = () => {
    window.addEventListener('resize', calculateScrollBy);
    scrollAreaNode.addEventListener('scroll', calculateScrollPosition);
    scrollAreaNode.addEventListener('wheel', onWheel);
    window.addEventListener('resize', calculateScrollBy);
    };

    const detachListeners = () => {
    scrollAreaNode.removeEventListener('scroll', calculateScrollPosition);
    scrollAreaNode.removeEventListener('wheel', onWheel);
    window.removeEventListener('resize', calculateScrollBy);
    };
    @@ -63,8 +83,9 @@ export default function useCarousel() {
    }

    if (isTouchDevice === false) {
    calculateScrollBy();
    attachListeners();
    calculateScrollBy();
    calculateScrollPosition();
    }

    return detachListeners;
    @@ -101,5 +122,6 @@ export default function useCarousel() {
    isTouchDevice,
    navigate,
    scrollAreaRef: scrollArea,
    scrollPosition,
    };
    }
  9. benknight revised this gist Oct 24, 2020. 1 changed file with 9 additions and 0 deletions.
    9 changes: 9 additions & 0 deletions useCarousel.js
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,12 @@
    /*
    I recommend adding the following style to avoid weird elastic pull effect:
    body {
    overscroll-behavior-x: none;
    }
    */
    import _debounce from 'lodash/debounce';
    import React from 'react';

  10. benknight created this gist Oct 24, 2020.
    96 changes: 96 additions & 0 deletions useCarousel.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,96 @@
    import _debounce from 'lodash/debounce';
    import React from 'react';

    export default function useCarousel() {
    const scrollArea = React.useRef();
    const [isTouchDevice, setIsTouchDevice] = React.useState(null);
    const [scrollBy, setScrollBy] = React.useState(null);
    const [swipeDirection, setSwipeDirection] = React.useState(null);

    const navigate = React.useCallback(
    delta => {
    scrollArea.current.scroll({
    left: scrollArea.current.scrollLeft + scrollBy * delta,
    behavior: 'smooth',
    });
    },
    [scrollBy],
    );

    React.useEffect(() => {
    // Calculate scrollBy offset
    const scrollAreaNode = scrollArea.current;
    const calculateScrollBy = () => {
    const { width: containerWidth } = scrollAreaNode.getBoundingClientRect();
    const { width: itemWidth } = scrollAreaNode
    .querySelector(':scope > *')
    .getBoundingClientRect();
    setScrollBy(itemWidth * Math.floor(containerWidth / itemWidth));
    };

    // Swipe behavior for non-touch devices
    const resetSwipeDirection = _debounce(() => setSwipeDirection(null), 40);
    const onWheel = event => {
    if (event.deltaX > 20) {
    setSwipeDirection('right');
    } else if (event.deltaX < -20) {
    setSwipeDirection('left');
    }
    resetSwipeDirection();
    };

    const attachListeners = () => {
    window.addEventListener('resize', calculateScrollBy);
    scrollAreaNode.addEventListener('wheel', onWheel);
    };

    const detachListeners = () => {
    scrollAreaNode.removeEventListener('wheel', onWheel);
    window.removeEventListener('resize', calculateScrollBy);
    };

    if (isTouchDevice === true) {
    detachListeners();
    }

    if (isTouchDevice === false) {
    calculateScrollBy();
    attachListeners();
    }

    return detachListeners;
    }, [isTouchDevice, navigate]);

    React.useEffect(() => {
    const mql = window.matchMedia('(pointer: fine)');
    const handleMql = ({ matches }) => {
    setIsTouchDevice(!matches);
    scrollArea.current.style.overflow = matches ? 'hidden' : 'auto';
    };
    handleMql(mql);
    mql.addEventListener('change', handleMql);
    return () => {
    mql.removeEventListener('change', handleMql);
    };
    }, []);

    React.useEffect(() => {
    if (swipeDirection === 'right') {
    navigate(1);
    } else if (swipeDirection === 'left') {
    navigate(-1);
    }
    }, [navigate, swipeDirection]);

    return {
    getLeftNavProps: () => ({
    onClick: () => navigate(-1),
    }),
    getRightNavProps: () => ({
    onClick: () => navigate(1),
    }),
    isTouchDevice,
    navigate,
    scrollAreaRef: scrollArea,
    };
    }