Creating a Horizontal Scrolling Component with Scroll Indicators
Adam C. |

In today's blog post, we'll explore the creation of a versatile horizontal scrolling component that enhances user experience when dealing with horizontally overflowing content. This component is especially useful when you have a row of content elements that don't fit within the screen's width, and you want to provide intuitive navigation options.

Photo by Taylor Flowe on Unsplash

Our horizontal scrolling component, aptly named DNXScroll, provides the following features:

Horizontal Scrolling: The component allows content to be displayed in a single row. When the content exceeds the available width, users can horizontally scroll to view more items.

Navigation Arrows: Recognizing that not all users have the capability to perform horizontal scrolling with a mouse, we include arrow icons on the left and right. These icons enable users to click to scroll left or right, enhancing accessibility.

Gradient Indicators: To address the issue of arrow icons sometimes being difficult to see due to varying background colors, we've applied linear gradients to the edges of the content container. These gradients indicate the presence of hidden content and only become visible when there's more to scroll.

Mobile Support: On mobile devices, where users can swipe left and right to navigate, the scrolling icons are hidden to avoid clutter and provide a clean interface.

Let's dive deeper into the implementation of our DNXScroll component.

Implementation

The DNXScroll component is built using React and styled with CSS. It supports two methods of content input: an array of cards or child elements for any content you'd like to place in a row that can be scrolled horizontally.

DNXScroll (react component with semantic-ui)

import React, { useRef, useState, useEffect } from "react";
import { Icon, Card, Label, Image } from "semantic-ui-react";

const DNXScroll = ({
  cards = [],
  className = "dnxCard",
  withWrap = false,
  children,
}) => {
  const dnxScrollRef = useRef(null);
  const [showLeftArrow, setShowLeftArrow] = useState(false);
  const [showRightArrow, setShowRightArrow] = useState(true);

  useEffect(() => {
    const container = dnxScrollRef.current;

    const handleScroll = () => {
      // Check if the container is scrolled to the beginning
      setShowLeftArrow(container.scrollLeft > 0);

      // Calculate the maximum scroll position
      const maxScrollLeft = container.scrollWidth - container.clientWidth;

      // Check if the container is scrolled to the end
      setShowRightArrow(
        container.scrollLeft < maxScrollLeft && maxScrollLeft > 0
      );
    };

    // Add a scroll event listener to track scrolling
    container.addEventListener("scroll", handleScroll);

    // Cleanup the event listener on unmount
    return () => {
      container.removeEventListener("scroll", handleScroll);
    };
  }, []);

  const slide = (direction) => {
    const container = dnxScrollRef.current;
    let maxScrollLeft = container.scrollWidth - container.clientWidth;
    if (withWrap) {
      maxScrollLeft -= 1;
    }

    let scrollCompleted = 0;
    const slideVar = setInterval(function () {
      if (direction === "left") {
        container.scrollLeft -= 50;
      } else {
        container.scrollLeft += 50;
      }
      scrollCompleted += 50;
      if (scrollCompleted >= 600) {
        window.clearInterval(slideVar);
      }

      if (container.scrollLeft === 0) {
        setShowLeftArrow(false);
        setShowRightArrow(true);
      } else if (container.scrollLeft < maxScrollLeft) {
        setShowLeftArrow(true);
        setShowRightArrow(true);
      } else {
        setShowLeftArrow(true);
        setShowRightArrow(false);
      }
    }, 50);
  };

  let wrapClassNames = "dnxScrollWrapper";
  if (showLeftArrow) {
    wrapClassNames += " scrolled-left";
  }
  if (showRightArrow) {
    wrapClassNames += " scrolled-right";
  }

  return (
    <div
      className={wrapClassNames}
    >
      {showLeftArrow && (
        <div className="dnxScrollLeftArrow">
          <Icon
            name="chevron circle left"
            size="large"
            link
            onClick={() => slide("left")}
          />
        </div>
      )}
      {showRightArrow && (
        <div className="dnxScrollRightArrow">
          <Icon
            name="chevron circle right"
            size="large"
            link
            onClick={() => slide("right")}
          />
        </div>
      )}

      <div className="dnxScrollContent scroll" ref={dnxScrollRef}>
        {
          cards.length > 0
            ? cards.map((card) => (
                <div key={card.key} className={className}>
                  {card.content}
                </div>
              ))
            : children /* Render the children only when cards is empty */
        }

      </div>
    </div>
  );
};

