Skip to content

react-horizontal-scrolling-menu

Horizontal scrolling menu component for React with per-item visibility tracking. Supports mouse and touch devices, SSR-safe.

menu-recipes

483 linesSource

Recipes composed on the react-horizontal-scrolling-menu public API — NOT props: autoplay (setInterval + scrollNext gated on menuVisible), infinite loop/carousel (clone head/tail + scrollLeft teleport at seams), center on click (scrollToItem 'center'), save/restore scroll position (onUpdate, onInit, scrollContainer), load-more with a loader item (items.last()?.visible in onUpdate), one item per scroll (getPrevElement/getNextElement), hiding arrows, multiple menus, tab switching. Load when asked for autoplay, looping, carousel-like behavior, infinite scroll, or any feature missing from the props table — inventing an autoplay/loop/snap prop generates dead code.

react-horizontal-scrolling-menu — Recipes

No autoplay, loop, or snap props exist, by design (README "What it does — and doesn't"). Each feature below is a ~60-line recipe on the public API. Generate the recipe, never a prop.

Every recipe is also a live-editable Storybook story — URLs and source paths in references/stories.md.

Setup

Shared base every pattern below builds on (scrollbar hiding and item spacing are plain CSS — see skills/menu-setup/SKILL.md):

tsx
import React from 'react';
import {
  ScrollMenu,
  VisibilityContext,
  type publicApiType,
} from 'react-horizontal-scrolling-menu';
import 'react-horizontal-scrolling-menu/dist/styles.css';

const ids = Array.from({ length: 10 }, (_, i) => `item-${i}`);

function LeftArrow() {
  const api = React.useContext<publicApiType>(VisibilityContext);
  const disabled = api.useLeftArrowVisible();
  return (
    <button disabled={disabled} onClick={() => api.scrollPrev()}>
      Prev
    </button>
  );
}
function RightArrow() {
  const api = React.useContext<publicApiType>(VisibilityContext);
  const disabled = api.useRightArrowVisible();
  return (
    <button disabled={disabled} onClick={() => api.scrollNext()}>
      Next
    </button>
  );
}
function Card({ itemId, title }: { itemId: string; title: string }) {
  const api = React.useContext<publicApiType>(VisibilityContext);
  const visible = api.useIsVisible(itemId, true);
  return <div style={{ width: 160, opacity: visible ? 1 : 0.5 }}>{title}</div>;
}
// Children below are written as ids.map(renderCard) to keep recipes short.
const renderCard = (id: string) => <Card itemId={id} key={id} title={id} />;

Core Patterns

Autoplay: interval calling scrollNext, gated on menu visibility

tsx
export function AutoplayMenu({ interval = 3000 }: { interval?: number }) {
  const apiRef = React.useRef<publicApiType | null>(null);
  const [paused, setPaused] = React.useState(false);
  React.useEffect(() => {
    if (paused) return;
    const id = window.setInterval(() => {
      const api = apiRef.current;
      // Off-screen scrollNext drags the page; a hidden tab freezes IO.
      if (!api?.menuVisible.current || document.visibilityState !== 'visible')
        return;
      api.scrollNext();
    }, interval);
    return () => window.clearInterval(id);
  }, [paused, interval]);
  return (
    <div
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
    >
      <ScrollMenu LeftArrow={LeftArrow} RightArrow={RightArrow} apiRef={apiRef}>
        {ids.map(renderCard)}
      </ScrollMenu>
    </div>
  );
}

The full story also pauses on touch/focus, stays off under prefers-reduced-motion (WCAG 2.2.2), and layers this timer on the infinite-loop hook below so playback never hits the end of the row.

Infinite loop: clone head/tail, teleport scrollLeft at the seams

Render [tailClones, ...items, headClones]; when scrolling settles inside a clone zone, shift scrollLeft by one loop length — clone zones are pixel-identical, so the jump is invisible.

tsx
export function LoopMenu() {
  const loop = useInfiniteLoop(ids); // references/infinite-loop.md
  return (
    <ScrollMenu {...loop.menuProps} LeftArrow={LoopPrev} RightArrow={LoopNext}>
      {loop.slides.map(({ itemId, realId }) => (
        // itemId must stay unique (clone suffix); display/selection use realId
        <Card itemId={itemId} key={itemId} title={realId} />
      ))}
    </ScrollMenu>
  );
}

Four parts of the hook are load-bearing, and each one is a bug if dropped:

  • clone zones at least a viewport wide per side (CLONES_PER_SIDE = 6), or a Next click from the page straddling the seam clamps at the row end
  • the teleport is pure offsetLeft geometry, idempotent, and must not be gated on visibility flags — they lag a frame behind a teleport
  • arrows always enabled: useLeftArrowVisible/useRightArrowVisible track the outermost items, which here are clones, so they flash at the seam
  • useIsVisible OR'd across an item and both clone twins, same lag

