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

copet80

Anthony Tambrin

Posted on July 15, 2024

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

Part 2: Introducing Animation for Minimise/Expand Transitions

In Part 1, I laid the foundation for our dialog component with basic minimise and expand functionality. Now, it's time to add smooth animations to these transitions. After brainstorming several CSS properties, including transform: scale(), zoom, and width and height, I decided to use max-width and max-height for the animation.

Here’s why:

Using max-width and max-height for animations has several advantages:

  1. Fluidity: These properties allow the dialog to adapt dynamically to the content size, creating a smooth and natural animation.

  2. Control: They offer precise control over the dimensions during transitions, ensuring the dialog does not overflow its container.

  3. Consistency: When dealing with fluid content that may vary in size, max-width and max-height ensure the dialog transitions remain consistent with the content's natural dimensions.

While transform and zoom are other potential options, they have some drawbacks in this context:

  • Scale Transform: Although performant, scaling affects both the dialog and its content, leading to possible distortion or undesirable scaling effects on inner elements.

  • Zoom: Similar to transform scale, zoom can lead to pixelation or blurriness, especially on non-vector elements, and doesn't provide as precise control over dimensions as max-width and max-height.

  • Width and Height: Directly animating width and height can be less performant due to layout recalculations that the browser must perform, which can lead to jankier animations compared to using max-width and max-height.

These benefits make max-width and max-height ideal choices for implementing fluid and efficient dialog transitions in React.

Introducing the DialogAnimation Component

My first thought was to provide the option to toggle animations allows for greater flexibility, accommodating different user preferences and performance considerations. Animations can be resource-intensive and may not be desirable in all contexts, such as on lower-powered devices or when a simpler user experience is preferred. By making animations optional, the component can be easily adapted to various use cases without sacrificing performance or usability.

So... to make the animation an optional feature (while resisting the temptation to implement prefers-reduced-motion media query), I introduced a DialogAnimation component.

Here’s a boilerplate of the DialogAnimation component:

// src/components/FluidDialog/DialogAnimation.js
import { useState, useEffect, useRef } from 'react';
import { styled } from 'styled-components';
import { useDialog } from './DialogContext';

export function DialogAnimation({ children }) {
  // Animation logic here
  return <AnimatedDialogContainer>{children}</AnimatedDialogContainer>;
}

const AnimatedDialogContainer = styled.div`
  // Styled component logic here
`;
Enter fullscreen mode Exit fullscreen mode

Updating the DialogContainer Component

To use the DialogAnimation component, I updated the DialogContainer component to include a boolean prop called animate. When animate is set to true, the DialogContainer will return the children wrapped in DialogAnimation, which supports animation transitions.

Here’s the updated code for DialogContainer:

// src/components/FluidDialog/DialogContainer.js
import { styled } from 'styled-components';
import { useDialog } from './DialogContext';
import { DialogAnimation } from './DialogAnimation';

export default function DialogContainer({ animate, children }) {
  const { isExpanded } = useDialog();

  if (animate) {
    return <DialogAnimation>{children}</DialogAnimation>;
  }

  return (
    <DialogContainerComponent isVisible={isExpanded}>
      {children}
    </DialogContainerComponent>
  );
}

const DialogContainerComponent = styled.div`
  display: ${({ isVisible }) => (isVisible ? undefined : 'none')};
`;
Enter fullscreen mode Exit fullscreen mode

To ensure this works, I set the animate prop to true for both DialogBody and DialogFooter since these are the components whose content I want to "hide" when minimised.

// src/components/FluidDialog/DialogBody.js
<DialogContainer animate>
  <DialogBodyContent id={`${dialogId}_desc`}>{children}</DialogBodyContent>
</DialogContainer>
Enter fullscreen mode Exit fullscreen mode
// src/components/FluidDialog/DialogBody.js
<DialogContainer animate>
  <DialogFooterContent>{children}</DialogFooterContent>
</DialogContainer>
Enter fullscreen mode Exit fullscreen mode

Building the DialogAnimation Component

Now, let's dive into building the DialogAnimation component. This component is essential for managing the smooth transitions between the expanded and minimised states of the dialog. It dynamically adjusts max-width and max-height based on the dialog's state and uses hooks to manage the animation lifecycle. This approach ensures fluid and smooth transitions, although it comes with its own set of pros and cons, which I'll discuss later.

Here's how this approach works:

  • Calculating Expanded Dimensions: The component calculates the dialog's expanded dimensions using the DOM's getBoundingClientRect() method, which provides the most accurate dimensions.
  • Timing the Calculation: The calculation occurs when the dialog is minimised. This ensures that when it expands, it does so to the correct size.
  • Detecting State Changes: The calculation is triggered by changes in the isExpanded state. To track these changes, the component compares isExpanded with isAnimatedExpanded, recalculating dimensions whenever there's a difference. This also allows the component to respond to any content changes that affect the dialog's height.
  • Assumptions for Minimised State: When the dialog is minimised, it assumes max-width and max-height are zero. This approach has some drawbacks, which I'll discuss later.
  • Applying CSS Transitions: Once the dimensions are calculated, the component sets max-width and max-height accordingly and relies on CSS to smoothly transition between these properties.

And here's how I built the DialogAnimation component step-by-step:

Setting Up the Basic Structure

First, I set up the basic structure of the component, importing necessary hooks and styled-components:

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

