Animate items in, out and into place with auto-animate

Appending to a horizontal list makes the new item pop into place; removing one snaps its neighbors together. @formkit/auto-animate fixes both with a single parent ref — and ScrollMenu’s containerRef prop hands it exactly the element it needs.
item 1
item 2
item 3
item 4
item 5
item 6
item 7
item 8
item 9
item 10
item 11
item 12

Add, remove and shuffle — every entry, exit and reorder is animated. The menu itself has no animation code.

How it works

useAutoAnimate() returns a ref that must land on the direct parent of the elements it should animate. Inside ScrollMenu that parent is the scroll container: each child you pass is wrapped in an item div, and those item divs are the container’s immediate children. The story passes the ref straight through — <ScrollMenu containerRef={parent}> — and auto-animate takes it from there: added items ease in, removed items animate out, and reordered items slide to their new slot. The menu itself never knows it’s being animated.

Add, remove, shuffle

All three controls are plain setState calls on the items array — addItems appends one, removeItems drops the last, shuffle is a Fisher–Yates pass over a copy. The animations come entirely from the DOM mutations those updates cause. One rule is worth keeping: itemId doubles as the React key and as the item’s handle in the menu’s tracking map, so ids must stay unique — the story even backfills numbering gaps left by removals rather than risk minting a duplicate.

Scrolling and tracking keep working

The menu re-observes its children whenever they change, so a freshly added item’s useIsVisible reports correctly right away and the arrows keep paging. A new item usually lands offscreen, though — if the entrance should actually be seen, pair this with scrollToItem the way the add-item-and-scroll-to-it example does.

Notes

Full source

Complete and copy-paste ready — this is the exact file behind the live-editable Storybook version.

Items_animation.source.tsx
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();
  }
}

Related examples

All 21 examples