Animar elementos al entrar, salir y a su sitio con auto-animate
@formkit/auto-animate arregla ambas cosas con una sola ref padre — y la prop containerRef de ScrollMenu le entrega exactamente el elemento que necesita.Añade, elimina y baraja — cada entrada, salida y reordenación está animada. El propio menú no tiene código de animación.
Edita este ejemplo en vivo en Storybook
Cómo funciona
useAutoAnimate() devuelve una ref que debe caer sobre el padre directo de los elementos que debe animar. Dentro de ScrollMenu ese padre es el contenedor de desplazamiento: cada hijo que pasas se envuelve en un div de elemento, y esos divs de elemento son los hijos inmediatos del contenedor. La historia pasa la ref de largo — <ScrollMenu containerRef={parent}> — y auto-animate toma el relevo: los elementos añadidos se atenúan al entrar, los eliminados se animan al salir y los reordenados se deslizan a su nueva ranura. El propio menú nunca sabe que lo están animando.
Añadir, eliminar, barajar
Los tres controles son llamadas setState corrientes sobre el array items — addItems añade uno, removeItems suelta el último, shuffle es una pasada de Fisher–Yates sobre una copia. Las animaciones provienen enteramente de las mutaciones de DOM que causan esas actualizaciones. Vale la pena mantener una regla: itemId hace las veces de key de React y de manejador del elemento en el mapa de seguimiento del menú, así que los id deben seguir siendo únicos — la historia incluso rellena los huecos de numeración que dejan las eliminaciones en lugar de arriesgarse a acuñar un duplicado.
El desplazamiento y el seguimiento siguen funcionando
El menú vuelve a observar a sus hijos siempre que cambian, así que el useIsVisible de un elemento recién añadido informa correctamente de inmediato y las flechas siguen paginando. Un elemento nuevo suele aterrizar fuera de pantalla, eso sí — si la entrada debe verse de verdad, combina esto con scrollToItem como hace el ejemplo add-item-and-scroll-to-it.
Notas
containerRefacepta un objeto ref o una ref callback — el callback deuseAutoAnimatese conecta directamente.- auto-animate es de configuración cero e independiente del framework; el enlace a React es el único hook
useAutoAnimate. - La demo de arriba simplifica la gestión de id a un contador monótono; el panel de código muestra la versión con relleno de huecos de la historia.
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 { 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();
}
}
Ejemplos relacionados
- Añadir un elemento y desplazarse a élEl patrón de chips de filtro: añadir y luego traer a la vista.
- Cargar más cuando aparece el finalAñadido infinito impulsado por la visibilidad del último elemento.
- Animación de desplazamiento personalizadaTrae tu propio easing y duración para los desplazamientos programáticos.