DEV Community

Anthony Tambrin
Anthony Tambrin

Posted on

Creating a Smooth Transitioning Dialog Component in React (Part 3/4)

Part 3: Improving Animation Reliability

In Part 2, I enhanced our dialog component by adding smooth animations for minimise and expand actions using max-width and max-height. This approach ensured the dialog dynamically adapted to its content, providing fluid and natural transitions. However, one key limitation was the assumption that minimised dimensions are zero, which caused the transition to not scale down smoothly and look less natural.

Also in Part 2, I added DialogAnimation inside DialogContainer to animate DialogBody and DialogFooter individually. For Part 3, I've unified the animation to affect both components at a higher level, inside the Dialog component. This change simplifies the structure and eliminates the need for animate props in DialogBody and DialogFooter in preparation to the improvement that I was planning to make from the last approach.

Here are the changes I've made:

// src/components/FluidDialog/Dialog.js
// The children are now wrapped within DialogAnimation
<DialogComponent
  role="dialog"
  aria-labelledby={`${dialogId}_label`}
  aria-describedby={`${dialogId}_desc`}
  ref={rootRef}
  maxWidth={maxWidth}
>
  <DialogAnimation>{children}</DialogAnimation>
</DialogComponent>
Enter fullscreen mode Exit fullscreen mode
// src/components/FluidDialog/DialogContainer.js
// The DialogAnimation has been removed
export default function DialogContainer({ children }) {
  const { isExpanded } = useDialog();
  return <DialogContainerComponent isVisible={isExpanded}>{children}</DialogContainerComponent>;
}
Enter fullscreen mode Exit fullscreen mode

I'm eager to hear your thoughts on this approach and whether you find it an improvement. Your feedback will be invaluable in refining this component.

Improvement From Last Approach

In the last approach, the assumption that minimised dimensions are zero (max-width: 0, max-height: 0) caused the transition to not scale down smoothly. This is because the actual minimised dimensions are never zero, leading to the transition overcompensating and making the animation look less natural.

As an improvement, I'm going to calculate both expanded and minimised dimensions so that the DialogAnimation component can use those dimensions to reliably transition between the two states.

What Changes

  • Calculate Both Expanded and Minimised Dimensions: The DialogAnimation component will now calculate dimensions for both expanded and minimised states. This is crucial for ensuring the animation transitions smoothly between the correct dimensions.
  • Successive Render Cycles: To obtain accurate dimensions, the dialog needs to be expanded and minimised in successive render cycles. This allows the DOM element dimensions to be calculated using getBoundingClientRect.

What Remains

  • Fluid Height: The dialog still needs to hug the content correctly, so max-width and max-height need to be unset when the animation transition is not happening.

Step-by-Step Dimension Calculation

Here's the step-by-step process for calculating the dimensions (consider each numbered bullet represent the nth render cycle):

  1. Expand the Dialog: The dialog is expanded first to measure its full size.
  2. Calculate and Store Dimensions: The dimensions are calculated using getBoundingClientRect and stored as expandedDimensions.
  3. Minimise the Dialog: The dialog is then minimised to measure its compact size.
  4. Calculate and Store Dimensions: The dimensions are recalculated and stored as minimisedDimensions.
  5. Prepare for Transition: The dialog is set to the correct state (either expanded or minimised) with max-width and max-height set to the current state dimensions, and the transition property unset.
  6. Animate the Transition: Finally, max-width and max-height are set to the target state dimensions with the transition property enabled, initiating the animation.

Rewriting and Improving DialogAnimation

Now that I'm armed with that strategy, I'm going to rewrite (probably most of) the DialogAnimation.js code. I’ll explain it as I go, bear with me.

Imports and Constants

import { useState, useEffect, useRef, useTransition } from 'react';
import { styled } from 'styled-components';
import { useDialog } from './DialogContext';

const transitionSpeed = 0.3; // second
Enter fullscreen mode Exit fullscreen mode

Nothing much changed here except that I've set a constant for the transition speed.

Component and State Initialization

