Add a scroll progress indicator to a horizontal menu

A carousel that hides its scrollbar still owes the user an answer to “how much is left?”. The menu already knows: it tracks every item’s visibility, so position is a matter of counting. The story renders numbered page buttons plus items-left/right counts from that data; this demo distills the same math into a progress bar.
#01
#02
#03
#04
#05
#06
#07
#08
#09
#10
#11
#12
#13
#14
#15
#16
#17
#18
#19
#20

Scroll the row, drag it, or use the arrows — the bar fills page by page and the counter shows where you are.

How it works

The indicator is passed as the Footer prop, so ScrollMenu renders it inside the menu where VisibilityContext is available. From the context it takes items — the map behind visibility tracking — and subscribes with items.subscribe(’onUpdate’, cb). That event fires on every IntersectionObserver callback, so the story debounces it (a timeout plus requestAnimationFrame) before reading items.getVisible().

From visible items to a page number

The count of visible items is the page size. Total pages is Math.ceil(items.size / visibleItemsLen); the current page comes from the last visible entry’s index. The story turns those into clickable page buttons — each one calls scrollToItem(getItemByIndex(itemInd)), addressing an item by position without knowing its id — and derives items-on-the-left and items-on-the-right counts from the same numbers. The demo’s bar is just currentPage / totalPages as a width percentage.

Notes

Full source

Complete and copy-paste ready — this is the exact file behind the live-editable Storybook version.

Progress.source.tsx
import 'react-horizontal-scrolling-menu/dist/styles.css';

import styled from '@emotion/styled';
import React from 'react';
import {
  type publicApiType,
  ScrollMenu,
  VisibilityContext,
} from 'react-horizontal-scrolling-menu';

export function Progress() {
  const [items] = React.useState(() => getItems(30));
  const [selected, setSelected] = React.useState<string[]>([]);

  const isItemSelected = (id: string): boolean =>
    !!selected.find((el) => el === id);

  const handleItemClick = (itemId: string) => {
    const itemSelected = isItemSelected(itemId);

    setSelected((currentSelected: string[]) =>
      itemSelected
        ? currentSelected.filter((el) => el !== itemId)
        : currentSelected.concat(itemId),
    );
  };

  return (
    <div>
      <NoScrollbar>
        <ScrollMenu
          LeftArrow={LeftArrow}
          RightArrow={RightArrow}
          onWheel={onWheel}
          Footer={Footer}
        >
          {items.map(({ id }) => (
            <Card
              title={id}
              itemId={id} // NOTE: itemId is required for track items
              key={id}
              onClick={() => handleItemClick(id)}
              selected={isItemSelected(id)}
            />
          ))}
        </ScrollMenu>
      </NoScrollbar>
    </div>
  );
}

const Footer = () => {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const { items } = visibility;
  const [visible, setVisible] = React.useState<[string, { index: string }][]>(
    [],
  );

  // Need to update this component
  // listening to 'onUpdate' event with some debounce.
  // 'onInit' covers the very first classification (it fires instead of
  // 'onUpdate' there), and the immediate call picks the state up when the
  // first batch landed before this effect ran — otherwise the footer would
  // wait for the next scroll to appear.
  React.useEffect(() => {
    if (items) {
      let timer: ReturnType<typeof setTimeout>;
      const cb = () => {
        clearTimeout(timer);
        timer = setTimeout(
          () => requestAnimationFrame(() => setVisible(items.getVisible())),
          200,
        );
      };
      items.subscribe('onInit', cb);
      items.subscribe('onUpdate', cb);
      cb();

      return () => {
        clearTimeout(timer);
        items.unsubscribe('onInit', cb);
        items.unsubscribe('onUpdate', cb);
      };
    }
  }, [items]);

  if (!visible.length) {
    return null;
  }

  const total = items?.size;
  const visibleItemsLen = visible.length;
  const totalPages = Math.ceil(total / visibleItemsLen);
  const lastVisibleInd = +visible.slice(-1)[0][1].index;
  const currentPage = Math.ceil(lastVisibleInd / visibleItemsLen);
  const pages = Array(totalPages)
    .fill(1)
    .map((_, ind) => ind + 1);
  const itemsLeft = (currentPage - 1) * visibleItemsLen;
  const itemsRight = total - visibleItemsLen * currentPage;

  const scrollToPage = (page: number) => {
    const itemInd = page * visibleItemsLen - 1;
    visibility.scrollToItem(visibility.getItemByIndex(itemInd));
  };

  return (
    <>
      <FooterContainer>
        {pages.map((page) => (
          <button
            key={page}
            data-testid={`page-${page}`}
            onClick={() => scrollToPage(page)}
            onKeyDown={(ev) => {
              if (ev.code === 'Space') {
                scrollToPage(page);
              }
            }}
            className={`page-btn ${page === currentPage ? 'active' : ''}`}
          >
            {page}
          </button>
        ))}
      </FooterContainer>
      <FooterContainer>
        <div>
          <div>
            Items on the left: <span data-testid="items-left">{itemsLeft}</span>
          </div>
          <div>
            Items on the right:{' '}
            <span data-testid="items-right">{itemsRight}</span>
          </div>
        </div>
      </FooterContainer>
    </>
  );
};

