Add a scroll progress indicator to a horizontal menu
Scroll the row, drag it, or use the arrows — the bar fills page by page and the counter shows where you are.
Edit this example live in Storybook
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
- Nothing is measured in pixels — the math runs entirely on visibility data, so it keeps working when item widths differ.
- Resize the viewport and the page size follows: more items fit,
getVisible()returns more entries, and the page count recomputes on the next update. - The effect returns a cleanup that calls
items.unsubscribeand clears the pending timer — skip it and an unmounted footer keeps getting called. - Before the first observer report
getVisible()is empty; the story returnsnulluntil then, and the demo paints an empty track.
Full source
Complete and copy-paste ready — this is the exact file behind the live-editable Storybook version.
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();
}
}