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>
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;
};
-
Properties
includes the animation properties we support. For this tutorial these are limited tox
,y
, andopacity
. -
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;
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);
},
};
}
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;
};
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);
}
}
...
}
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);
}
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 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;
}
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.
The last step is to support exit animations, which will be covered in Part 3.
Top comments (0)