const FooterContainer = styled('div')({
  '&': {
    display: 'flex',
    justifyContent: 'center',
    gap: '5px',
    margin: '10px',
  },
  '& button.active': {
    color: 'red',
  },
});

export default Progress;

const NoScrollbar = styled('div')({
  '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
    display: 'none',
  },
  '& .react-horizontal-scrolling-menu--scroll-container': {
    scrollbarWidth: 'none',
    '-ms-overflow-style': 'none',
  },
});

function LeftArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);

  const disabled = visibility.useLeftArrowVisible();

  return (
    <Arrow
      disabled={disabled}
      onClick={() => visibility.scrollPrev()}
      testId="left-arrow"
    >
      Left
    </Arrow>
  );
}

function RightArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);

  const disabled = visibility.useRightArrowVisible();

  return (
    <Arrow
      disabled={disabled}
      onClick={() => visibility.scrollNext()}
      testId="right-arrow"
    >
      Right
    </Arrow>
  );
}

function Arrow({
  children,
  disabled,
  onClick,
  className,
  testId,
}: {
  children: React.ReactNode;
  disabled: boolean;
  onClick: VoidFunction;
  className?: string;
  testId: string;
}) {
  return (
    <ArrowButton
      disabled={disabled}
      onClick={onClick}
      className={'arrow' + `-${className}`}
      data-testid={testId}
    >
      {children}
    </ArrowButton>
  );
}
const ArrowButton = styled('button')((props) => ({
  cursor: 'pointer',
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  marginBottom: '2px',
  opacity: props.disabled ? '0' : '1',
  userSelect: 'none',
  borderRadius: '6px',
  borderWidth: '1px',
}));

function Card({
  onClick,
  selected,
  title,
  itemId,
}: {
  onClick: (context: publicApiType) => void;
  selected: boolean;
  title: string;
  itemId: string;
}) {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isVisible = visibility.useIsVisible(itemId, true);

  return (
    <CardBody
      data-cy={itemId}
      onClick={() => onClick(visibility)}
      onKeyDown={(ev: React.KeyboardEvent) => {
        ev.code === 'Enter' && onClick(visibility);
      }}
      data-testid="card"
      role="button"
      tabIndex={0}
      className="card"
      visible={isVisible}
      selected={selected}
    >
      <div className="header">
        <div>{title}</div>
        <div className="visible">visible: {JSON.stringify(isVisible)}</div>
        <div className="selected">selected: {JSON.stringify(!!selected)}</div>
      </div>
      <div className="background" />
    </CardBody>
  );
}
const CardBody = styled('div')<{ selected?: boolean; visible?: boolean }>(
  (props) => ({
    border: '1px solid',
    display: 'inline-block',
    margin: '0 10px',
    width: '160px',
    userSelect: 'none',
    borderRadius: '8px',
    overflow: 'hidden',

    '& .header': {
      backgroundColor: 'white',
    },

    '& .visible': {
      backgroundColor: props.visible ? 'transparent' : 'gray',
    },

    '& .background': {
      backgroundColor: props.selected ? 'green' : 'bisque',
      height: '200px',
    },
  }),
);

const getId = (index: number) => `${'test'}${index}`;

const getItems = (count: number = 10) =>
  Array(count)
    .fill(0)
    .map((_, ind) => ({ id: getId(ind) }));

function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void {
  // NOTE: no good standart way to distinguish touchpad scrolling gestures
  // but can assume that gesture will affect X axis, mouse scroll only Y axis
  // of if deltaY too small probably is it touchpad
  const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;

  if (isThouchpad) {
    ev.stopPropagation();
    return;
  }

  if (ev.deltaY < 0) {
    apiObj.scrollNext();
  } else {
    apiObj.scrollPrev();
  }
}

Related examples

All 21 examples