Animation de défilement personnalisée : votre propre easing et durée
noPolyfill={false} vous laisse prendre la main — le menu calcule où le rail doit aller, et votre code y conduit scrollLeft.Cliquez sur les flèches et changez la durée — à 2500 ms, la courbe ease-in-out-cubic est facile à voir. Un clic en pleine animation annule la précédente.
Modifier cet exemple en direct dans Storybook
Comment ça marche
Par défaut, le menu défile avec le scrollIntoView natif et ignore les deux props de transition. Définir noPolyfill={false} route les défilements programmatiques par le polyfill scroll-into-view-if-needed, qui calcule la cible et la remet à votre transitionBehavior comme instructions : une action { el, top, left } par ancêtre défilable qui doit bouger — ici toujours le seul conteneur de défilement, parce que le menu le passe comme limite. À partir de là, animateScroll fait avancer el.scrollLeft vers la cible à chaque requestAnimationFrame, en mappant le progrès via easeInOutCubic sur la durée choisie.
Interrompre une animation en vol
Un second clic de flèche peut atterrir en pleine animation. La story garde le frame en attente par élément dans un WeakMap, donc un nouvel appel annule l’ancienne boucle de requestAnimationFrame au lieu de laisser les deux se battre pour scrollLeft. Et comme chaque animation lit son point de départ dans le scrollLeft actuel de l’élément, la nouvelle reprend exactement là où l’interrompue s’est arrêtée.
Notes
- Rien ici n’est lié à la fonction d’easing — une fois que vous avez la position cible, n’importe quelle courbe ou bibliothèque d’animation fonctionne.
- Les types décrivent
transitionBehaviorcomme une chaîneScrollBehavior, mais la valeur va directement à scroll-into-view-if-needed comme son callbackbehavior— d’où le cast dans la source. - La story câble le même état de durée dans
transitionDurationet dans l’animation elle-même, pour que les deux ne puissent pas diverger.
Source complète
Complète et prête à copier-coller — c’est le fichier exact derrière la version Storybook modifiable en direct.
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';
/**
* What scroll-into-view-if-needed hands to a custom `transitionBehavior`:
* one action per scrollable ancestor that has to move — here always just the
* scroll container, because the menu passes it as `boundary`.
*/
type ScrollAction = { el: Element; top: number; left: number };
const durations = [500, 1200, 2500];
const defaultDuration = 1200;
export function CustomTransitionExample() {
const [items] = React.useState(() => getItems());
const [selected, setSelected] = React.useState<string[]>([]);
const [duration, setDuration] = React.useState(defaultDuration);
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),
);
};
// Instead of letting the browser scroll, receive the computed target
// positions and drive `scrollLeft` there manually — any curve or animation
// library works from here.
const transition = (instructions: ScrollAction[]) => {
instructions.forEach(({ el, left }) => animateScroll(el, left, duration));
};
return (
<div>
<DurationSelect value={duration} onChange={setDuration} />
{/* NOTE: transitionDuration and transitionBehavior only take effect
with noPolyfill={false} — the default noPolyfill={true} scrolls with
native scrollIntoView and ignores both. */}
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
noPolyfill={false}
transitionDuration={duration}
// The typings describe the options-object form, but the menu passes
// this value straight to scroll-into-view-if-needed as its `behavior`
// callback — hence the cast.
transitionBehavior={transition as unknown as ScrollBehavior}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
</ScrollMenu>
</div>
);
}
export default CustomTransitionExample;
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
/**
* A second arrow click can land mid-animation; remembering the pending frame
* per element lets the new animation cancel the old one instead of both
* fighting over `scrollLeft`.
*/
const pendingFrames = new WeakMap<Element, number>();
function animateScroll(el: Element, target: number, duration: number) {
const prevFrame = pendingFrames.get(el);
if (prevFrame !== undefined) {
cancelAnimationFrame(prevFrame);
}
const from = el.scrollLeft;
const distance = target - from;
const startTime = performance.now();
const step = (now: number) => {
const progress = Math.min((now - startTime) / duration, 1);
el.scrollLeft = from + distance * easeInOutCubic(progress);
if (progress < 1) {
pendingFrames.set(el, requestAnimationFrame(step));
} else {
pendingFrames.delete(el);
}
};
pendingFrames.set(el, requestAnimationFrame(step));
}
function DurationSelect({
value,
onChange,
}: {
value: number;
onChange: (val: number) => void;
}) {
return (
<SelectWrapper>
<label htmlFor="duration">Duration</label>
<select
id="duration"
data-testid="duration-select"
value={value}
onChange={(ev: React.ChangeEvent<HTMLSelectElement>) =>
onChange(Number(ev.target.value))
}
>
{durations.map((ms) => (
<option value={ms} key={ms}>
{ms} ms
</option>
))}
</select>
</SelectWrapper>
);
}
const SelectWrapper = styled('div')({
display: 'flex',
alignItems: 'center',
margin: '16px',
'& *:first-child': {
marginRight: '4px',
},
});
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();
}
}