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, as a sixty-line recipe
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.
One component, every horizontal row
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.
Category rows, card carousels, tab strips, chip filters, galleries — any row of things your app needs to reason about, built from your own components and your own CSS. The one thing it skips is fancy slide effects; a library that specializes in those is minutes away to try, and there is no problem in reaching for one.
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.
Or hand it to your coding agent
Models trained on older releases still reach for visibleElements, Separator items and an Arrows prop — all removed years ago — and invent an autoplay prop that never existed. The package ships eight SKILL.md files to stop that: task-scoped guidance your agent loads on demand through TanStack Intent, versioned with the library instead of with this page.
Run once in a project that already has the package installed. Your agent then discovers the skills from node_modules/react-horizontal-scrolling-menu/skills/.
menu-setupA first working menu, arrows, the required CSS importmenu-visibilityWhat’s on screen, and arrow state at the edgesmenu-scrollingscrollToItem, apiRef, page-at-a-time pagingmenu-interactionsDrag, wheel and touch — and their handler factoriesmenu-recipesAutoplay, infinite loop, load-more: recipes, not propsmenu-transitions-rtlAnimation timing, custom easing, right-to-leftmenu-testing-ssrNext.js and RSC, Jest mocks, Playwrightmenu-migrationUpgrading pre-v8 code, and the APIs models still invent
Read the skills on GitHub llms.txt — the same facts, condensed
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
Slide effects are a solved problem elsewhere — if a page needs cube transitions, grab a library that ships them; trying one takes minutes. Infinite loop and autoplay aren’t props — 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.
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.
