A category rail for your store, in React
Category rails — the tappable row of departments over a storefront grid — are the highest-traffic scroll containers in e-commerce, and they are menus, not carousels: every tile is a link, nothing snaps, and half a tile peeking at the edge is what invites the scroll.
Drag the rail or use the arrows — they disable at the real ends of the row.
Edit this example live in Storybook
Why native scroll wins on a storefront
Storefront rails live above the fold on pages you fight for every Lighthouse point on. A carousel engine ships tens of kilobytes of gesture emulation to do what the browser does natively; this library is ≈5.7 kB min+gzip and leaves scrolling to the platform, so there is no hydration jank — the rail scrolls before your JavaScript loads, which also means it works in the server-rendered HTML your crawlers see. This page is itself server-rendered proof: the demo above scrolls with JavaScript disabled.
The comparison page has the full table against Swiper, Embla, keen-slider and react-slick.
Visibility tracking is a storefront feature
Per-item visibility sounds like an implementation detail until you map it to merchandising:
- Lazy images — render a placeholder tile until
useIsVisiblereports it on screen. - Impression analytics —
getVisible()(live in the hero demo on the homepage) tells you exactly which categories were seen, not just that the rail rendered. - Edge-aware arrows — disable or hide at the true ends, even after categories load in async, as in the add-items example.
Fit it to your design system
Tiles are your components — image cards, circles, text pills — each carrying an itemId. Height and width come from your CSS; the menu imposes no dimensions. Step one item at a time like a product slider with one-item-scroll, show a scroll progress indicator, or ship it RTL for Arabic and Hebrew stores with the RTL example — the rail is composition, not configuration.
The pattern, minimal
Tiles with an itemId, arrows from the visibility hooks — the whole rail is under forty lines.
function CategoryRail({ categories }: { categories: Category[] }) {
return (
<ScrollMenu LeftArrow={LeftArrow} RightArrow={RightArrow}>
{categories.map((category) => (
<CategoryTile itemId={category.id} key={category.id} {...category} />
))}
</ScrollMenu>
);
}
function CategoryTile({ itemId, name, image }: CategoryTileProps) {
// Lazy images for free: placeholders until the tile is on screen.
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isVisible = visibility.useIsVisible(itemId, false);
return (
<a href={`/category/${itemId}`} className="rail-tile">
{isVisible ? <img src={image} alt="" /> : <div className="ph" />}
<span>{name}</span>
</a>
);
}
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const atStart = visibility.useLeftArrowVisible();
return (
<button disabled={atStart} onClick={() => visibility.scrollPrev()}>
←
</button>
);
}Or install it as a shadcn component
The base scroll-menu registry item is this rail — shadcn-styled arrows, drag to scroll, hidden scrollbar — installed into your components/ui/ and styled by your tokens:
npx shadcn@latest add https://react-horizontal-scrolling-menu.dev/r/scroll-menu.json