export default DNXScroll;

CSS

/** Horizontal Scroll Component **/
.dnxScrollWrapper {
  position: relative;
  margin-bottom: 1em;
  &::after {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    height: 100%;
    width: 5rem;
    background-image: linear-gradient(
      to left,
      rgba(255, 255, 255, 0),
      rgba(255, 255, 255, 0)
    );
    z-index: 9;
    transition: all linear 0.3s;
  }
  &::before {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: 5rem;
    background-image: linear-gradient(
      to right,
      rgba(255, 255, 255, 0),
      rgba(255, 255, 255, 0)
    );
    z-index: 9;
    transition: all linear 0.3s;
  }
  &.scrolled-right {
    &::after {
      background-image: linear-gradient(
        to left,
        rgba(255, 255, 255, 1),
        rgba(255, 255, 255, 0)
      );
    }
  }
  &.scrolled-left {
    &::before {
      background-image: linear-gradient(
        to right,
        rgba(255, 255, 255, 1),
        rgba(255, 255, 255, 0)
      );
    }
  }
}

.dnxScrollContent {
  display: flex;
}
.dnxScrollLeftArrow,
.dnxScrollRightArrow {
  position: absolute;
  top: calc(50% - 12px);
  left: -15px;
  z-index: 10;
}

/** This is used for <Cards> in the <DNXScroll> in the example **/
.dnxCard {
  padding: 1rem;
  width: 320px;
  white-space: normal;
}

/** Hide arrow on mobile **/
@media only screen and (max-width: 767px) {
  .dnxScrollLeftArrow,
  .dnxScrollRightArrow {
    display: none;
  }
}

Example 1 - Horizontal Cards

// cards is an array of <Card> components     
<DNXScroll
  cards={cards}
  className="dnxCard dealCard"
/>

Example 2 - Overflow Button Group

<DNXScroll>
  <Button.Group fluid>
    <Button color="red" as="a" href="/summer-leagues/allstar">
      All Star Rankings
    </Button>
    <Button color="violet" as="a" href="/summer-leagues/allstar-relay-seedings">
      All Star Relay Seedings
    </Button>
    {/* Add more <Button> elements here */}
  </Button.Group>
</DNXScroll>

Live Example

  1. https://swimstandards.com/shop  (Horizontal Cards)
  2. https://swimstandards.com/summer-leagues/mcsl (Button Group)

Note 

During the implementation of our DNXScroll component, I encountered a challenge where the linear gradient on the left (`&::before`) did not work as expected. It appeared to be hidden under the content inside the scroll div. This unexpected behavior was due to the z-index.

Interestingly, the linear gradient on the right (`&::after`) worked as intended without any issues. Anyway, to resolve this problem and ensure that the linear gradient on the left is visible, we applied a `z-index` of 9 (you can adjust the number based on your specific case) to the gradient element. This adjustment successfully resolved the issue, allowing both gradients to function correctly and indicate hidden content.

When encountering similar challenges with linear gradients or z-index in your own projects, consider experimenting with z-index values to achieve the desired layering and visibility of elements.

Conclusion

The DNXScroll component offers an elegant solution for handling horizontally overflowing content. With features like arrow navigation, gradient indicators, and mobile support, it enhances the user experience and ensures that your content remains accessible and visually appealing, regardless of background colors.

By implementing this component, you can provide a seamless navigation experience for your users and make your website or application more user-friendly.