export function DialogAnimation({ children }) {
  const { isExpanded, rootRef } = useDialog();
  const containerRef = useRef(null);
  const [isExpandSettled, setIsExpandSettled] = useState(isExpanded);
  const [isAnimatedExpanded, setIsAnimatedExpanded] = useState(isExpanded);
  const [expandedDimensions, setExpandedDimensions] = useState(undefined);
Enter fullscreen mode Exit fullscreen mode

Calculating Dimensions

To handle the dimensions for the expanded state, I created a helper function. This function measures the container’s dimensions and updates the state accordingly.

  • updateExpandedDimensions: Measures the dimensions when the dialog is expanded and the updates the state if they haven't been set yet or if the current width and height differ from the stored values. This prevents unnecessary updates and re-renders, optimizing performance.
const updateExpandedDimensions = (
  containerRef,
  expandedDimensions,
  setExpandedDimensions
) => {
  const container = containerRef?.current;
  if (!container) return;

  const { width, height } = container.getBoundingClientRect();
  if (
    !expandedDimensions ||
    width !== expandedDimensions.width ||
    height !== expandedDimensions.height
  ) {
    setExpandedDimensions({ width, height });
  }
};
Enter fullscreen mode Exit fullscreen mode

Managing State and Effects

Next, I used several useEffect hooks to manage the state and animation lifecycle:

Step 1: Handle State Changes: This hook updates dimensions and state when isExpanded changes.

  • Why setTimeout?: The setTimeout ensures that the state update for isAnimatedExpanded occurs after the dimension calculation, allowing for a smooth transition.
  • isAnimatedExpanded: Tracks the animation state separately from the expansion state to manage the timing of transitions.
useEffect(() => {
  if (isExpanded !== isAnimatedExpanded) {
    if (!isExpanded) {
      updateExpandedDimensions(
        containerRef,
        expandedDimensions,
        setExpandedDimensions
      );
    }
    setTimeout(() => {
      setIsAnimatedExpanded(isExpanded);
    });
  }
}, [containerRef?.current, rootRef?.current, isExpanded, isAnimatedExpanded]);
Enter fullscreen mode Exit fullscreen mode

Step 2: Set Initial Dimensions: This hook sets the initial dimensions and handles transition end events.

  • Why handleTransitionEnd?: This function sets setIsExpandSettled to true after the transition completes. This is crucial for unsetting max-width and max-height, allowing the dialog to grow and shrink according to the content size.
  • setIsExpandSettled: Manages whether the expansion transition has completed, ensuring the dialog remains fluid post-transition.
useEffect(() => {
  const container = containerRef?.current;
  if (!container) return;

  if (!expandedDimensions) {
    updateExpandedDimensions(
      containerRef,
      expandedDimensions,
      setExpandedDimensions
    );
  }

  const handleTransitionEnd = () => {
    if (isExpanded) {
      setIsExpandSettled(true);
    }
  };

  container.addEventListener('transitionend', handleTransitionEnd);

  return () => {
    container.removeEventListener('transitionend', handleTransitionEnd);
  };
}, [containerRef?.current, rootRef?.current, expandedDimensions, isExpanded]);
Enter fullscreen mode Exit fullscreen mode

Step 3: Update Settled State: This hook resets the settled state when the dialog is minimised.

  • Resetting Settled State: Ensures the dialog is ready for the next transition by resetting the settled state when minimised.
useEffect(() => {
  if (!isExpanded) {
    setIsExpandSettled(false);
  }
}, [isExpanded]);
Enter fullscreen mode Exit fullscreen mode

Try the Demo!

You can access the whole source code 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 how the transition to minimised state looks a bit strange.

Pros and Cons of This Approach

Before wrapping up, let's dive into the pros and cons of this approach of calculating expanded dimensions to transition max-width and max-height for our dialog animation. Understanding these will help us refine and optimise the approach in future steps.

Pros

  1. Fluid Transitions: The use of max-width and max-height ensures the dialog grows and shrinks smoothly, offering a natural and visually appealing experience.
  2. Dynamic Adaptability: By dynamically calculating dimensions, the dialog adjusts to various content sizes, making it highly adaptable and versatile for different use cases.
  3. Simplicity: The implementation is straightforward, leveraging CSS transitions and React hooks, making it easy to understand, implement, and maintain.
  4. Accurate Measurements: Using getBoundingClientRect() provides precise dimension calculations, crucial for accurate and seamless animations.

Cons

  1. Assumption of Minimised Dimensions: The assumption that minimised dimensions are zero (max-width: 0, max-height: 0) can cause the transition to not scale down smoothly. The actual minimised dimensions are not zero, leading to the transition overcompensating and making the animation look less natural.

Conclusion and Next Steps

In Part 2, I enhanced our dialog component by adding smooth animations for minimise and expand actions. Using max-width and max-height, I ensured the dialog dynamically adapts to its content, providing fluid and natural animations.

Key Takeaways:

  • max-width and max-height: Chosen for their fluidity, control, and consistency.
  • DialogAnimation Component: Manages the transition logic and dynamically adjusts dimensions.
  • State Management: Utilises useState and useEffect hooks to handle state changes and lifecycle events.

Next, in Part 3, I’ll tackle the transition overcompensation issue with the current approach and improve animation reliability. Stay tuned as we continue to refine and optimise 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.

💖 💪 🙅 🚩
copet80
Anthony Tambrin

Posted on July 15, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related