A filter-chip bar that scrolls, in React
The chip row under every search bar — YouTube topics, store filters, tag pickers — is a single-line scroll container full of toggle buttons. The hard 10% is what happens at the edges: new chips appearing off-screen, drags that must not toggle anything, and arrows that know when they are pointless.
Add a filter — the row scrolls the new chip into view by itself.
Edit this example live in Storybook
The edge cases are the feature
Any flex row with overflow-x: auto scrolls. A chip bar earns its keep on the details:
- A chip added off-screen must announce itself. The demo scrolls to every new chip with
apiRef.current.scrollToItem(el, 'smooth', 'end')after render — the add-item-and-scroll-to-it example is exactly this wiring. - Drag to scroll, click to toggle — never both. Desktop users drag the row like a touch surface; releasing over a chip must not flip it. The drag recipe tracks the gesture and suppresses that one click.
- Arrows only when useful.
useLeftArrowVisible/useRightArrowVisibleare wired to the same IntersectionObserver as everything else, so arrows disable at the real edges — including after chips are added or removed.
State stays in your hands
The library scrolls; it does not own selection. Chips are your buttons — aria-pressed for multi-select toggles, plain state for single-select — and the menu only needs each one to carry an itemId. That means chip state composes with whatever you already have: URL search params, a form library, a server-driven filter model. Deleting a chip is removing an item; animating it out is the items-animation example.
Mobile: one warning about body scroll
On touch screens a horizontal swipe inside the bar can drag the page sideways with it on some browsers. If you see that, the prevent-body-scroll example shows the touch-action and overscroll containment to lock it down — CSS only, no gesture library.
The pattern, minimal
Chips are toggle buttons with an itemId; a ref to the menu API scrolls a newly added chip into view.
function ChipBar({ options }: { options: string[] }) {
const apiRef = React.useRef<publicApiType>(null);
const [selected, setSelected] = React.useState<string[]>([]);
const toggle = (id: string) =>
setSelected((cur) =>
cur.includes(id) ? cur.filter((c) => c !== id) : [...cur, id],
);
// A chip appended off-screen scrolls itself into view.
const addChip = (id: string) => {
toggle(id);
requestAnimationFrame(() => {
const el = apiRef.current?.getItemElementById(id);
if (el) apiRef.current?.scrollToItem(el, 'smooth', 'end');
});
};
return (
<ScrollMenu apiRef={apiRef}>
{options.map((id) => (
<Chip itemId={id} key={id} pressed={selected.includes(id)}
onToggle={() => toggle(id)} />
))}
</ScrollMenu>
);
}Or install it as a shadcn component
The chip-bar registry item ships this as a controlled component — options, selected, onSelectedChange — Tailwind-styled in your components/ui/:
npx shadcn@latest add https://react-horizontal-scrolling-menu.dev/r/chip-bar.json