The horizontal menu that knows what’s visible
A React scrolling menu built on the browser’s own scroll — per-item visibility tracking, arrows, drag, and a full imperative API. 5.7 kB gzipped.
getVisible() → ['scifi', 'comedy', 'drama', 'horror', 'docs', 'kids']
This is the library, live — drag it. Dimmed tiles are the ones useIsVisible reports as off-screen.
Autoplay, without a carousel engine
There’s no autoplay prop — this rail is a recipe on the public API: the row cloned onto both ends, one scrollLeft jump at the seam, and a timer calling scrollNext(). It pauses on hover, focus and hidden tabs, sits still under reduced motion — and you can drag it, even backwards, across the seam.
A menu, not a carousel
Embla, Swiper and keen-slider re-implement scrolling in JavaScript to build image sliders — snap points, spring physics, a render loop. This library ships none of that. It rides native browser scrolling and adds the one thing the browser doesn’t give you: knowing exactly which items are on screen.
The wrong tool for a fullscreen image slider — use Embla or Swiper there. The right tool for category rows, tab strips, chip filters, and any row of things your app needs to reason about.
Native scrolling
Momentum, scrollbar, touch, wheel and accessibility come from the browser, not a physics engine. The row scrolls before your JavaScript hydrates — every demo on this page is server-rendered.
Visibility tracking
IntersectionObserver reports which items are on screen. useIsVisible(itemId) subscribes one component to one item — no scroll-position math, and only the affected items re-render.
Imperative when you need it
scrollToItem, scrollNext, scrollPrev, lookup by id or index — through context inside the menu, or apiRef from outside it.
Your components, your CSS
Arrows, header, footer and every item are components you write. Item width is your CSS. The library ships 210 bytes of layout styles and stays out of the way.
Quick start
One file, no configuration: items with an itemId, two arrows reading VisibilityContext, and the stylesheet import.
import React from 'react';
import {
ScrollMenu,
VisibilityContext,
type publicApiType,
} from 'react-horizontal-scrolling-menu';
import 'react-horizontal-scrolling-menu/dist/styles.css';
const items = Array.from({ length: 10 }, (_, i) => `item-${i + 1}`);
export function App() {
return (
<ScrollMenu LeftArrow={LeftArrow} RightArrow={RightArrow}>
{items.map((id) => (
<Card itemId={id} key={id} title={id} />
))}
</ScrollMenu>
);
}
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isFirstVisible = visibility.useIsVisible('first', true);
return (
<button
disabled={isFirstVisible}
onClick={() => visibility.scrollPrev()}
>
←
</button>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastVisible = visibility.useIsVisible('last', false);
return (
<button
disabled={isLastVisible}
onClick={() => visibility.scrollNext()}
>
→
</button>
);
}
function Card({ itemId, title }: { itemId: string; title: string }) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isVisible = visibility.useIsVisible(itemId);
return (
<div className="card" data-visible={isVisible}>
<div>{title}</div>
<div>visible: {String(isVisible)}</div>
</div>
);
}The code on the left, running:
itemId is required on every item — it’s how tracking works. The React key works as a fallback.
styles.css is a separate import; the JS bundle never injects CSS.
Item width comes from your own CSS — the menu measures nothing.
Recipes you’ll actually ship
Four common patterns, live, with the lines that matter.
A tab strip that centers the active tab
Click a tab: scrollToItem with inline: 'center' brings it to the middle of the row. The same call handles start, end and paging.
function Tab({ itemId, label }: { itemId: string; label: string }) {
const api = React.useContext<publicApiType>(VisibilityContext);
const centerOnClick = () => {
const el = api.getItemElementById(itemId);
if (el) api.scrollToItem(el, 'smooth', 'center');
};
return <button onClick={centerOnClick}>{label}</button>;
}Add a chip, scroll to it
State lives outside the menu; apiRef reaches in. Add a filter and the row follows it.
const apiRef = React.useRef<publicApiType>(null);
const lastAdded = React.useRef<string | null>(null);
function addChip(id: string) {
lastAdded.current = id;
setChips((current) => [...current, id]);
}
// After the new chip renders, scroll it into view from outside
// the menu — this is what apiRef is for.
React.useEffect(() => {
const id = lastAdded.current;
if (!id) return;
const el = apiRef.current?.getItemElementById(id);
if (el) apiRef.current?.scrollToItem(el, 'smooth', 'end');
lastAdded.current = null;
}, [chips]);
<ScrollMenu apiRef={apiRef}>…</ScrollMenu>Load more when the end shows up
onUpdate tells you when the last item becomes visible — append the next page right there. No scroll listeners, no pixel thresholds to tune.
<ScrollMenu
onUpdate={(api) => {
// react in onUpdate, not onScroll — onScroll fires
// before the visibility state settles
if (api.items.last()?.visible) loadMore();
}}
>
{cards}
</ScrollMenu>Right-to-left, one prop
RTL flips the scroll container’s direction; arrows and paging logic follow.
<ScrollMenu RTL LeftArrow={LeftArrow} RightArrow={RightArrow}>
{items.map((item) => (
<Item itemId={item.id} key={item.id} label={item.label} />
))}
</ScrollMenu>What’s in the box
- Per-item visibility hooks —
useIsVisible(itemId) first/lasthelpers for arrow statescrollToItem·scrollNext·scrollPrevapiReffor control from outside the menu- Drag, wheel, touch and scrollbar input
- Dynamic add/remove detection
- Header and Footer slots
slidingWindow+getItemsPospaging helpers- Right-to-left support
- Custom transition functions
- SSR-safe — this page proves it
- TypeScript-first —
publicApiTypeexported - One stable API across React 16.8 – 19
Not in the box
- Snap and spring physics
- Fullscreen image sliders
- Lightboxes
Those belong to image-slider land — Embla and Swiper do them well. Infinite loop and autoplay aren’t props either — they’re recipes: about sixty lines of the public API each, live-editable in Storybook. The rail near the top of this page is exactly that recipe, running. This stays a menu.
Downloaded 347,516 times last month by some 20,000 repositories — maintained since 2018.
Every example is editable, in your browser
The Storybook doubles as a playground: each story ships with a Monaco editor loaded with the library’s real type definitions. Change the code, watch it re-render — no sandbox account, no local setup.
