Expandable tags with Framer Motion and React

12 min read

Framer Motion is arguably one of the most powerful libraries for UI animation with React. I recently got a chance to play around with this library in-depth for some of the stuff I’m working on at Razorpay. In this video blog post, I plan to share some of my learnings by building an expandable tag component whose content changes when the user hovers over them. I’ll be discussing various edge cases in a component like this and also how Framer Motion makes complex layout animations a breeze!

Changing the content of an element when user hovers over them

Hover-related styling is very straightforward with CSS because of the :hover pseudo-selector, but it’s not very clear how the content of an element can be changed on hover. Thankfully, JavaScript exposes two events - onMouseEnter and onMouseLeave which get fired when the mouse enters(hovers) an element or leaves(unhovers) the element respectively. These events can be used to update a state variable in React which is then used to conditionally render the content of the element.

jsx
1
function ExpandableTag({ unhoveredText, hoveredText }) {
2
const [isHovering, setIsHovering] = useState(false);
3
return (
4
<div
5
className="inline-block rounded-lg relative text-gray-300 bg-gray-900 ring-1 ring-gray-800 px-4 py-1.5 tracking-wider text-sm font-medium whitespace-nowrap"
6
onMouseEnter={() => setIsHovering(true)}
7
onMouseLeave={() => setIsHovering(false)}
8
>
9
{isHovering ? hoveredText : unhoveredText}
10
</div>
11
);
12
}

We have a basic implementation of the component ready, but there are two problems:

  1. There is no animation. The content changes instantly which might not be a good UX in this scenario and some subtle animation can polish this component even more.
  2. The dreaded flicker of doom. This is a hard one to catch but in a case where your hovered text is lesser than the unhovered text, the user can cause the component to enter into a flickering state where the content of the component changes continuously.

Adding fade-in / out animation to the content

To solve the first problem, let’s start by adding simple fade-in and fade-out on the changing content within the tag. When the user hovers over the tag, the unhovered text fades out and hovered text fades in. This plays in reverse when the user hovers out. This is very easy to do in Framer Motion with the motion component and the AnimatePresence component. The AnimatePresence component should be used whenever there is an exit animation defined for the motion component. In our case, this would be the fade-out animation when the content is exiting the UI.

jsx
1
function ExpandableTag({ unhoveredText, hoveredText }) {
2
const [isHovering, setIsHovering] = useState(false);
3
return (
4
<div
5
className="inline-block rounded-lg relative text-gray-300 bg-gray-900 ring-1 ring-gray-800 px-4 py-1.5 tracking-wider text-sm font-medium whitespace-nowrap"
6
onMouseEnter={() => setIsHovering(true)}
7
onMouseLeave={() => setIsHovering(false)}
8
>
9
<AnimatePresence>
10
<motion.span
11
initial={{ opacity: 0 }}
12
animate={{ opacity: 1 }}
13
exit={{ opacity: 0 }}
14
>
15
{isHovering ? hoveredText : unhoveredText}
16
</motion.span>
17
</AnimatePresence>
18
</div>
19
);
20
}

This alone does not solve the problem, Framer Motion does not know when your animations should run, so by default, it only runs when the component is first mounted. We want the animation to run when the user hovers and unhovers the element(as that is when the contents change), and this is already tracked by the isHovering state variable. To tell Framer Motion to run the animations, we can simply set the key prop such that it changes when we want the animations to run. In our case, that would be setting the key to something based on isHovering state variable.

Also, since we don’t want the entry animations to run when the component is first mounted, we can opt out of this behavior by setting the initial prop in the AnimatePresence component to false

jsx
1
function ExpandableTag({ unhoveredText, hoveredText }) {
2
const [isHovering, setIsHovering] = useState(false);
3
return (
4
<div
5
className="inline-block rounded-lg relative text-gray-300 bg-gray-900 ring-1 ring-gray-800 px-4 py-1.5 tracking-wider text-sm font-medium whitespace-nowrap"
6
onMouseEnter={() => setIsHovering(true)}
7
onMouseLeave={() => setIsHovering(false)}
8
>
9
<AnimatePresence initial={false}>
10
<motion.span
11
initial={{ opacity: 0 }}
12
animate={{ opacity: 1 }}
13
exit={{ opacity: 0 }}
14
key={isHovering ? 'hovering' : 'unhovering'}
15
>
16
{isHovering ? hoveredText : unhoveredText}
17
</motion.span>
18
</AnimatePresence>
19
</div>
20
);
21
}

But wait, the contents fade in and out simultaneously

The contents of the tag fade in and out simultaneously in the above code because, by default, Framer Motion synchronizes the entry and exit animations on motion components. This is useful in various scenarios, but it does not look good for this use case. We can change this behavior by changing the mode prop of the AnimatePresence component from "sync" (default value) to "wait", which runs the entry animations only after the exit animations have completed.

