Custom Animation

Uses new animationInterpolateFn prop introduced in version 3.9 to implement custom animation.

import { generateMockData, RechartsDevtools } from '@recharts/devtools';
import { Scatter, ScatterChart, CartesianGrid, XAxis, YAxis, Tooltip, ZAxis, CartesianLayout } from 'recharts';
import type { AnimationInterpolateFn, ScatterPointItem } from 'recharts';
import type { Lever } from '../../../components/Shared/levers/Levers.tsx';
import { createSelectLever } from '../../../components/Shared/levers/Levers.tsx';
import { animationDurationLever } from '../../../components/Shared/levers/gallery/animationDurationLever.tsx';
import { replayAnimationLever } from '../../../components/Shared/levers/gallery/replayAnimationLever.tsx';
import { swapDataSetLever } from '../../../components/Shared/levers/gallery/swapDataLever.tsx';

const dataA = generateMockData(10, 42);
const dataB = generateMockData(10, 99);

type AnimatedScatterPoint = ScatterPointItem & { opacity?: number };

const crossfade: AnimationInterpolateFn<ScatterPointItem, CartesianLayout> = (items, animationElapsedTime) => {
  if (items == null) return [];
  if (animationElapsedTime === 1) return items.flatMap(item => (item.status === 'removed' ? [] : [item.next]));
  const result: AnimatedScatterPoint[] = [];
  for (const item of items) {
    if (item.status === 'matched') {
      result.push({ ...item.prev, opacity: 1 - animationElapsedTime });
      result.push({ ...item.next, opacity: animationElapsedTime });
    } else if (item.status === 'added') {
      result.push({ ...item.next, opacity: animationElapsedTime });
    } else {
      result.push({ ...item.prev, opacity: 1 - animationElapsedTime });
    }
  }
  return result;
};

const staggeredCrossfade: AnimationInterpolateFn<ScatterPointItem, CartesianLayout> = (items, animationElapsedTime) => {
  if (items == null) return [];
  if (animationElapsedTime === 1) return items.flatMap(item => (item.status === 'removed' ? [] : [item.next]));
  const count = items.length;
  const result: AnimatedScatterPoint[] = [];
  items.forEach((item, index) => {
    if (item.status === 'matched' || item.status === 'added') {
      const delay = index / count;
      const progress = Math.max(0, Math.min(1, (animationElapsedTime - delay) / (1 - delay)));
      result.push({ ...item.next, opacity: progress });
    }
    if (item.status === 'matched' || item.status === 'removed') {
      const reverseIndex = count - 1 - index;
      const delay = reverseIndex / count;
      const progress = Math.max(0, Math.min(1, (animationElapsedTime - delay) / (1 - delay)));
      result.push({ ...item.prev, opacity: 1 - progress });
    }
  });
  return result;
};

const popCrossfade: AnimationInterpolateFn<ScatterPointItem, CartesianLayout> = (items, animationElapsedTime) => {
  if (items == null) return [];
  if (animationElapsedTime === 1) return items.flatMap(item => (item.status === 'removed' ? [] : [item.next]));
  const result: AnimatedScatterPoint[] = [];
  for (const item of items) {
    if (item.status === 'matched' || item.status === 'added') {
      const { next } = item;
      result.push({
        ...next,
        opacity: animationElapsedTime,
        size: next.size * animationElapsedTime * animationElapsedTime,
      });
    }
    if (item.status === 'matched' || item.status === 'removed') {
      const { prev } = item;
      result.push({
        ...prev,
        opacity: 1 - animationElapsedTime,
        size: prev.size * (1 - animationElapsedTime) * (1 - animationElapsedTime),
      });
    }
  }
  return result;
};

const animationOptions = {
  crossfade: { label: 'Crossfade', fn: crossfade },
  staggered: { label: 'Staggered crossfade', fn: staggeredCrossfade },
  pop: { label: 'Pop crossfade (scale + fade)', fn: popCrossfade },
} as const;

type AnimationStyle = keyof typeof animationOptions;

type ControlsType = {
  animationStyle: AnimationStyle;
  animationDuration: number;
  replayKey: number;
  dataSet: 'a' | 'b';
};

export const customAnimationDefaultState: ControlsType = {
  animationStyle: 'crossfade',
  animationDuration: 1500,
  replayKey: 0,
  dataSet: 'a',
};

export const customAnimationLevers = [
  replayAnimationLever<ControlsType>({
    buttonLabel: '▶ Replay initial animation',
  }),
  swapDataSetLever<ControlsType>(),
  createSelectLever<ControlsType, AnimationStyle>({
    key: 'animationStyle',
    label: 'Animation style',
    options: Object.entries(animationOptions).map(([value, option]) => ({
      value: value as AnimationStyle,
      label: option.label,
    })),
    getValue: state => state.animationStyle,
    onChange: (animationStyle, state) => ({ ...state, animationStyle }),
  }),
  animationDurationLever<ControlsType>(),
] satisfies ReadonlyArray<Lever<ControlsType>>;

export default function CustomAnimationExample(props: Partial<ControlsType>) {
  const { animationStyle, animationDuration, replayKey, dataSet } = { ...customAnimationDefaultState, ...props };

  const animationInterpolateFn = animationOptions[animationStyle].fn;

  return (
    <ScatterChart
      key={replayKey}
      style={{ width: '100%', aspectRatio: 1.618, maxWidth: 600 }}
      responsive
      margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
    >
      <CartesianGrid />
      <XAxis dataKey="x" name="x" type="number" />
      <YAxis dataKey="y" name="y" type="number" />
      <ZAxis dataKey="z" range={[1, 1000]} domain={['dataMin', 'dataMax']} />
      <Tooltip />
      <Scatter
        name="Data"
        data={dataSet === 'a' ? dataA : dataB}
        fillOpacity={0.85}
        dataKey="x"
        animationDuration={animationDuration}
        animationInterpolateFn={animationInterpolateFn}
      />
      <RechartsDevtools />
    </ScatterChart>
  );
}
1500