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):
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} => api.scrollPrev()}>
Prev
</button>
);
}
function RightArrow() {
const api = React.useContext<publicApiType>(VisibilityContext);
const disabled = api.useRightArrowVisible();
return (
<button disabled={disabled} => 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
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
=> setPaused(true)}
=> 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.
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
offsetLeftgeometry, idempotent, and must not be gated on visibility flags — they lag a frame behind a teleport - arrows always enabled:
useLeftArrowVisible/useRightArrowVisibletrack the outermost items, which here are clones, so they flash at the seam useIsVisibleOR'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.
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}
=> 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.
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}
>
{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.
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}
=> {
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.
function OneLeftArrow() {
const api = React.useContext<publicApiType>(VisibilityContext);
const =>
api.scrollToItem(api.getPrevElement(), 'smooth', 'start');
return (
<button disabled={api.useLeftArrowVisible()}
Prev
</button>
);
}
function OneRightArrow() {
const api = React.useContext<publicApiType>(VisibilityContext);
const => api.scrollToItem(api.getNextElement(), 'smooth', 'end');
return (
<button disabled={api.useRightArrowVisible()}
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/RightArroware optional — omit them; the menu still scrolls natively (wheel, touch, drag recipes). - Arrows below the menu: render both arrows from a
Footerslot; it readsVisibilityContextlike arrows do (<ScrollMenu Footer={Arrows}>). - Multiple menus per page: each
ScrollMenuis independent, but each must be seen on screen once before its visibility data is valid — below-the-fold menus showdefaultValuestates until scrolled into view. - Tab switching: remount with
key={selectedTab}— see Common Mistakes.
Common Mistakes
CRITICAL Inventing autoplay/loop/snap props
Wrong:
<ScrollMenu autoplay autoplayInterval={3000} loop>
{ids.map(renderCard)}
</ScrollMenu>
Correct:
// 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:
setInterval(() => apiRef.current?.scrollNext(), 3000);
Correct:
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:
onInit={(api) => api.scrollToItem(api.getItemById('item-9'), 'smooth')}
// menu below the fold: the whole page scrolls to the menu on load
Correct:
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:
setItems([...items, newItem]);
apiRef.current.scrollToItem(apiRef.current.getItemById(newItem.id)); // undefined
Correct:
// 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:
onUpdate={(api) => {
if (api.items.last()?.visible) {
api.scrollToItem(api.items.first(), 'smooth'); // visible rewind jump
}
}}
Correct:
// 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:
<ScrollMenu LeftArrow={LeftArrow} RightArrow={RightArrow}>
{tabs[selected].items.map(renderCard)}
</ScrollMenu>
Correct:
<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:
onScroll={(api, ev) => {
const el = ev.target as HTMLElement;
if (el.scrollLeft > el.scrollWidth - 800) fetchMore();
}}
Correct:
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/onScrollare plain callbacks.
References
- Infinite loop: the full hook, Safari fallback and drag integration
- Live story map: URLs and source files per recipe