A vertical scrolling menu — the same component, turned by CSS
vertical prop, and none is needed: the menu is a flex row inside a native scroll container, so pointing it downward is a couple of CSS overrides. Visibility tracking, the arrow hooks and scrollPrev/scrollNext all keep working on the new axis.Wheel over the column or use the arrows — Up and Down are ScrollMenu's Header and Footer. Rows dim as they leave the view.
Edit this example live in Storybook
Two overrides and one height bound
The story restyles two library class names. The scroll container gets flex-direction: column, overflow-y: auto and height: initial in place of the default max-content; the wrapper gets height: 100%, so whatever fixed height the parent has becomes the scrolling bound. That’s the entire vertical mode. The story applies the overrides with emotion; the demo on this page passes Tailwind utilities through the wrapperClassName and scrollContainerClassName props instead — any styling route works, the class names are stable.
Arrows become Header and Footer
The LeftArrow/RightArrow slots render beside the rail — the wrong place for a column. ScrollMenu also accepts Header and Footer components rendered above and below, and the story mounts its Up and Down buttons there. They’re ordinary VisibilityContext consumers: useIsVisible('first', true) disables Up at the top, useIsVisible('last', false) disables Down at the bottom. The clicks pass a third argument — scrollPrev(undefined, undefined, 'end') and scrollNext(undefined, undefined, 'start') — the block position for scrollIntoView. 'end' drops the previous item at the bottom edge (a full page up); 'start' puts the next item at the top (a full page down). With the default 'nearest', each click would only nudge the next row into view.
Keeping the scroll inside the column
scrollIntoView moves every scrollable ancestor of its target, and the page is one of them — so a block-aligned jump inside a column takes the whole document with it. The option that stops the walk is boundary, passed in the fourth argument: scrollNext(undefined, undefined, 'start', { boundary }) with the menu’s own scrollContainer.current scrolls the rows and nothing else. It needs noPolyfill={false} on ScrollMenu, since only the polyfill understands boundary — the demo above passes both. Horizontal menus rarely run into this: their default block: 'nearest' asks the page for no vertical movement in the first place.
Visibility has no axis
useIsVisible is IntersectionObserver-backed, and intersection is measured in both dimensions — rows report their state as they cross the top and bottom edges exactly the way horizontal items do at the sides. The demo dims out-of-view rows to show it, with the first four server-painted visible via the hook’s defaultValue argument.
Notes
- The only fixed dimension is the panel’s inline height; the wrapper’s
height: 100%carries it down to the scroll container. - Wheel and touch scroll the column natively —
overflow-y: automakes it a real scroll container; the arrows are convenience, not mechanism. - The second argument of
scrollPrev/scrollNextis theinline(horizontal) position — vertical menus care aboutblock, which is why the story passes it explicitly.
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';
const NoScrollbar = styled('div')({
'& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
display: 'none',
},
// NOTE: also need to set on parent:
// display: 'flex' and position: 'relative'
'& .react-horizontal-scrolling-menu--wrapper': {
height: '100%',
},
'& .react-horizontal-scrolling-menu--scroll-container': {
height: 'initial',
scrollbarWidth: 'none',
'-ms-overflow-style': 'none',
overflowY: 'auto',
flexDirection: 'column',
},
});
export function VerticalExample() {
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 (
<NoScrollbar>
<ScrollMenu Header={UpArrow} Footer={DownArrow}>
{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>
);
}
export default VerticalExample;
function UpArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isFirstItemVisible = visibility.useIsVisible('first', true);
return (
<Arrow
disabled={isFirstItemVisible}
onClick={() => visibility.scrollPrev(undefined, undefined, 'end')}
testId="up-arrow"
>
Up
</Arrow>
);
}
function DownArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastItemVisible = visibility.useIsVisible('last', false);
return (
<Arrow
disabled={isLastItemVisible}
onClick={() => visibility.scrollNext(undefined, undefined, 'start')}
testId="down-arrow"
>
Down
</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: '125px',
},
}),
);
const getId = (index: number) => `${'test'}${index}`;
const getItems = () =>
Array(10)
.fill(0)
.map((_, ind) => ({ id: getId(ind) }));