Animating Height with MutationObserver in React

Anton Krylov
AvitoTech
Published in
3 min readOct 19, 2022

If you’ve already tried animating height in a browser, you know how much of a headache it is. If you haven’t faced this issue yet, well, lucky you. Generations of frontend developers had to suffer and reinvent the wheel to solve that task. An example of such reinvention is animating with max-height in pixels (containers bigger than content inside them).

Big shoutout to Boris (he was the one to come up with the fix, I just rewrote it a bit and tried to have a closer look on the problem). So, if you want the solution and no popcorn, just scroll down :)

Firstly, I tried to check out for pure CSS solutions, but this way is tricky and not suitable for animating each change of a layout. I don’t want to tell about old CSS solutions, but if you want to check them out, you can read Andy’s article.

Also, there are some pure JS solutions, like this one.

  • elBlock: HTMLDivElement - block for hiding/opening
  • elToggle: HTMLButtonElement - toggle for hide/open elBlock
elToggle.addEventListener("click", () => {elBlock.addEventListener("transitionend", () => {
if (elBlock.style.height === "0px") {
if (elBlock.style.height !== "0px") {
elBlock.style.height = "auto"
}
});
elBlock.style.height = `${ elBlock.scrollHeight }px`
} else {
elBlock.style.height = `${ elBlock.scrollHeight }px`;
elBLock.clientHeight // use it for forced layout recalculate
elBlock.style.height = "0";
}
});

But let’s talk about the React way of solving that issue. Here’s a brief of the solution:

  • Change the overflow to hidden during the animation phase, because otherwise layers are going to overlap.
  • Determine when the height of wrapped components should change (e.g. any state/props change).
  • With every change of the height, the styles of the wrapper should change to start the animation phase.

First, let’s understand what we’ll use for our purposes:

type Params = {
duration?: number;
normalOverflow?: CSSProperties['overflow']
animationFunction?: CSSProperties['animationTimingFunction'];
}

Next, let’s define the needed state:

const [animationState, setAnimationState] = useState({ height: 0, isAnimated: true }) const ref = useRef<HTMLDivElement>(null)

Then, the logic:

useEffect(() => {setAnimationState((state) => state.height !== newHeight ? { isAnimated: true, height: newHeight }: state)
if (ref.current) {
}
})
const newHeight = ref.current.scrollHeight

Then goes the logic for clear animation:

useEffect(() => {return () => clearTimeout(timeoutId);
const timeoutId = setTimeout(
}, [animationState.inAnimation, duration]);
() => setAnimationState((prev) => ({ ...prev, inAnimation: false })),
duration
);

So, we end up with:

But this one works only if we have parameters in place, and it also has the stupid clear function (which can lead to bugs, if the duration is long or your users are super-fast). The better way is to use onTransitionEnd.

Now, let’s dive into the MutationObserver example. We need to change the logic to this one:

useEffect(() => {observer.current?.observe(ref.current, {
if (ref.current) {
childList: true,
subtree: true
})
}
return () => observer.current?.disconnect()
})
observer.current = new MutationObserver(() => {
if (ref.current) {
const newHeight = ref.current.scrollHeight

setAnimationState((state) => state.height !== newHeight ? { isAnimated: true, height: newHeight }: state)
}
})

Now, it will observe every change, but it still doesn’t work as we want it to, because the first render is not observed as a change :)

And we can use it only on a mount, because it observes independently from our React state. So, let’s keep on rewriting.

Firstly, take out the update function separately. Also, update end animation to a separate function. When we’re done separating the repeated code, we’ll get the final solution.

And now we can write our container…

… and use it like this:

Final notes

After all, MutationObserver can be used in many other ways. Instead of it, you can also use ResizeObserver (but it doesn’t support as many browsers, e.g. IE11).

Also, you can calculate cumulative child height instead of ref passing, but this way of solving issues is not 100% React :D

Links

· https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver

· https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver

· https://andybeckmann.com/blog/animating-height-with-javascript

· https://habr.com/ru/post/475520/

Originally published at https://medium.com on October 19, 2022.

--

--