Save and restore the scroll position
Scroll the row somewhere, unmount the menu, then mount it again — the rail comes back at the same offset.
Edit this example live in Storybook
How it works
Two callbacks carry the whole feature. onUpdate fires as the menu’s visibility state changes while the user scrolls; savePos reads api.scrollContainer.current.scrollLeft and writes it to sessionStorage. On the next mount, onInit assigns the saved value straight back to scrollLeft — a plain property write, so the restore is instant rather than an animation replaying in front of the user.
Surviving remounts, reloads and back navigation
sessionStorage outlives the component: client-side route changes, conditional renders and full page reloads all come back to the saved offset, and the value is per-tab, so two tabs don’t overwrite each other. For history navigation the story also sets window.history.scrollRestoration = ’manual’, keeping the browser’s own scroll restoration from fighting the manual one on back and forward.
Notes
- Restoring by raw
scrollLeftis pixel-exact and doesn’t care which items exist — no ids to remember, nothing to look up. - The story’s Reload button swaps the menu’s
keyto force a remount; the demo’s unmount/remount toggle is the same test made explicit. - Reset just removes the storage key — the next mount starts from zero like a first visit.
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 Position() {
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),
);
};
const { getPosition, setPosition, reset } = usePosition();
const savePos = React.useCallback(
(api: publicApiType) => {
setPosition(api.scrollContainer.current?.scrollLeft ?? 0);
},
[setPosition],
);
const restorePosition = React.useCallback(
(api: publicApiType) => {
const node = api.scrollContainer.current;
if (node) {
node.scrollLeft = getPosition();
}
},
[getPosition],
);
const [key, setKey] = React.useState(() => String(Math.random()));
const reload = React.useCallback(() => setKey(String(Math.random())), []);
return (
<>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
onUpdate={savePos}
onInit={restorePosition}
key={key}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
</ScrollMenu>
<div>
<button onClick={reset} data-testid="reset">
Reset position
</button>
<button onClick={reload} data-testid="reload">
Reload
</button>
</div>
</>
);
}
const usePosition = () => {
React.useEffect(() => {
window.history.scrollRestoration = 'manual';
}, []);
const setPosition = React.useCallback((pos: number | string) => {
sessionStorage.setItem('position', String(pos));
}, []);
const getPosition = () => +(sessionStorage.getItem('position') || 0);
const reset = React.useCallback(
() => sessionStorage.removeItem('position'),
[],
);
return { getPosition, setPosition, reset };
};
export default Position;
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();
}
}