jsx
1
// rest...
2
<AnimatePresence mode="wait" initial={false}>
3
<motion.span
4
initial={{ opacity: 0 }}
5
animate={{ opacity: 1 }}
6
exit={{ opacity: 0 }}
7
key={isHovering ? 'hovering' : 'unhovering'}
8
>
9
{isHovering ? hoveredText : unhoveredText}
10
</motion.span>
11
</AnimatePresence>
12
// rest...

We now have our contents fading in and out when the user hovers and unhovers the element. This is already way better than what we started from!

Smooth resizing of the tag container element

While the fade-in and fade-out are good additions, the container element of the tag still resizes instantly when the contents change. This is a jarring experience for the user as too many unsynchronized changes are happening in the UI at once. To make this better, we must smoothly resize the container and synchronize it with the content fade in/out.

This might seem hard as animating the width of an element is not a cheap operation as it runs on the CPU and causes expensive layout calculations for every frame of the animation. But hey, since we are using Framer Motion, we don’t have to worry about it! Framer Motion makes these complex layout animations very easy by making use of CSS transforms to animate the size and positioning of elements that run on the GPU, thus making the animations buttery smoooooth!

Opting into layout animations is simple, we have to change the container element to motion.div component (instead of HTML div) and set the layout prop. The layout prop takes "position" and "size" if we are interested in only animating the position or size of the element respectively. If we want to animate both position and size, then the layout can be set to true. Another change we have to do is to wrap the component with LayoutGroup component from Framer Motion so that Framer Motion can detect content changes and apply the layout animations correctly.

jsx
1
function ExpandableTag({ unhoveredText, hoveredText }) {
2
const [isHovering, setIsHovering] = useState(false);
3
return (
4
<LayoutGroup>
5
<motion.div
6
layout
7
className="inline-block rounded-lg relative text-gray-300 bg-gray-900 ring-1 ring-gray-800 px-4 py-1.5 tracking-wider text-sm font-medium whitespace-nowrap"
8
onMouseEnter={() => setIsHovering(true)}
9
onMouseLeave={() => setIsHovering(false)}
10
>
11
<AnimatePresence mode="wait" initial={false}>
12
<motion.span
13
initial={{ opacity: 0 }}
14
animate={{ opacity: 1 }}
15
exit={{ opacity: 0 }}
16
key={isHovering ? 'hovering' : 'unhovering'}
17
>
18
{isHovering ? hoveredText : unhoveredText}
19
</motion.span>
20
</AnimatePresence>
21
</motion.div>
22
</LayoutGroup>
23
);
24
}

A quirk of how layout animations are done in Framer Motion is that it may affect some of your other CSS properties – border-radius, box-shadow, etc. This is because the layout animations use CSS Transforms under the hood and they in turn cause some unintended side effects on how the styles of these properties get rendered. To fix this, we should pass border-radius as an inline style to a motion component using layout animations, instead of setting it in a CSS file somewhere else. This would allow Framer Motion to perform additional calculations that nullify the effect of CSS Transforms on these properties.

jsx
1
// rest...
2
<motion.div
3
layout
4
className="inline-block relative text-gray-300 bg-gray-900 ring-1 ring-gray-800 px-4 py-1.5 tracking-wider text-sm font-medium whitespace-nowrap"
5
style={{ borderRadius: 8 }}
6
onMouseEnter={() => setIsHovering(true)}
7
onMouseLeave={() => setIsHovering(false)}
8
>
9
{/* rest of the children */}
10
</motion.div>
11
// rest...

With this change, we now have a component that has smooth animations on all kinds of user interactions and improves the overall UX. This solves the first problem.

Solving the dreaded flicker of doom

The second problem is the flickering of the tag when the user hovers towards the edge of the component where the hovered text is lesser than the unhovered text. This flickering happens when the side-effect of one event triggers another event which resets the changes from the side-effect, but it also ends up triggering the event causing that side-effect. Essentially an indirect recursion but with JavaScript events causing annoying UI glitches.

With animations in place, the effects of these flickers are greatly reduced but it’s still an underlying problem that should be solved. The solution to this problem requires extracting out all the interaction events (onMouseEnter and onMouseLeave) to an element whose size does not change since changes in the size of the element with interaction events were causing the infinite recursion. And to prevent this new element from shrinking when the content shrinks, we can set the min-width of this element to be the width of the tag element when it is in the unhovered state. This will ensure that the element with interaction events never shrinks when the user has triggered the hover interaction but not the unhover interaction.

jsx
1
function ExpandableTag({ unhoveredText, hoveredText }) {
2
const width = useRef(0);
3
const ref = useRef();
4
const [isHovering, setIsHovering] = useState(false);
5
return (
6
<LayoutGroup>
7
<div
8
className="inline-block"
9
style={{ minWidth: width.current }}
10
onMouseEnter={() => {
11
width.current = ref.current.offsetWidth;
12
setIsHovering(true);
13
}}
14
onMouseLeave={() => setIsHovering(false)}
15
>
16
<motion.div
17
ref={ref}
18
layout
19
className="inline-block relative text-gray-300 bg-gray-900 ring-1 ring-gray-800 px-4 py-1.5 tracking-wider text-sm font-medium whitespace-nowrap"
20
style={{ borderRadius: 8 }}
21
>
22
{/* rest of the children */}
23
</motion.div>
24
</div>
25
</LayoutGroup>
26
);
27
}

