Creating a Smooth Transitioning Dialog Component in React (Part 2/4)
Anthony Tambrin
Posted on July 15, 2024
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:
Fluidity: These properties allow the dialog to adapt dynamically to the content size, creating a smooth and natural animation.
Control: They offer precise control over the dimensions during transitions, ensuring the dialog does not overflow its container.
Consistency: When dealing with fluid content that may vary in size,
max-width
andmax-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
andmax-height
.Width and Height: Directly animating
width
andheight
can be less performant due to layout recalculations that the browser must perform, which can lead to jankier animations compared to usingmax-width
andmax-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
`;
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')};
`;
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>
// src/components/FluidDialog/DialogBody.js
<DialogContainer animate>
<DialogFooterContent>{children}</DialogFooterContent>
</DialogContainer>
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 comparesisExpanded
withisAnimatedExpanded
, 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
andmax-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
andmax-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);
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 });
}
};
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 forisAnimatedExpanded
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]);
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 unsettingmax-width
andmax-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]);
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]);
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
-
Fluid Transitions: The use of
max-width
andmax-height
ensures the dialog grows and shrinks smoothly, offering a natural and visually appealing experience. - Dynamic Adaptability: By dynamically calculating dimensions, the dialog adjusts to various content sizes, making it highly adaptable and versatile for different use cases.
- Simplicity: The implementation is straightforward, leveraging CSS transitions and React hooks, making it easy to understand, implement, and maintain.
-
Accurate Measurements: Using
getBoundingClientRect()
provides precise dimension calculations, crucial for accurate and seamless animations.
Cons
-
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
andmax-height
: Chosen for their fluidity, control, and consistency. - DialogAnimation Component: Manages the transition logic and dynamically adjusts dimensions.
-
State Management: Utilises
useState
anduseEffect
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.
Posted on July 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.