DEV Community

Cover image for Part 2: Property animations
Zsolt Szabó
Zsolt Szabó

Posted on

1 1 1 1 1

Part 2: Property animations

In Part 1, we built an animated list using AnimDiv, AnimNode, and a hook to connect them. We also added a simple fade-in animation for the list items. But everything was hardcoded. In this part, we will make it more customizable.

Our goal is to create an API similar to the one in the motion package. We want to set initial values for the properties, define animation targets, and control duration and the easing function. For now, we won't handle exit animations—that will come in Part 3.

<motion.div
  layout
  key={id}
  initial={{ opacity: 0, transition: { duration: 0.3 } }}
  exit={{ opacity: 0, transition: { duration: 0.3 } }}
  animate={{ opacity: 1, transition: { duration: 0.2 } }}
  transition={{ duration: 0.2 }}
>
  {renderItem(id)}
</motion.div>
Enter fullscreen mode Exit fullscreen mode

Extending the AnimDiv props

First, we define the types we will use:

export type Properties = {
  x: number;
  y: number;
  opacity: number;
};

export type AnimationEasing =
  | "linear"
  | "ease"
  | "ease-in"
  | "ease-out"
  | "ease-in-out";

export type AnimationOptions = {
  duration?: number;
  easing?: AnimationEasing;
};
Enter fullscreen mode Exit fullscreen mode
  • Properties includes the animation properties we support. For this tutorial these are limited to x, y, and opacity.
  • AnimationOptions defines the duration and easing function.

To use these as props in AnimDiv, we need to make them optional and pass them to the useAnimNode hook:

export type Animation = Partial<Properties> & {
  options?: AnimationOptions;
};

type AnimDivOwnProps = {
  initial?: Animation;
  animate?: Animation;
  options?: AnimationOptions;
};

export type AnimDivProps = ComponentProps<"div"> & AnimDivOwnProps;
Enter fullscreen mode Exit fullscreen mode

In the useAnimNode hook, we synchronize props with the AnimNode instance. Since layout animations start in afterUpdate, we run this logic between beforeUpdate and afterUpdate using layout effects. This is fine as long as it's inexpensive and doesn’t modify any React state.

