Oculta las flechas en móvil — desplazamiento solo táctil en pantallas pequeñas
Ábrelo en un teléfono o activa la emulación táctil en DevTools — las flechas desaparecen y deslizar hace todo el trabajo.
Edita este ejemplo en vivo en Storybook
Cómo oculta la demo las flechas
LeftArrow y RightArrow son props opcionales — pasa undefined y la ranura no se renderiza en absoluto, así que no hay nada que ocultar con CSS y no quedan botones en el orden de tabulación. El conmutador es una comprobación matchMedia(’(pointer: coarse)’) en un efecto: el servidor no puede saber el tipo de puntero, así que el primer pintado es desktop-first con flechas, y la hidratación las elimina una vez confirmado un puntero grueso. Un listener change lo mantiene vivo — la emulación de dispositivo de DevTools lo voltea sin recargar.
Qué hace la historia al tocar
El hook useSwipe de la historia convierte el paneo libre en paginación. Las props currificadas onTouchStart, onTouchMove y onTouchEnd reciben cada una el objeto API; start reinicia la coordenada final y registra targetTouches[0].clientX, move la rastrea, y end mide la distancia recorrida. Más allá de minSwipeDistance (20px) llama a apiObj.scrollPrev() o apiObj.scrollNext() — una página suave por deslizamiento, sea cual sea la velocidad del dedo.
Suprimir el desplazamiento táctil nativo
Para que la paginación sea el único movimiento, el paneo del navegador tiene que detenerse, y React 18+ registra los listeners de touchmove como pasivos, donde preventDefault se ignora. El efecto de la historia llega al elemento de desplazamiento real a través de apiRef (ref.current.scrollContainer.current) y engancha su propio listener con { passive: false }, donde la llamada funciona.
Notas
- Elige el valor por defecto de SSR a propósito: renderizar las flechas primero favorece a los crawlers y usuarios de escritorio, y los dispositivos táctiles las pierden justo tras la hidratación.
(pointer: coarse)apunta a la entrada, no al tamaño de pantalla — una ventana de escritorio estrecha conserva sus flechas, una tableta no.- Si solo quieres ocultar las flechas y mantener el deslizamiento nativo (el comportamiento de la demo), omite el efecto
touchmovede la historia — el paneo libre y las flechas ocultas conviven bien. - El umbral táctil es de 20px frente a los 50px del flick de escritorio — mira el ejemplo swipe-on-desktop para la variante de ratón.
Fuente completa
Completa y lista para copiar y pegar: este es el archivo exacto que está detrás de la versión de Storybook editable en vivo.
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 MobileSwipeOnly() {
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),
);
};
const { onTouchEnd, onTouchMove, onTouchStart } = useSwipe();
const ref = React.useRef<publicApiType>(null);
// React 18+ attaches touchmove listeners as passive, so preventDefault only
// works from a non-passive listener added manually to the scroll container.
React.useEffect(() => {
const onTouchMove = (ev: TouchEvent) => {
ev.preventDefault();
};
const node = ref.current?.scrollContainer.current;
node?.addEventListener('touchmove', onTouchMove, { passive: false });
return () => node?.removeEventListener('touchmove', onTouchMove);
}, [ref]);
return (
<NoScrollbar>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
onTouchStart={onTouchStart}
apiRef={ref}
>
{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>
);
}
export default MobileSwipeOnly;
export const useSwipe = () => {
const [touchStart, setTouchStart] = React.useState(0);
const [touchEnd, setTouchEnd] = React.useState(0);
// the required distance between touchStart and touchEnd to be detected as a swipe
const minSwipeDistance = 20;
const onTouchStart = () => (ev: React.TouchEvent) => {
setTouchEnd(0);
setTouchStart(ev.targetTouches[0].clientX);
};
const onTouchMove = () => (ev: React.TouchEvent) => {
setTouchEnd(ev.targetTouches[0].clientX);
};
const onTouchEnd = (apiObj: publicApiType) => () => {
if (!touchStart || !touchEnd) return false;
const distance = touchStart - touchEnd;
const isSwipe = Math.abs(distance) > minSwipeDistance;
const isLeftSwipe = distance < minSwipeDistance;
if (isSwipe) {
if (isLeftSwipe) {
apiObj.scrollPrev();
} else {
apiObj.scrollNext();
}
}
};
return { onTouchStart, onTouchEnd, onTouchMove };
};
const NoScrollbar = styled('div')({
'&': {
position: 'relative',
},
'& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
display: 'none',
},
'& .react-horizontal-scrolling-menu--scroll-container': {
scrollbarWidth: 'none',
'-ms-overflow-style': 'none',
},
});
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const disabled = visibility.useLeftArrowVisible();
return (
<Arrow
disabled={disabled}
onClick={() => visibility.scrollPrev()}
testId="left-arrow"
>
Left
</Arrow>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const disabled = visibility.useRightArrowVisible();
return (
<Arrow
disabled={disabled}
onClick={() => visibility.scrollNext()}
testId="right-arrow"
>
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',
}));
function Card({
onClick,
selected,
title,
itemId,
}: {
onClick: (context: 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 getId = (index: number) => `${'test'}${index}`;
const getItems = () =>
Array(10)
.fill(0)
.map((_, ind) => ({ id: getId(ind) }));
function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void {
// NOTE: no good standart way to distinguish touchpad scrolling gestures
// but can assume that gesture will affect X axis, mouse scroll only Y axis
// of if deltaY too small probably is it touchpad
const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;
if (isThouchpad) {
ev.stopPropagation();
return;
}
if (ev.deltaY < 0) {
apiObj.scrollNext();
} else {
apiObj.scrollPrev();
}
}