That solves the flickering problem! We have come quite far from where we started and there are just a few minor issues to solve to make our component even better!

Fixing a few more minor issues

Fast hover and unhover cause inconsistent UI

Try hovering and then unhovering your tag component fast. The expandable tag may render the hovered state UI when you have unhovered the component. This UI inconsistency stems from a regression in recent versions of Framer Motion – GitHub issue.

If the above GitHub issue is closed with a bug fix when you are reading this or you are not experiencing this problem in newer versions of Framer Motion, feel free to skip this section of the post.

If you are facing this problem, then the only workaround right now is to downgrade to a version of Framer Motion where this bug wasn’t there 😕, and this happens to be version 9.0.2. Downgrading to this version solves the problem, without requiring any code change!

Using the component with the rest of the UI causes stray layout gaps

When the expandable tag component is used with the rest of the larger UI, and the user hovers over and unhovers the tag, there can be some instances where the component causes stray layout gaps in the UI. This is because the interaction element that we added to solve the flicker problem may capture the size of the tag in its hovered state when we are interested in the size of the tag in its unhovered state. So, we can add a guard to only capture the width when the isHovering state variable is false.

jsx
1
// rest...
2
<div
3
className="inline-block"
4
style={{ minWidth: width.current }}
5
onMouseEnter={() => {
6
if (!isHovering) {
7
width.current = ref.current.offsetWidth;
8
}
9
setIsHovering(true);
10
}}
11
onMouseLeave={() => setIsHovering(false)}
12
>
13
{/* rest of the children */}
14
</div>
15
// rest...

Changing the JS events to Framer Motion gesture callbacks

This is an optional change, since hover interactions are only defined on devices that have a pointer, it does not make sense to use them on touch devices like tablets and mobiles. To disable them from running on touch devices, we can remove the onMouseEnter and onMouseLeave events and instead use Framer Motion’s hover gesture callbacks - onHoverStart and onHoverEnd.

jsx
1
function ExpandableTag({ unhoveredText, hoveredText }) {
2
// rest...
3
return (
4
<LayoutGroup>
5
<motion.div
6
className="inline-block"
7
style={{ minWidth: width.current }}
8
onHoverStart={() => {
9
if (!isHovering) {
10
width.current = ref.current.offsetWidth;
11
}
12
setIsHovering(true);
13
}}
14
onHoverEnd={() => setIsHovering(false)}
15
>
16
{/* rest of the children */}
17
</motion.div>
18
</LayoutGroup>
19
);
20
}

With that last fix, we now have a powerful expandable tag component that has beautiful animations and can be used with the rest of the UI.

Magic of Framer Motion’s layout animations

The beauty of Framer Motion is how declarative it is for defining complex UI animations for React apps. Once you use the right primitives to build your components, synchronizing complex layout animations across them is as easy as wrapping your UI with just a LayoutGroup component. Framer Motion will figure out the triggers and effects of a layout animation on its own!

With the ExpandableTag component ready, let’s wrap a few instances of this component with a LayoutGroup and see it in action!

When a user hovers over a tag, the other tags also react with smooth animations to accommodate the changing size of the hovered tag. This would have been very hard to pull off with just CSS + JavaScript, but Framer Motion does all this while keeping our code simple and declarative. And this I feel is the true magic of Framer Motion! ✨

Final code

jsx
1
import { useRef, useState } from 'react';
2
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
3
4
function ExpandableTag({ unhoveredText, hoveredText }) {
5
const width = useRef(0);
6
const ref = useRef();
7
const [isHovering, setIsHovering] = useState(false);
8
return (
9
<LayoutGroup>
10
<motion.div
11
className="inline-block"
12
style={{ minWidth: width.current }}
13
onHoverStart={() => {
14
if (!isHovering) {
15
width.current = ref.current.offsetWidth;
16
}
17
setIsHovering(true);
18
}}
19
onHoverEnd={() => setIsHovering(false)}
20
>
21
<motion.div
22
ref={ref}
23
layout
24
className="inline-block relative text-gray-300 bg-gray-900 ring-1 ring-gray-800 px-4 py-1.5 tracking-wider text-sm font-medium whitespace-nowrap"
25
style={{ borderRadius: 8 }}
26
>
27
<AnimatePresence mode="wait" initial={false}>
28
<motion.span
29
initial={{ opacity: 0 }}
30
animate={{ opacity: 1 }}
31
exit={{ opacity: 0 }}
32
key={isHovering ? 'hovering' : 'unhovering'}
33
>
34
{isHovering ? hoveredText : unhoveredText}
35
</motion.span>
36
</AnimatePresence>
37
</motion.div>
38
</motion.div>
39
</LayoutGroup>
40
);
41
}
42
43
export default ExpandableTag;

If you found this article helpful, you will love these too.

Building highlighted input field in React

12 min read
Building highlighted input field in React
Building an expressive API for custom confirm dialogs in React
My look at SvelteJS and how you can start using it