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.

Sci-Fi
Comedy
Drama
Horror
Docs
Kids
Action
Indie
Anime
Classics
Thriller
Reality

getVisible() → ['scifi', 'comedy', 'drama', 'horror', 'docs', 'kids']

This is the library, live — drag it. Dimmed tiles are the ones useIsVisible reports as off-screen.

npm install react-horizontal-scrolling-menu347k downloads/month5.7 kB min+gzipReact 16.8 – 19MIT

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.

Tokyo
Oslo
Lima
Cairo
Sydney
Quito
Seoul
Porto
Denver
Hanoi

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.

App.tsx
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:

item-1
visible: true
item-2
visible: true
item-3
visible: true
item-4
visible: false
item-5
visible: false
item-6
visible: false
item-7
visible: false
item-8
visible: false
item-9
visible: false
item-10
visible: false

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.

Read the full getting-started example

What’s in the box

  • Per-item visibility hooks — useIsVisible(itemId)
  • first / last helpers for arrow state
  • scrollToItem · scrollNext · scrollPrev
  • apiRef for control from outside the menu
  • Drag, wheel, touch and scrollbar input
  • Dynamic add/remove detection
  • Header and Footer slots
  • slidingWindow + getItemsPos paging helpers
  • Right-to-left support
  • Custom transition functions
  • SSR-safe — this page proves it
  • TypeScript-first — publicApiType exported
  • 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.

788 stars on GitHubFeatured in React Status #257In production at Our World in Data

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.

Built and maintained by Aleksandr Smyshliaev

First published in 2018, same public API across React 16.8 to 19. Aleksandr is a frontend engineer — React, Next.js, TypeScript — currently open to contract and full-time work.