normalize() also has to run after any manual scrollLeft write (a drag), not just on scrollend.

Center the clicked item

Items read the api from VisibilityContext — no apiRef needed inside the menu. scrollToItem takes an element or item object, never an id string.

tsx
function CenterCard({
  itemId,
  selected,
  onSelect,
}: {
  itemId: string;
  selected: boolean;
  onSelect: (id: string) => void;
}) {
  const api = React.useContext<publicApiType>(VisibilityContext);
  const handleClick = () => {
    onSelect(itemId);
    const el = api.getItemElementById(itemId);
    if (el) api.scrollToItem(el, 'smooth', 'center');
  };
  return (
    <div
      role="button"
      tabIndex={0}
      onClick={handleClick}
      onKeyDown={(ev) => ev.code === 'Enter' && handleClick()}
      style={{ width: 160, background: selected ? 'palegreen' : 'white' }}
    >
      {itemId}
    </div>
  );
}

Render CenterCard where Setup renders Card, holding the selected id in the parent — the menu itself needs no extra props.

Save and restore scroll position

Save scrollContainer.current.scrollLeft in onUpdate (fires after scroll settles — onScroll fires mid-animation), restore it in onInit.

tsx
export function RestoredMenu() {
  // Also set window.history.scrollRestoration = 'manual' once (an effect),
  // so the browser doesn't fight the restore on reload/back navigation.
  const save = (api: publicApiType) =>
    sessionStorage.setItem(
      'menu-pos',
      String(api.scrollContainer.current?.scrollLeft ?? 0),
    );

  const restore = (api: publicApiType) => {
    const node = api.scrollContainer.current;
    if (node) node.scrollLeft = +(sessionStorage.getItem('menu-pos') ?? 0);
  };
  return (
    <ScrollMenu
      LeftArrow={LeftArrow}
      RightArrow={RightArrow}
      onUpdate={save}
      onInit={restore}
    >
      {ids.map(renderCard)}
    </ScrollMenu>
  );
}

Load more when the end comes into view

Give the loader its own itemId as the last child; trigger fetching from onUpdate when the last item becomes visible.

tsx
const Loader = ({ itemId }: { itemId: string }) => (
  <div style={{ width: 160 }}>Loading…</div>
);

export function LoadMoreMenu() {
  const [items, setItems] = React.useState(ids);
  const [loading, setLoading] = React.useState(false);

  const fetchMore = () => {
    setLoading(true);
    window.setTimeout(() => {
      setItems((cur) => [
        ...cur,
        ...Array.from({ length: 5 }, (_, i) => `item-${cur.length + i}`),
      ]);
      setLoading(false);
    }, 1000);
  };
  return (
    <ScrollMenu
      LeftArrow={LeftArrow}
      RightArrow={RightArrow}
      onUpdate={(api) => {
        if (api.items.last()?.visible && !loading) fetchMore();
      }}
    >
      {items.map(renderCard)}
      {loading && <Loader itemId="loader" key="loader" />}
    </ScrollMenu>
  );
}

One item per scroll

scrollNext/scrollPrev page a full group of visible items; getPrevElement()/getNextElement() return the single item adjacent to the visible window.

tsx
function OneLeftArrow() {
  const api = React.useContext<publicApiType>(VisibilityContext);
  const onClick = () =>
    api.scrollToItem(api.getPrevElement(), 'smooth', 'start');
  return (
    <button disabled={api.useLeftArrowVisible()} onClick={onClick}>
      Prev
    </button>
  );
}
function OneRightArrow() {
  const api = React.useContext<publicApiType>(VisibilityContext);
  const onClick = () => api.scrollToItem(api.getNextElement(), 'smooth', 'end');
  return (
    <button disabled={api.useRightArrowVisible()} onClick={onClick}>
      Next
    </button>
  );
}

For custom group/page math the package also exports the menu's own helpers: slidingWindow(api.items.toItems(), visibleIds).next() picks the next group and getItemsPos(group).center its centre id — see skills/menu-scrolling/SKILL.md.

Hiding arrows, arrows below the menu, multiple menus, tabs

  • No arrows: LeftArrow/RightArrow are optional — omit them; the menu still scrolls natively (wheel, touch, drag recipes).
  • Arrows below the menu: render both arrows from a Footer slot; it reads VisibilityContext like arrows do (<ScrollMenu Footer={Arrows}>).
  • Multiple menus per page: each ScrollMenu is independent, but each must be seen on screen once before its visibility data is valid — below-the-fold menus show defaultValue states until scrolled into view.
  • Tab switching: remount with key={selectedTab} — see Common Mistakes.

Common Mistakes

CRITICAL Inventing autoplay/loop/snap props

Wrong:

tsx
<ScrollMenu autoplay autoplayInterval={3000} loop>
  {ids.map(renderCard)}
</ScrollMenu>

