| name | menu-recipes |
| description | 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.
|
| metadata | {"type":"core","library":"react-horizontal-scrolling-menu","library_version":"8.2.3"} |
| sources | ["asmyshlyaev177/react-horizontal-scrolling-menu:stories/Autoplay/Autoplay.source.tsx","asmyshlyaev177/react-horizontal-scrolling-menu:stories/InfiniteLoop/InfiniteLoop.source.tsx","asmyshlyaev177/react-horizontal-scrolling-menu:stories/CenterOnClick/CenterOnClick.source.tsx","asmyshlyaev177/react-horizontal-scrolling-menu:stories/SaveRestorePosition/Position.source.tsx","asmyshlyaev177/react-horizontal-scrolling-menu:stories/AddItems/AddItems.source.tsx","asmyshlyaev177/react-horizontal-scrolling-menu:stories/OneItemScroll/OneItemScroll.source.tsx"] |
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} onClick={() => api.scrollPrev()}>
Prev
</button>
);
}
function RightArrow() {
const api = React.useContext<publicApiType>(VisibilityContext);
const disabled = api.useRightArrowVisible();
return (
<button = = => api.scrollNext()}>
Next
);
}
() {
api = .<publicApiType>();
visible = api.(itemId, );
;
}
= () => ;
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;
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= =>
{ids.map(renderCard)}
);
}
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);
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.
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.
export function RestoredMenu() {
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.
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= => {
if (api.items.last()?.visible && !loading) fetchMore();
}}
>
{items.map(renderCard)}
{loading && }
);
}
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 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:
<ScrollMenu autoplay autoplayInterval={3000} loop>
{ids.map(renderCard)}
</ScrollMenu>
Correct:
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);
}, []);
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')}
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));
Correct:
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');
}
}}
Correct:
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/onScroll are plain callbacks.
References