function useAnimNode({ initial, animate, options }: UseAnimNodeParams) {
  const animNodeRef = useRef<AnimNode>(new AnimNode());
  const animNode = animNodeRef.current;

  animNode.beforeUpdate();

  useLayoutEffect(() => {
    animNode.mount(initial);
    return () => {
      animNode.unmount();
    };
  }, []);

  useLayoutEffect(() => {
    animNode.setDefaultOptions(options ?? {});
  }, [options]);

  useLayoutEffect(() => {
    animNode.animateTo(animate ?? {}, animate?.options);
  }, [animate]);

  useLayoutEffect(() => {
    animNode.afterUpdate();
  }, []);

  return {
    animNode,
    domRef: (el: HTMLDivElement) => {
      animNode.setDomElement(el);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

We are modifying the existing mount function to accept an initial values parameter and we are adding the setDefaultOptions and animateTo functions. Next, we’ll implement these in AnimNode.

Public vs Internal types

In AnimNode, we could reuse the Animation type we defined earlier. However, it's better to use a separate type that fits our animation approach (Element.animate).

Keep in mind that we could use a different solution (or switch to it in the future), like manual property animations, where x and y can be separate. But with our current method, these two animate a single property: translate.

Users don’t need to know about these internal details, but for us, it’s easier to convert everything to a format that uses translate instead of x and y separately.

const kAnimationProperies = ["translate", "opacity"] as const;
type AnimationProperty = (typeof kAnimationProperies )[number];

type AnimationTarget = {
  [p in AnimationProperty]?: string;
} & {
  options?: AnimationOptions;
};
Enter fullscreen mode Exit fullscreen mode

We can use a single array with the supported style properties and derive the other types from it. This way, we have one source of truth for the properties we support.

How to start animations

Just like layout changes don't trigger animations immediately, animateTo won't either. We will store the previous animation target, just like we did with the layout information. New animation requests will be registered, and changes will be handled in the afterUpdate method.

export class AnimNode {
  ...
+  private animationTarget: AnimationTarget = {};

-  private prevLayout?: LayoutProperties;
+  private prev: {
+    layout?: LayoutProperties;
+    target: AnimationTarget;
+  } = { target: {} };

  afterUpdate() {
    if (!this.domElement || !this.isMounted) {
      return;
    }

+    if (this.prev.target !== this.animationTarget) {
+      this.handleTargetChange();
+    }

    const layout = calcLayoutProperties(this.domElement);
    if (this.prev.layout && hasLayoutChanged(this.prev.layout, layout)) {
      this.handleLayoutChange(this.prev.layout, layout);
    }
  }
  ...
}

Enter fullscreen mode Exit fullscreen mode

To figure out how we want to animate to a new target, we need to consider the most complex scenario: receiving a new target while a previous animation is still active.

We can't use the composite: "add" trick we used for layout animations because these values aren't additive. For example, if an opacity animation goes from 0 to 0.5 and is interrupted by another one going to 0.7, we can't just add the values together. Doing so could exceed the target value, even going over 1, which doesn't make sense. So, we need to use "replace" mode instead.

The problem with "replace" mode is that it overrides the previous animation's values. So, we need a way to start the new animation exactly from where the previous one left off and decide what to do with the active animation.

Fortunately, we can use implicit from keyframes, which means we only provide the target value, and the browser will figure out the starting value for us. This way, we don’t need to cancel the active animation, as canceling would reset to its starting state.

For the fill option, we’ll use 'forwards' to keep the final values after the animation ends. This allows us to commit the styles and remove the animation when it's done without any glitches.

 private animateProperty(
    property: AnimationProperty,
    to: string,
    { duration, easing }: AnimationOptions = {},
  ) {
    if (!this.domElement) {
      return;
    }

    const animation = this.domElement.animate(
      { [property]: to },
      {
        duration:
          duration ??
          this.defaultAnimationOptions.duration ??
          kGlobalAnimationOptions.duration,
        easing:
          easing ??
          this.defaultAnimationOptions.easing ??
          kGlobalAnimationOptions.easing,
        composite: "replace",
        fill: "forwards",
      },
    );

    this.registerAnimation(animation);
  }
Enter fullscreen mode Exit fullscreen mode

The animateProperty method starts an animation for a single property. We could animate all properties at once, but handling them separately means that if only one property changes, we don’t have to restart the entire animation - just start a new one for that property.

In my opinion this is preferable because the easing function will "reset" (it's a new standalone animation after all), which will break the natural continuous flow of the animation. The difference is that with separate animations, only the affected properties will be impacted instead of all of them. But ultimately this is a product decision rather than a technical necessity.

  private handleTargetChange() {
    const target = this.animationTarget;
    const prevTarget = this.prev.target;

    kAnimationProperties.forEach((property) => {
      const targetValue = target[property];
      const prevTargetValue = prevTarget[property];

      if (targetValue !== undefined) {
        if (prevTargetValue === undefined || targetValue !== prevTargetValue) {
          this.animateProperty(property, targetValue, target.options);
        }
      } else {
        // skip
      }
    });
  }
Enter fullscreen mode Exit fullscreen mode

Enter animations

Now that we have a working property animation solution, we need to set initial values to enable enter animations. We have already decided that the mount method will handle this:

  mount(initialValues: Partial<Properties> = {}) {
    this.isMounted = true;

    if (!this.domElement) {
      throw new Error("AnimNode: mounting without a dom element");
    }

    const initialAnimationValues = toAnimationTarget(initialValues);
    kAnimationProperties.forEach((property) => {
      if (initialAnimationValues[property] !== undefined) {
        this.domElement!.style[property] = initialAnimationValues[property];
      }
    });

    this.prev.target = initialAnimationValues;
  }
Enter fullscreen mode Exit fullscreen mode

Now that all the building blocks are in place, we can finally use them in our List component. You can try experimenting with the initial and animate props - using x values, so items slide in from the left for example.

List with enter animations items fading in and sliding in from the left


The last step is to support exit animations, which will be covered in Part 3.

You can view the full code or check the diff from Part 1.

Playwright CLI Flags Tutorial

5 Playwright CLI Flags That Will Transform Your Testing Workflow

  • --last-failed: Zero in on just the tests that failed in your previous run
  • --only-changed: Test only the spec files you've modified in git
  • --repeat-each: Run tests multiple times to catch flaky behavior before it reaches production
  • --forbid-only: Prevent accidental test.only commits from breaking your CI pipeline
  • --ui --headed --workers 1: Debug visually with browser windows and sequential test execution

Learn how these powerful command-line options can save you time, strengthen your test suite, and streamline your Playwright testing experience. Practical examples included!

Watch Video 📹️

Top comments (0)