Skip to content

Latest commit

 

History

558 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

React horizontal scrolling menu

npm NPM Downloads npm bundle size (minified + gzip) CI Available for hire

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.

example

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 ARMYVerticalList 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} 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 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.

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.

ScrollOptions

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.

About

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

Topics

Resources

Code of conduct

Contributing

Stars

788 stars

Watchers

6 watching

Forks

Used by

Contributors

Languages