export function DialogAnimation({ children }) {
  const containerRef = useRef(null);
  const { isExpanded, setIsExpanded, rootRef } = useDialog();
  const [isAnimatedExpanded, setIsAnimatedExpanded] = useState(isExpanded);
  const [_, startTransition] = useTransition();
  const [isAnimating, setIsAnimating] = useState(false);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  const [minimisedDimensions, setMinimisedDimensions] = useState({
    width: 0,
    height: 0,
  });
  const [expandedDimensions, setExpandedDimensions] = useState({
    width: 0,
    height: 0,
  });
  const [dimensionCheckState, setDimensionCheckState] = useState(0);
Enter fullscreen mode Exit fullscreen mode

I set up my state variables and refs. containerRef points to the dialog container. isExpanded and rootRef come from useDialog. I use state variables to track animation states and dimensions.

Here's a detailed breakdown of the variables and what they are used for:

  • containerRef: A reference to the dialog container DOM element. Used to access and manipulate the DOM directly for dimension calculations.
  • isExpanded: Tracks if the dialog is expanded.
  • rootRef: A reference to the root element of the dialog.
  • isAnimatedExpanded: Tracks the animated state of the dialog separately from isExpanded. This helps manage the animation timing and ensures smooth transitions.
  • isAnimating: A boolean state to indicate if an animation is in progress. This is used to conditionally apply transition properties.
  • dimensions: Stores the current dimensions (width and height) of the dialog. This is dynamically updated during the animation process.
  • minimisedDimensions: Stores the dimensions when the dialog is minimised. Calculated and stored to ensure smooth transitions to this state.
  • expandedDimensions: Stores the dimensions when the dialog is expanded. Calculated and stored to ensure smooth transitions to this state.
  • dimensionCheckState: Manages the state of the dimension calculation process. This state machine controls the sequence of expanding, measuring, minimising, and animating the dialog.
A Quick Note on useTransition

The useTransition hook in React is used to manage state transitions smoothly without blocking the UI. In DialogAnimation, startTransition is employed to handle state updates, supposedly ensuring that the dialog remains responsive during dimension calculations and animations. By marking updates as transitions, React prioritizes keeping the UI fluid and preventing rendering delays. For more details, check out the official useTransition documentation. (A colleague suggested this approach but I have yet benchmarked the performance. What do you think? Would it help in this case?)

First Effect Hook: Handling Expansion State Changes

  useEffect(() => {
    if (dimensionCheckState === 0 && isExpanded != isAnimatedExpanded) {
      const container = rootRef?.current;
      container.style.opacity = 0; // Make transparent to avoid flicker
      setIsAnimating(false);
      setIsAnimatedExpanded(isExpanded);
      setDimensionCheckState(1);
    }
  }, [isExpanded, isAnimatedExpanded]);
Enter fullscreen mode Exit fullscreen mode

When the expansion state changes, I make the dialog transparent to avoid flickering, then update my state to start calculating dimensions.

Main Effect Hook: Managing Dimension Calculation and Animation

  useEffect(() => {
    const container = rootRef?.current;

    switch (dimensionCheckState) {
      // Expand
      case 1:
        startTransition(() => {
          setIsExpanded(true);
          setDimensionCheckState(2);
        });
        break;

      // Set expanded dimensions
      case 2:
        {
          const { width, height } = container.getBoundingClientRect();
          startTransition(() => {
            setExpandedDimensions({ width, height });
            setDimensionCheckState(3);
          });
        }
        break;

      // Minimise
      case 3:
        startTransition(() => {
          setIsExpanded(false);
          setDimensionCheckState(4);
        });
        break;

      // Set minimised dimensions
      case 4:
        {
          const { width, height } = container.getBoundingClientRect();
          startTransition(() => {
            setMinimisedDimensions({ width, height });
            setIsExpanded(true);
            setDimensionCheckState(5);
          });
        }
        break;

      // Prepare animation
      case 5:
        setIsAnimating(true);
        setDimensions(
          isAnimatedExpanded ? minimisedDimensions : expandedDimensions
        );
        setTimeout(() => {
          startTransition(() => setDimensionCheckState(6));
        });
        break;

      // Animate
      case 6:
        startTransition(() => {
          setDimensions(
            isAnimatedExpanded ? expandedDimensions : minimisedDimensions
          );
          setDimensionCheckState(0);
        });
        container.style.opacity = 1;

        // Finalize animation state after transition
        setTimeout(() => {
          startTransition(() => {
            setIsExpanded(isAnimatedExpanded);
            setIsAnimating(false);
          });
        }, transitionSpeed * 1000);
        break;

      // Idle
      default:
        break;
    }
  }, [dimensionCheckState, startTransition]);
Enter fullscreen mode Exit fullscreen mode

This is where the magic happens. The effect hook manages the entire animation lifecycle through a switch case:

  1. Expand the dialog.
  2. Measure and store expanded dimensions.
  3. Minimise the dialog.
  4. Measure and store minimised dimensions.
  5. Prepare for animation by setting current dimensions.
  6. Perform the animation and reset states.

Rendering the Animated Container

  return (
    <AnimatedDialogContainer
      ref={containerRef}
      dimensions={dimensions}
      isAnimating={isAnimating}
    >
      <FixedContainer dimensions={expandedDimensions} isAnimating={isAnimating}>
        {children}
      </FixedContainer>
    </AnimatedDialogContainer>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here, I render the AnimatedDialogContainer and FixedContainer, passing the necessary props to manage dimensions and animation states.

Styled Components

const AnimatedDialogContainer = styled.div`
  overflow: hidden;
  transition: ${({ isAnimating }) =>
    isAnimating
      ? `max-width ${transitionSpeed}s, max-height ${transitionSpeed}s`
      : undefined};
  max-width: ${({ dimensions, isAnimating }) =>
    isAnimating ? `${dimensions.width}px` : undefined};
  max-height: ${({ dimensions, isAnimating }) =>
    isAnimating ? `${dimensions.height}px` : undefined};
`;

const FixedContainer = styled.div`
  width: ${({ dimensions, isAnimating }) =>
    isAnimating ? `${dimensions.width}px` : '100%'};
  height: ${({ dimensions, isAnimating }) =>
    isAnimating ? `${dimensions.height}px` : '100%'};
`;
Enter fullscreen mode Exit fullscreen mode
  • AnimatedDialogContainer: Manages the transition properties based on whether an animation is happening. The max-width and max-height are unset when it's not animating so the dialog can hug thecontent correctly.
  • FixedContainer: Ensures the minimised content maintains its dimensions during the animation to avoid appearing squashed.

Try the Demo!

You can access the whole source code for this approach on CodeSandbox.

You can also see a live preview of the implementation below. Play around with the dynamic adaptability of the dialog and also pay close attention to the occasional (or frequent?) flickering jank when you minimise and expand the dialog.

Pros and Cons of This Approach

Before we wrap up, let's dive into the pros and cons of this approach compared to the one in Part 2.

Pros

  1. Accurate Transitions: By calculating both expanded and minimised dimensions, this approach ensures the dialog transitions to the exact right size, making the animation smooth and visually appealing.
  2. Clean Structure: Wrapping the DialogAnimation around the children of the Dialog component simplifies the code structure, eliminating the need for individual animate props in DialogBody and DialogFooter.
  3. Dynamic Adaptability: The dialog adapts to content changes more reliably, as dimensions are recalculated during specific render cycles.

Cons

  1. Increased Complexity: Managing state transitions and dimension calculations in multiple steps adds complexity to the codebase.
  2. Performance Overhead: Expanding and minimising the dialog in successive render cycles could introduce performance overhead, particularly with frequent (complex) content changes.
  3. Jank/Flicker During Calculation: The biggest drawback is the introduction of jank or flicker when calculating dimensions. The dialog needs to be expanded and minimised to measure with getBoundingClientRect, causing visible jumps in the UI.
  4. Initial Calculation Delay: The initial dimension calculation process involves multiple steps, which may introduce a slight delay before the animation starts.

Conclusion and Next Steps

In Part 3, I improved the DialogAnimation component to calculate both expanded and minimised dimensions for more accurate and visually appealing transitions. This approach involved using successive render cycles to expand and minimise the dialog, allowing for precise dimension calculations. However, it also introduced some complexity and potential performance issues, particularly the jank or flicker during dimension calculations.

Key Takeaways:

  • Accurate Transitions: Calculating both expanded and minimised dimensions ensures smooth animations.
  • Jank/Flicker Issue: Expanding and minimising the dialog for dimension calculations can cause visible UI jumps.

Next, in Part 4, I'll tackle the flickering issue by introducing a secondary, invisible container exclusively for dimension calculations. This approach aims to eliminate the jank while maintaining smooth and reliable transitions. Stay tuned as we continue to refine and perfect the dialog component!

I invite feedback and comments from fellow developers to help refine and improve this approach. Your insights are invaluable in making this proof of concept more robust and effective.

Top comments (0)