Correct:

tsx
// Autoplay is a recipe: a timer firing scrollNext(), gated on visibility.
const apiRef = React.useRef<publicApiType | null>(null);
React.useEffect(() => {
  const id = window.setInterval(() => {
    if (apiRef.current?.menuVisible.current) apiRef.current.scrollNext();
  }, 3000);
  return () => window.clearInterval(id);
}, []);
// Loop is the useInfiniteLoop clone-and-teleport hook — references/infinite-loop.md

These props do not exist and are silently ignored — the menu renders normally and never plays or loops; snap physics is out of scope by design (use Embla or Swiper for a physics carousel).

Source: README.md "What it does — and doesn't"; stories/Autoplay, stories/InfiniteLoop

HIGH Autoplay interval scrolls the page to the menu

Wrong:

tsx
setInterval(() => apiRef.current?.scrollNext(), 3000);

Correct:

tsx
setInterval(() => {
  if (apiRef.current?.menuVisible.current) apiRef.current.scrollNext();
}, 3000);

Scrolling is scrollIntoView-based, so scrollNext on an off-screen menu scrolls ancestors too — the page keeps jumping back to the menu every tick.

Source: stories/Autoplay/Autoplay.source.tsx:56-66; issue #276

HIGH Any programmatic scroll while the menu is off screen drags the page

Wrong:

tsx
onInit={(api) => api.scrollToItem(api.getItemById('item-9'), 'smooth')}
// menu below the fold: the whole page scrolls to the menu on load

Correct:

tsx
onUpdate={(api) => {
  if (api.items.getVisible().length && !api.isItemVisible('item-9')) {
    api.scrollToItem(api.getItemById('item-9'), 'smooth');
  }
}}

Same mechanism as autoplay above, for every recipe calling scroll methods outside a user gesture — gate on menuVisible.current or items.getVisible().

Source: issue #276 (#277, #174, #230); skills/menu-scrolling/SKILL.md

HIGH getItemById right after adding an item returns undefined

Wrong:

tsx
setItems([...items, newItem]);
apiRef.current.scrollToItem(apiRef.current.getItemById(newItem.id)); // undefined

Correct:

tsx
// after the state update commits (effect or onUpdate callback):
apiRef.current.scrollToItem(apiRef.current.getItemElementById(newItem.id));

The internal ItemsMap lags children by one render; getItemElementById queries the DOM by data-key and sees the new item immediately.

Source: issue #167; discussion #295; stories/AddItemAndScrollToIt

MEDIUM Promising a seamless infinite loop without clones

Wrong:

tsx
onUpdate={(api) => {
  if (api.items.last()?.visible) {
    api.scrollToItem(api.items.first(), 'smooth'); // visible rewind jump
  }
}}

Correct:

tsx
// Seamless needs cloned head/tail items plus a scrollLeft teleport at the
// seams — the useInfiniteLoop hook from references/infinite-loop.md:
const loop = useInfiniteLoop(ids);

Without pixel-identical clone zones the only possible "loop" is an animated scroll back to the first item — a visible jump, not a loop.

Source: issue #213; stories/InfiniteLoop/InfiniteLoop.source.tsx:94-113; stories/loopTestUtils.ts

MEDIUM Tab switching reuses one menu with stale position and state

Wrong:

tsx
<ScrollMenu LeftArrow={LeftArrow} RightArrow={RightArrow}>
  {tabs[selected].items.map(renderCard)}
</ScrollMenu>

Correct:

tsx
<ScrollMenu key={selected} LeftArrow={LeftArrow} RightArrow={RightArrow}>
  {tabs[selected].items.map(renderCard)}
</ScrollMenu>

Swapping the item set in place keeps the previous tab's scroll offset and visibility entries; key={selected} remounts the menu fresh per tab.

Source: discussion #294; issue #204

MEDIUM Load-more from scroll-position math instead of a loader item

Wrong:

tsx
onScroll={(api, ev) => {
  const el = ev.target as HTMLElement;
  if (el.scrollLeft > el.scrollWidth - 800) fetchMore();
}}

Correct:

tsx
onUpdate={(api) => {
  if (api.items.last()?.visible && !loading) fetchMore();
}}

Pixel thresholds break with dynamic item widths and onScroll fires mid-animation; the last item's visibility flag is layout-independent, and onUpdate fires only after the scroll settles.

Source: stories/AddItems/AddItems.source.tsx:54-59; discussion #297

See also

  • skills/menu-scrolling/SKILL.md — every recipe is built from the imperative API (scrollToItem, scrollNext/scrollPrev, scrollContainer, apiRef, getItemElementById, slidingWindow).
  • skills/menu-interactions/SKILL.md — drag, wheel and body-scroll recipes wire the pointer callback shapes: mouse/touch props are handler factories (api) => (event) => void; onWheel/onScroll are plain callbacks.

References