English · 简体中文 · 日本語 · 한국어 · Русский · Español · Português (BR) · Français · Tiếng Việt

A horizontal scrolling menu component for React, built on native browser
scrolling with per-item visibility tracking. Good for category rows, tab
strips, chip filters, galleries — any row of things your app needs to reason
about. Items are your own components with your own CSS; the menu is
responsive to its parent width; navigation works by scrollbar, touch, mouse
wheel, drag, or the arrow components you provide. 5.7 kB min+gzip.

Used by
Over 20,000 repositories depend on this library. Five you can go and read —
every link lands on the import in the component that uses it, pinned to a
commit, not on a package.json:
- Our World in Data
— the key-insights slider in their article renderer; also their
topic facets,
which wraps a react-aria
ToggleButton. ^8.2.0
- Precious Plastic / ONE ARMY
—
VerticalList in their shared component package, built from this library’s
own docs. ^8.2.0
- erxes
— the category menu in their point-of-sale client.
^4.0.4
- Reapit
— the viewport tab bar in their app builder.
^3.2.5
- AWS Performance Dashboard
— the dashboard
Tabs component; their
Arrows
use VisibilityContext directly. Archived in 2024, pins ^2.1.1.
Also featured in React Status #257.
Quick start
npm install react-horizontal-scrolling-menu
Using shadcn/ui? One command installs a styled,
ready-made component — edge-aware arrow buttons, drag-to-scroll, hidden
scrollbar — straight into your components/ui/:
npx shadcn@latest add https://react-horizontal-scrolling-menu.dev/r/scroll-menu.json
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} => visibility.scrollPrev()}>
←
</button>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastVisible = visibility.useIsVisible('last', false);
return (
<button disabled={isLastVisible} => visibility.scrollNext()}>
→
</button>
);
}
function Card({ itemId, title }: { itemId: string; title: string }) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isVisible = visibility.useIsVisible(itemId);
return (
<div style={{ width: '160px' }} data-visible={isVisible}>
{title}
</div>
);
}
Three things the example relies on:
- Every item needs a unique
itemId prop — that’s how visibility 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.
Writing plain JavaScript? Drop the type imports and use
React.useContext(VisibilityContext) as usual.
Using with AI coding agents
Models trained on older releases still reach for visibleElements,
Separator items and an Arrows prop — all removed — and invent an
autoplay prop that never existed. The package ships eight SKILL.md
files to stop that: task-scoped guidance loaded on demand through
TanStack Intent,
versioned with the library rather than with any web page.
npm install react-horizontal-scrolling-menu
npx @tanstack/intent@latest install # once per project
install adds skill discovery to your agent’s config (CLAUDE.md,
.cursorrules, …); the agent then loads a skill on demand from
node_modules/react-horizontal-scrolling-menu/skills/. List or load them
directly with npx @tanstack/intent@latest list and
npx @tanstack/intent@latest load react-horizontal-scrolling-menu#menu-setup.
| Skill |
When it’s loaded |
menu-setup |
A first working menu, arrows, the required CSS import |
menu-visibility |
What’s on screen, and arrow state at the edges |
menu-scrolling |
scrollToItem, apiRef, page-at-a-time paging |
menu-interactions |
Drag, wheel and touch — and their handler factories |
menu-recipes |
Autoplay, infinite loop, load-more: recipes, not props |
menu-transitions-rtl |
Animation timing, custom easing, right-to-left |
menu-testing-ssr |
Next.js and RSC, Jest mocks, Playwright |
menu-migration |
Upgrading pre-v8 code, and the APIs models still invent |
The sources live in skills/. Agents that can’t load Intent
skills should read
llms.txt instead —
the same facts, condensed into one file.
What it does — and doesn’t
Built on native browser scrolling: momentum, scrollbar, touch, wheel and
accessibility come from the browser, not a physics reimplementation. On top
of that: per-item visibility via IntersectionObserver, scrollToItem /
scrollNext / scrollPrev, an apiRef for control from outside, Header
and Footer slots, RTL, dynamic add/remove detection, and TypeScript types
throughout. SSR-safe — the landing page
server-renders every demo.
No snap or spring physics built in — slide effects (fade, cube, coverflow)
are the one job better served by a dedicated effects library, and trying
one costs minutes these days. The
comparison page
lays the trade-offs out honestly, with deep dives on
Embla vs Swiper,
react-slick alternatives
and
Swiper alternatives. Autoplay and infinite loop aren’t props
either; they’re recipes of about sixty lines each on the public API,
live-editable in Storybook
(infinite loop,
autoplay).
If you need a row that knows what’s visible, this is it.
Examples
Complete patterns by outcome, each with a live server-rendered demo, the
code, and a matching shadcn install:
Netflix-style row ·
scrollable tabs ·
filter chips ·
category rail ·
testimonial carousel.
Every example is live-editable in the
Storybook —
each story ships with a Monaco editor loaded with the library’s real type
definitions. Covers: basic usage, one-item-per-scroll, mouse drag, scroll to
item on mount, center on click, adding items dynamically, save/restore
position, items animation, progress dots, preventing body scroll, custom
transitions, infinite loop, autoplay, vertical layout, arrows in the footer,
mobile swipe, RTL, and a 5000-item stress test.
Helpers and API
Children of the main ScrollMenu component (arrows, header, footer, items)
can use VisibilityContext to access state and callbacks. Function
callbacks also receive the context, e.g. onWheel, onScroll.
Properties and callbacks
| Prop |
Signature |
| LeftArrow |
React component for left arrow |
| RightArrow |
React component for right arrow |
| Header |
React component Header |
| Footer |
React component Footer |
| onWheel |
(VisibilityContext, event) => void |
| onScroll |
(VisibilityContext, event) => void, fires before scroll settles |
| onInit |
(VisibilityContext) => void |
| onUpdate |
(VisibilityContext) => void |
| apiRef |
React.RefObject | React.RefCallback |
| options |
options for IntersectionObserver - rootMargin, threshold, and ratio to consider element visible |
| containerRef |
React.RefObject | React.RefCallback for the scroll container |
| onMouseDown |
(VisibilityContext) => (React.MouseEventHandler) => void |
| onMouseLeave |
(VisibilityContext) => (React.MouseEventHandler) => void |
| onMouseUp |
(VisibilityContext) => (React.MouseEventHandler) => void |
| onMouseMove |
(VisibilityContext) => (React.MouseEventHandler) => void |
| onTouchMove |
(VisibilityContext) => (React.TouchEventHandler) => void |
| onTouchStart |
(VisibilityContext) => (React.TouchEventHandler) => void |
| onTouchEnd |
(VisibilityContext) => (React.TouchEventHandler) => void |
| itemClassName |
ClassName of Item |
| scrollContainerClassName |
ClassName of scrollContainer |
| wrapperClassName |
ClassName of the outer-most div |
| transitionDuration |
Duration of transitions in ms, default 500, needs noPolyfill={false} |
| transitionBehavior |
‘smooth’ | ‘auto’ | custom function, needs noPolyfill={false} |
| RTL |
Enable Right to left direction |
| noPolyfill |
true by default (native scrollIntoView); set false to enable transition props |
Note the two callback shapes: onWheel and onScroll are plain
(context, event) => void, while the mouse and touch props are handler
factories — (context) => (event) => void. See the
MouseDrag story
for the factory pattern in use.
VisibilityContext
Hooks (call them only inside components rendered under ScrollMenu, following
the rules of hooks):
| Hook |
Signature |
| useIsVisible |
(itemId: string | ‘first’ | ‘last’, defaultValue?: boolean) => boolean |
| useLeftArrowVisible |
() => boolean |
| useRightArrowVisible |
() => boolean |
Values and functions:
| Prop |
Signature |
| getItemById |
itemId => IOItem | undefined |
| getItemElementById |
itemId => DOM Element | null |
| getItemByIndex |
index => IOItem | undefined |
| getItemElementByIndex |
index => DOM Element | null |
| getNextElement |
() => IOItem | undefined |
| getPrevElement |
() => IOItem | undefined |
| isFirstItemVisible |
boolean |
| isItemVisible |
itemId => boolean |
| isLastItem |
boolean |
| isLastItemVisible |
boolean |
| menuVisible |
{ current: boolean } |
| scrollNext |
(behavior, inline, block, ScrollOptions) => void |
| scrollPrev |
(behavior, inline, block, ScrollOptions) => void |
| scrollToItem |
(item, behavior, inline, block, ScrollOptions) => void |
| items |
ItemsMap class instance |
| scrollContainer |
Ref |
items class instance
ItemsMap stores info about all items, with methods to get currently visible
items and the previous or next item. You can also subscribe to updates.
| Prop/method |
Description |
| subscribe |
subscribe to events for itemId or first, last, onInit, onUpdate, e.g. items.subscribe('item5', (item) => setVisible(item.visible)) |
| unsubscribe |
use in useEffect for cleanup, pass the same callback instance |
| getVisible |
returns only visible items |
| toItems |
returns ids of all items |
| toArr |
returns all items |
| first |
returns the first item |
| last |
returns the last item |
| prev |
(itemId | Item) => previous item | undefined |
| next |
(itemId | Item) => next item | undefined |
Transitions and animation
transitionDuration and transitionBehavior ('smooth', 'auto', or a
custom function) control how scrollToItem and the scroll helpers animate.
Both require noPolyfill={false} — the default native scroll ignores them.
They don’t combine with the RTL prop.
See the
CustomTransition story
for a custom easing function.
The last argument of scrollToItem, scrollPrev and scrollNext overrides
the transition props for that one call:
scrollToItem(getItemElementById('item-5'), 'smooth', 'center', 'nearest', {
duration: 800, // milliseconds
});
Other helpers
slidingWindow
Get the previous or next group of visible items:
slidingWindow(allItems, visibleItems).prev();
// or .next()
getItemsPos
Get the first, center and last item of a group — e.g. to scroll to the
center of the previous page:
const prevGroup = slidingWindow(allItems, visibleItems).prev();
const { center } = getItemsPos(prevGroup);
scrollToItem(getItemById(center), 'smooth', 'center');
apiRef
Pass a ref to ScrollMenu and the full VisibilityContext value is assigned to
it — useful for firing functions like scrollToItem from outside the menu.
Data values on the ref can go stale, so prefer calling functions:
apiRef.current.scrollToItem(apiRef.current.getItemElementById('item-3'));
You can also reach an item’s DOM element directly via
document.querySelector(`[data-key='${itemId}']`). See the
ScrollToItem story
and the
AddItemAndScrollToIt story.
SSR
The library is SSR-safe: the first render emits plain markup and
IntersectionObserver only attaches client-side. The useIsVisible
defaultValue argument controls the server-rendered state — the canonical
arrow pattern (('first', true) / ('last', false)) renders a disabled
left arrow and enabled right arrow, matching a row scrolled to its start.
Next.js note
The package is ESM-first. On older Next.js setups you may hit
“Cannot use import statement outside a module” —
adding the package to
transpilePackages
resolves it.
Browser support
Requires IntersectionObserver and requestAnimationFrame — every
modern browser. No IE.
Development
git clone https://github.com/asmyshlyaev177/react-horizontal-scrolling-menu
cd react-horizontal-scrolling-menu
pnpm run setup
pnpm run demo # example app (Next.js, port 3003) with the library in watch mode
pnpm run demo-tanstack # example app (TanStack Start SSR, port 3004)
pnpm run storybook # examples
pnpm test # unit + e2e + storybook tests
Two integration example apps live in the repo — example-nextjs and
example-tanstack (TanStack Start, server-rendered in workerd) — both
rendering the same demo (mouse drag, body-scroll locking, custom animation
with a control panel) so the one e2e suite in e2e/ runs against the
library under both frameworks, including an assertion that the menu is
already present in the server-rendered HTML.
Contributions and corrections are welcome — fork, commit, open a PR, and
don’t forget tests. See CONTRIBUTING and the
CHANGELOG.
Docs for the legacy v1 API.
About
Built and maintained by Aleksandr Smyshliaev since 2018 — my first npm
package, and still the same public API across React 16.8 to 19. I’m a
frontend engineer (React / Next.js / TypeScript) and available for
contract and full-time work.
A ⭐️ on the repo helps more people find the library.
评论