Load more items when the end scrolls into view
onUpdate asks it after every scroll and appends the next batch when the answer is yes.Scroll to the right end — a loader card appears and the next batch arrives. The demo stops at 30 items.
Edit this example live in Storybook
How it works
onUpdate fires whenever item visibility changes. The handler reads api.items.last()?.visible — the library tracks every item by its itemId and keeps a visibility flag per item, so detecting the end costs one lookup, no IntersectionObserver of your own and no scroll-position math. pushNewItems then simulates a fetch: a one-second timeout, five more items, done.
Guarding the fetch
Visibility updates arrive in bursts, so the handler must be safe to call repeatedly. A loading flag makes it idempotent: both onUpdate and pushNewItems check it, and only the first trigger starts a fetch. The same flag renders a Loader component as a real menu item (with its own itemId) that calls scrollIntoView() on mount, keeping the row’s end in view while the batch loads.
Notes
- The right arrow is passed as an element,
RightArrow={<RightArrow disabled={...} />}— both component and element forms work, and the element form lets the parent pass props like the item cap. - That arrow only disables when the cap is reached and the last item is visible — before the cap, reaching the end means more items are coming.
newItemsLimitstops this demo at 24 items; in real code the equivalent signal is your API running out of pages.
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 AddItemsExample() {
const [items, setItems] = React.useState(() => getItems());
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),
);
};
const newItemsLimit = 24;
const [loading, setLoading] = React.useState(false);
const pushNewItems = () => {
if (loading || items.length > newItemsLimit) {
return false;
}
setLoading(true);
const newItems = items.concat(
Array(5)
.fill(0)
.map((_, ind) => ({ id: getId(items.length + ind) })),
);
new Promise((res) => {
setTimeout(() => res(setItems(newItems)), 1000);
}).finally(() => setLoading(false));
};
return (
<div>
<div>
<NoScrollbar>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={<RightArrow disabled={items.length >= newItemsLimit} />}
onWheel={onWheel}
onUpdate={(api) => {
const lastVisible = api?.items?.last?.()?.visible;
if (lastVisible && !loading) {
pushNewItems();
}
}}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
{loading && <Loader itemId="loader" key="loader" />}
</ScrollMenu>
</NoScrollbar>
</div>
</div>
);
}
function Loader({ itemId }: { itemId: string }) {
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
ref.current?.scrollIntoView();
}, [ref]);
return <div ref={ref}>Loading...</div>;
}
export default AddItemsExample;
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({ disabled }: { disabled: boolean }) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastItemVisible = visibility.useIsVisible('last', false);
return (
<Arrow
disabled={disabled && isLastItemVisible}
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 = () =>
Array(10)
.fill(0)
.map((_, ind) => ({ id: getId(ind) }));
const onWheel = (apiObj: publicApiType, ev: React.WheelEvent) => {
// 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();
}
};