A right-to-left horizontal menu
Flip the switch — the row restarts from the opposite edge and the arrows trade roles.
Edit this example live in Storybook
How it works
RTL={true} puts the scroll container into right-to-left mode: the first item sits at the right edge and scrolling advances leftward. Everything logical stays logical — useIsVisible(’first’) still means the first item in your data, scrollNext() still moves toward the last — only the on-screen direction flips.
Arrows swap slots, not logic
The LeftArrow prop always renders on the left side of the screen. In RTL that side is where “next” lives, so the story feeds the slots swapped elements: LeftArrow={RTL ? <RightArrow /> : <LeftArrow />}. The components themselves keep their logic — the one wired to scrollPrev still disables via useIsVisible(’first’) — only their screen position and label change.
Notes
- The story passes
noPolyfill={true}, so programmatic scrolls use the browser’s native smooth scrolling instead of the bundled polyfill. scrollPrev(’smooth’, ’end’)andscrollNext(’smooth’, ’start’)pass an explicit alignment — the second argument is the samestart/center/endset thatscrollToItemtakes.- The story toggles
RTLlive from a checkbox — the prop is just state, nothing about the menu is configured at build time.
Full source
Complete and copy-paste ready — this is the exact file behind the live-editable Storybook version.
import 'react-horizontal-scrolling-menu/dist/styles.css';
import styled from '@emotion/styled';
import React from 'react';
import {
type publicApiType,
ScrollMenu,
VisibilityContext,
} from 'react-horizontal-scrolling-menu';
export function RTL() {
const [RTL, setRTL] = React.useState(true);
const [items] = React.useState(() => getItems());
const [selected, setSelected] = React.useState<string[]>([]);
const isItemSelected = (id: string): boolean =>
!!selected.find((el) => el === id);
const handleItemClick = (itemId: string) => {
const itemSelected = isItemSelected(itemId);
setSelected((currentSelected: string[]) =>
itemSelected
? currentSelected.filter((el) => el !== itemId)
: currentSelected.concat(itemId),
);
};
return (
<>
<NoScrollbar>
<ScrollMenu
LeftArrow={RTL ? <RightArrow RTL={RTL} /> : <LeftArrow RTL={RTL} />}
RightArrow={RTL ? <LeftArrow RTL={RTL} /> : <RightArrow RTL={RTL} />}
onWheel={onWheel}
RTL={RTL}
noPolyfill={true}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
</ScrollMenu>
</NoScrollbar>
<Checkbox label="RTL" value={RTL} onClick={setRTL} />
</>
);
}
export default RTL;
function LeftArrow({ RTL }: { RTL: boolean }) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isFirstItemVisible = visibility.useIsVisible('first', true);
return (
<Arrow
disabled={isFirstItemVisible}
onClick={() => visibility.scrollPrev('smooth', 'end')}
testId={RTL ? 'right-arrow' : 'left-arrow'}
>
{RTL ? 'Right' : 'Left'}
</Arrow>
);
}
function RightArrow({ RTL }: { RTL: boolean }) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastItemVisible = visibility.useIsVisible('last', false);
return (
<Arrow
disabled={isLastItemVisible}
onClick={() => visibility.scrollNext('smooth', 'start')}
testId={RTL ? 'left-arrow' : 'right-arrow'}
>
{RTL ? 'Left' : 'Right'}
</Arrow>
);
}
function Arrow({
children,
disabled,
onClick,
className,
testId,
}: {
children: React.ReactNode;
disabled: boolean;
onClick: VoidFunction;
className?: string;
testId: string;
}) {
return (
<ArrowButton
disabled={disabled}
onClick={onClick}
className={'arrow' + `-${className}`}
data-testid={testId}
>
{children}
</ArrowButton>
);
}
const ArrowButton = styled('button')((props) => ({
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
marginBottom: '2px',
opacity: props.disabled ? '0' : '1',
userSelect: 'none',
borderRadius: '6px',
borderWidth: '1px',
}));
const Checkbox = ({
onClick,
value,
label,
}: {
value: boolean;
label: string;
onClick: (val: boolean) => void;
}) => {
return (
<CheckboxWrapper>
<BigCheckbox
type="checkbox"
id={label}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) =>
onClick(ev?.target?.checked)
}
checked={value}
defaultChecked={value}
/>
<label htmlFor={label}>{label}</label>
</CheckboxWrapper>
);
};
const CheckboxWrapper = styled('div')({
display: 'flex',
alignItems: 'center',
margin: '16px',
'& *:first-child': {
marginRight: '4px',
},
});
const BigCheckbox = styled('input')({
height: '24px',
width: '24px',
cursor: 'pointer',
});
function Card({
onClick,
selected,
title,
itemId,
}: {
onClick: (visibility: publicApiType) => void;
selected: boolean;
title: string;
itemId: string;
}) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isVisible = visibility.useIsVisible(itemId, true);
return (
<CardBody
data-cy={itemId}
onClick={() => onClick(visibility)}
onKeyDown={(ev: React.KeyboardEvent) => {
ev.code === 'Enter' && onClick(visibility);
}}
data-testid="card"
role="button"
tabIndex={0}
className="card"
visible={isVisible}
selected={selected}
>
<div className="header">
<div>{title}</div>
<div className="visible">visible: {JSON.stringify(isVisible)}</div>
<div className="selected">selected: {JSON.stringify(!!selected)}</div>
</div>
<div className="background" />
</CardBody>
);
}
const CardBody = styled('div')<{ selected?: boolean; visible?: boolean }>(
(props) => ({
border: '1px solid',
display: 'inline-block',
margin: '0 10px',
width: '160px',
userSelect: 'none',
borderRadius: '8px',
overflow: 'hidden',
'& .header': {
backgroundColor: 'white',
},
'& .visible': {
backgroundColor: props.visible ? 'transparent' : 'gray',
},
'& .background': {
backgroundColor: props.selected ? 'green' : 'bisque',
height: '200px',
},
}),
);
const NoScrollbar = styled('div')({
'& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
display: 'none',
},
'& .react-horizontal-scrolling-menu--scroll-container': {
scrollbarWidth: 'none',
'-ms-overflow-style': 'none',
},
});
const getId = (index: number) => `${'test'}${index}`;
const getItems = () =>
Array(10)
.fill(0)
.map((_, ind) => ({ id: getId(ind) }));
function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void {
const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;
if (isThouchpad) {
ev.stopPropagation();
return;
}
if (ev.deltaY < 0) {
apiObj.scrollPrev('smooth', 'end');
} else {
apiObj.scrollNext('smooth', 'start');
}
}