Animer les éléments à l’entrée, à la sortie et à leur place avec auto-animate
@formkit/auto-animate corrige les deux avec une seule ref parente — et la prop containerRef de ScrollMenu lui remet exactement l’élément dont il a besoin.Ajoutez, retirez et mélangez — chaque entrée, sortie et réorganisation est animée. Le menu lui-même n’a pas de code d’animation.
Modifier cet exemple en direct dans Storybook
Comment ça marche
useAutoAnimate() renvoie une ref qui doit se poser sur le parent direct des éléments à animer. Dans ScrollMenu, ce parent est le conteneur de défilement : chaque enfant que vous passez est enveloppé dans un div d’élément, et ces divs d’élément sont les enfants immédiats du conteneur. La story fait passer la ref directement — <ScrollMenu containerRef={parent}> — et auto-animate prend le relais : les éléments ajoutés entrent en douceur, les éléments retirés s’animent à la sortie, et les éléments réordonnés glissent vers leur nouveau slot. Le menu lui-même ne sait jamais qu’il est animé.
Ajouter, retirer, mélanger
Les trois contrôles sont de simples appels setState sur le tableau items — addItems en ajoute un, removeItems laisse tomber le dernier, shuffle est une passe de Fisher–Yates sur une copie. Les animations viennent entièrement des mutations du DOM que ces mises à jour causent. Une règle mérite d’être gardée : itemId sert à la fois de key React et de poignée de l’élément dans la carte de suivi du menu, donc les id doivent rester uniques — la story va jusqu’à reboucher les trous de numérotation laissés par les suppressions plutôt que de risquer de frapper un doublon.
Le défilement et le suivi continuent de fonctionner
Le menu re-observe ses enfants à chaque changement, donc le useIsVisible d’un élément fraîchement ajouté rapporte correctement tout de suite et les flèches continuent de paginer. Un nouvel élément atterrit généralement hors écran, cela dit — si l’entrée doit réellement être vue, associez ceci à scrollToItem comme le fait l’exemple add-item-and-scroll-to-it.
Notes
containerRefaccepte un objet ref ou une ref callback — le callback deuseAutoAnimatese branche directement.- auto-animate est sans configuration et indépendant du framework ; la liaison React est le seul hook
useAutoAnimate. - La démo ci-dessus simplifie la gestion des id en un compteur monotone ; le panneau de code montre la version avec rebouchage de trous de la story.
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 { useAutoAnimate } from '@formkit/auto-animate/react';
import React from 'react';
import {
type publicApiType,
ScrollMenu,
VisibilityContext,
} from 'react-horizontal-scrolling-menu';
export function ItemsAnimation() {
const [parent] = useAutoAnimate();
const [items, setItems] = React.useState(() => getItems(10));
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 addItems = React.useCallback(() => {
setItems((curr) => {
const currentItemsNumbers = curr
.map((el) => +el.id.replace(/\D/g, ''))
.sort();
const lastSeqItem = currentItemsNumbers.find(
(el, ind, arr) => arr[ind + 1] - el > 1,
);
const haveGaps = typeof lastSeqItem === 'number';
return [
...curr,
...getItems(1, haveGaps ? lastSeqItem + 1 : curr.length),
];
});
}, []);
const removeItems = React.useCallback(() => {
setItems((curr) => [...curr.sort().slice(0, curr.length - 1)]);
}, []);
const shuffle = React.useCallback(() => {
setItems((curr) => {
const array = [...curr];
let currentIndex = array.length,
randomIndex;
while (currentIndex > 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[array[currentIndex], array[randomIndex]] = [
array[randomIndex],
array[currentIndex],
];
}
return array;
});
}, []);
return (
<NoScrollbar>
<ScrollMenu
containerRef={parent}
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
noPolyfill={false}
>
{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 style={{ display: 'flex', gap: '8px', margin: '8px' }}>
<button onClick={addItems}>Add item</button>
<button onClick={removeItems}>Remove item</button>
<button onClick={shuffle}>Shuffle items</button>
</div>
</NoScrollbar>
);
}
export default ItemsAnimation;
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',
},
});
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 = (count: number, start: number = 0) =>
Array(count)
.fill(0)
.map((_, ind) => ({ id: getId(start + 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();
}
}
Exemples associés
- Ajouter un élément et défiler vers luiLe motif des puces de filtre : ajouter, puis amener à la vue.
- Charger plus quand la fin apparaîtAjout infini piloté par la visibilité du dernier élément.
- Animation de défilement personnaliséeApportez votre propre easing et durée pour les défilements programmatiques.