A vertical scrolling menu — the same component, turned by CSS

There is no 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.
Tokyo
Oslo
Lima
Cairo
Sydney
Quito
Seoul
Porto
Denver
Hanoi

Wheel over the column or use the arrows — Up and Down are ScrollMenu's Header and Footer. Rows dim as they leave the view.

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

Full source

Complete and copy-paste ready — this is the exact file behind the live-editable Storybook version.

Vertical.source.tsx
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) }));

Related examples

All 21 examples