UNPKG

react-horizontal-scrolling-menu

Version:

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

483 lines (391 loc) 15.1 kB
--- name: 'menu-recipes' description: > Recipes composed on the react-horizontal-scrolling-menu public APINOT 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`): ```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 - [Infinite loop: the full hook, Safari fallback and drag integration](references/infinite-loop.md) - [Live story map: URLs and source files per recipe](references/stories.md)