Scroll the menu with the wheel — without scrolling the page
onWheel handler that turns wheel ticks into paging, and a native non-passive listener that keeps the page from moving underneath. The second half can’t be done from React alone.Park the pointer over the row and spin the wheel: the row pages, the page stays still. Move off the row and the wheel scrolls the page again.
Edit this example live in Storybook
Turning the wheel into paging
ScrollMenu’s onWheel prop is called with the API object and the wheel event. A real mouse wheel reports Y-only deltas in coarse steps, so the handler calls scrollNext when deltaY is negative and scrollPrev otherwise — each tick pages the row. Before any of that it checks whether the event looks like a touchpad gesture: any deltaX at all, or a deltaY under 15.
Why the page lock needs a native listener
Calling preventDefault inside the React handler would be the obvious way to stop the page — and it silently does nothing, because React registers wheel listeners as passive, and a passive listener is forbidden from cancelling the event. So usePreventBodyScroll goes around React: on mouseenter it runs document.addEventListener('wheel', preventDefault, { passive: false }), on mouseleave it removes the listener again. While the pointer is over the menu, every wheel event bubbles up to document and has its default action — scrolling the page — cancelled there. A useEffect cleanup calls enableScroll on unmount, so the page can never be left locked.
The touchpad escape hatch
Two-finger panning arrives as wheel events too, and the container scrolls natively from them — the document listener would kill that. For events matching the touchpad heuristic the handler calls stopPropagation and returns: the event never reaches the document listener, so native panning survives. There’s no reliable way to detect a touchpad; the delta heuristic is the story’s honest guess, and it holds up in practice.
Notes
- Browsers made document-level wheel listeners passive by default precisely so pages can’t jank scrolling —
passive: falseis the explicit opt-out that makespreventDefaultlegal again. - Wheel-up pages forward and wheel-down pages back — that’s the story’s mapping; swap the
scrollNext/scrollPrevbranches for the reverse. - Touch devices never run any of this: there’s no
mouseenter, and swiping the row is native scrolling from the start. - The lock exists only between
mouseenterandmouseleave, so the rest of the page scrolls normally the moment the pointer leaves the rail.
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';
function usePreventBodyScroll() {
const preventDefault = React.useCallback((ev: Event) => {
ev?.preventDefault?.();
}, []);
const enableScroll = React.useCallback(() => {
document && document.removeEventListener('wheel', preventDefault, false);
}, [preventDefault]);
const disableScroll = React.useCallback(() => {
document &&
document.addEventListener('wheel', preventDefault, {
passive: false,
});
}, [preventDefault]);
React.useEffect(() => {
return enableScroll;
}, [enableScroll]);
return { disableScroll, enableScroll };
}
export function PreventBodyScroll() {
const { disableScroll, enableScroll } = usePreventBodyScroll();
const [items] = 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),
);
};
return (
<div style={{ height: '200vh' }}>
<div>
<NoScrollbar onMouseEnter={disableScroll} onMouseLeave={enableScroll}>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
>
{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>
</div>
);
}
export default PreventBodyScroll;
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 = () =>
Array(10)
.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();
}
}