Making animated tooltip with React and Framer Motion

Making animated tooltip with React and Framer Motion

Published at 24 April 2023
Last updated at 18 March 2024

Update. It seem, in version 10.12.8 of Framer Motion implementation of layout animations was changed. Unfortunately, this breaks main feature of the tooltip from this tutorial: sliding from one element to another. But you can still see original implementation by following CodeSandbox link in the end of this tutorial.

Table of contents

Intro

Tooltip seems like a very simple component. I mean, you just show a cute floating element with some tips or a help message. No complex styling or user interactions, right? Well, partially. You need to think about quite a few edge cases. How do you position the tooltip in the center (or align it by the edge of the reference element)? What if there's not enough space? Reference element changed size or position? How do you animate it? How do you animate at unmount?

And those are just the basic requirements, without any fancy interactions. Luckily, we already have libraries that provide us with a <Tooltip> component, you don't need to make it from scratch. Just pick your favorite, and you've avoided at least a few hours of struggle. And that's perfectly fine — you don't need to reinvent the wheel.

However, those libraries often times come with their distinct style and animations. Usually that's fine, but if you want to make your component unique or at least tweak it a little, continue reading. Today I'll show you how to make a tooltip without tears. It will handle all the edge cases I mentioned above, will be accessible, animated and have our own little twist: it will transition from one element to another, instead of hiding and reappearing on a new element. See for yourself:

Be not afraid, we won't have to do all this manually. Lucky for us, there are already two wonderful libraries which will do all the heavy lifting and do it better than the majority of us could do ourselves. We'll use Floating UI to build our <Tooltip> and Framer Motion to animate it and do this transition trickery. To follow this tutorial, you need to be comfortable with TypeScript and React (this tutorial is for React v18+).

Layout animation doesn't seem to work correctly with <React.StrictMode> because of double renders. I did not figure why this happens and noticed it only after I finished this article. Make sure to disable strict mode if you're following the tutorial along.

To start, install required libraries.

npm install framer-motion@10.6.0 @floating-ui/react@0.23.1

Both libraries are actively developed, so it's not guaranteed everything will work with the latest version when you read this. To avoid issues, we'll use particular versions of both libs. And while Framer Motion has breaking updates less often, Floating UI is still in active development and public API will probably change.

Bare-bones tooltip

To begin, let's create the functional part of the component, and then we will animate it. Luckily, Floating UI has an excellent tooltip tutorial, which we'll take as a base for our tooltip. Let's see the code:

import {
    FloatingPortal, Placement, autoUpdate, flip, offset, shift, useDismiss,
    useFloating, useFocus, useHover, useInteractions, useRole
} from '@floating-ui/react';
import { ReactNode, cloneElement, useState } from 'react';
import './Tooltip.css';
 
export type TooltipProps = {
    content: ReactNode;
    showDelay?: number;
    hideDelay?: number;
    placement?: Placement;
    children: JSX.Element;
}
 
export const Tooltip = ({ content, showDelay = 500, hideDelay = 100, placement = 'top', children }: TooltipProps) => {
    const [open, setOpen] = useState(false);
 
    const { x, y, reference, floating, strategy, context, placement: computedPlacement } = useFloating({
        placement,
        open,
        onOpenChange(open) {
            setOpen(open);
        },
        middleware: [offset(5), flip(), shift({ padding: 8 })],
        whileElementsMounted: autoUpdate
    });
 
    const { getReferenceProps, getFloatingProps } = useInteractions([
        useHover(context, { delay: showDelay, restMs: hideDelay }),
        useFocus(context),
        useRole(context, { role: 'tooltip' }),
        useDismiss(context),
    ]);
 
    return (<>
        {cloneElement(
            children,
            getReferenceProps({ ref: reference, ...children.props })
        )}
        <FloatingPortal>
            {open && <div
                {...getFloatingProps({
                    ref: floating,
                    className: 'Tooltip',
                    style: {
                        position: strategy,
                        top: y ?? 0,
                        left: x ?? 0,
                    }
                })}
            >
                {content}
            </div>}
        </FloatingPortal>
    </>);
};

And CSS for our tooltip:

.Tooltip {
    background: rgba(0, 0, 0, 0.85);
    color: white;
    pointer-events: none;
    border-radius: 6px;
    padding: 8px 18px;
    font-size: 0.85rem;
    max-width: 400px;
    z-index: 9999999999;
}

Surprisingly small amount of code, isn't it? Here is how it looks like:

<Tooltip content="Hola!">
    <button>Single tooltip</button>
</Tooltip>

Let's break this down bit by bit.

First, we have a TooltipProps type which describes the props our component will accept. It's pretty straightforward, except for the last field — children. In React, if your component is a container — that is, if it renders content passed to it as a prop (usually it's children) — you should use the ReactNode type for that prop. It accepts everything React can render: strings, arrays, null, elements, etc. However, for Floating UI to work properly, we need to pass a ref to a reference element (to get its size and coordinates for the tooltip), and we can't really pass a reference to string or null. Thus, we need to resort to JSX.Element type.

Next we have our component and useState hook to control it's open/closed state.

And after that, floating magic starts to happen. We have two Floating UI hooks. useFloating accepts the floating element's desired placement, an open state and callback to flip it, middlewares and functions to call while the elements are mounted. Middlewares are utils that help us handle all of the previously mentioned edge cases, such as not having enough space for the floating element — flip and shift handle that. whileElementsMounted will be called while elements are mounted and is generally used to reposition the floating element if size or position of the reference element changes. And what we get from useFloating is context (which you don't need to use directly, but it's required for next hook), refs for reference, floating elements (reference and floating), data about positioning your floating element: x, y and strategy (which is just another name for CSS's position).

Following that, we have the useInteractions hook. While useFloating takes care of positioning the float element, useInteractions is more high-level and handles interactions with our reference and floating elements. For example, useHover allows us to open tooltip when the user hovers over a reference element, useFocus does the same for focus, useRole attaches correct accessibility attributes to both elements and useDismiss allows the user to dismiss tooltip by pressing Esc. Finally, useInteractions just puts all those interactions together into two functions, which will return props for our reference and floating elements.

Finally, there's JSX. We don't render children directly at the start; instead, we utilize React's cloneElement function to render the cloned element. This function allows us to pass additional props to the element, and we do this with the help of getReferenceProps. This function (along with getFloatingProps) receives an object containing the props you want to send to the element and merges it with the props that Floating UI needs to pass to element. Thus, Floating UI needs to listen to the focus event on the reference element, and you might need to do the same for your own purposes. In this case, getReferenceProps will merge two onFocus handlers into one handler, which will be passed to the reference element.

One thing to note: we're passing a ref to our reference element, and this is critical for Floating UI to work. So to guarantee that the Tooltip works properly, only use it with elements that accept refs. Using HTML tags (like <div>) will work as-is, function components should be wrapped in forwardRef and pass a ref to an underlying DOM element. If you can't update your component to forward the ref properly (e.g. when using a 3rd-party library) you can just wrap it in a div and in most cases this will fix the issue.

There might be a problem if you need to assign your own ref to a reference element. With current approach, it will be overwritten with ref from Floating UI. To work around that, you can use useMergeRefs hook to merge refs into one mega-ref.

And lastly, we conditionally render div inside <FloatingPortal>, passing it props using getFloatingProps. We need FloatingPortal to avoid stacking context issues and scenarios where your tooltip is rendered beneath other elements or trimmed by parent with overflow: hidden. FloatingPortal will render your tooltip at the end of the body, which guarantees correct work.

Adding animations

In this section we'll take our component from acceptable to good (and later to exceptional) — we're gonna add animations. We won't go overboard with them, but believe me, even a simple animation makes the component a lot more pleasant to use.

We'll use Framer Motion for this purpose. Let's import it:

import { AnimatePresence, motion } from 'framer-motion';

Framer Motion does a lot of work for you, but for it to work properly, you'll need to use the components provided by Framer Motion (or wrap your custom components with motion). So let's replace our boring div with a new shiny motion.div and give it some animation:

// ...
{open && <motion.div
    initial={{ opacity: 0 }}
    animate={{ opacity: 1 }}
    {...getFloatingProps({
        ref: floating,
        className: 'Tooltip',
        style: {
            position: strategy,
            top: y ?? 0,
            left: x ?? 0,
        }
    })}
>
    {content}
</motion.div>}
// ...

Whoosh! Your tooltip is animated now. That's all it takes to animate a component with Framer Motion. initial describes the initial style of the element, animate describes its target style, and Framer Motion animates the transition for us.

Curious why we pass those props directly to the component instead of getFloatingProps? It's because Floating UI definitely won't use these props, so it's safe to pass them directly. Additionally, getFloatingProps is typed in a way to accept only standard HTML element properties and TypeScript will yell at you if you pass initial or animate there.

But this animation looks very boring, doesn't it? Let's make it prettier. We're going to add sliding to accomplish this. This animation should be placement-aware in order to avoid looking awkward.

const translate = {
    'top': { translateY: 5, },
    'bottom': { translateY: -5, },
    'left': { translateX: 5 },
    'right': { translateX: -5 },
}[computedPlacement.includes('-') ? computedPlacement.split('-')[0] : computedPlacement];

Because our final placement might be different from the one provided by the developer we use computedPlacement and since placement can be either side or side-alignment (e.g. top and top-left) we need to account for that too. Now just pass those as part of initial:

// ...
<motion.div
    initial={{ opacity: 0, ...translate }}
    animate={{ opacity: 1, translateX: 0, translateY: 0 }}
    // ...
>{content}</motion.div>

And the only missing piece now is unmount animation. This is especially tricky to do without libraries as you need to animate an element when it's unmounted from the components tree, but once it's unmounted it's also removed from DOM, and you have nothing to animate. There are tricks to get around this, but fortunately we have Framer Motion and don't need to stress about implementing them ourselves. To animate unmount, you need to wrap your motion component into AnimatePresence and add exit prop to the component.

// ...
<FloatingPortal>
    <AnimatePresence>
        {open && <motion.div
            initial={{ opacity: 0, ...translate }}
            animate={{ opacity: 1, translateX: 0, translateY: 0 }}
            exit={{ opacity: 0, ...translate }}
            {...getFloatingProps({
                ref: floating,
                className: 'Tooltip',
                style: {
                    position: strategy,
                    top: y ?? 0,
                    left: x ?? 0,
                }
            })}
        >
            {content}
        </motion.div>}
    </AnimatePresence>
</FloatingPortal>
// ...

We only scratched the basics of what Framer Motion can do, but this has already made our component a lot better. In the next section, we'll see how to animate tooltips in a group. Here is how our <Tooltip> looks so far:

import { FloatingPortal, Placement, autoUpdate, flip, offset, shift, useDismiss, useFloating, useFocus, useHover, useInteractions, useRole } from '@floating-ui/react';
import './Tooltip.css';
import { ReactNode, cloneElement, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
 
export type TooltipProps = {
    content: ReactNode;
    showDelay?: number;
    hideDelay?: number;
    placement?: Placement;
    children: JSX.Element;
}
 
export const Tooltip = ({ content, showDelay = 500, hideDelay = 100, placement = 'top', children }: TooltipProps) => {
    const [open, setOpen] = useState(false);
 
    const { x, y, reference, floating, strategy, context, placement: computedPlacement } = useFloating({
        placement,
        open,
        onOpenChange(open) {
            setOpen(open);
        },
        middleware: [offset(5), flip(), shift({ padding: 8 })],
        whileElementsMounted: autoUpdate
    });
 
    const { getReferenceProps, getFloatingProps } = useInteractions([
        useHover(context, { delay: showDelay, restMs: hideDelay }),
        useFocus(context),
        useRole(context, { role: 'tooltip' }),
        useDismiss(context),
    ]);
 
    const translate = {
        'top': { translateY: 5, },
        'bottom': { translateY: -5, },
        'left': { translateX: 5 },
        'right': { translateX: -5 },
    }[computedPlacement.includes('-') ? computedPlacement.split('-')[0] : computedPlacement];
 
    return (<>
        {cloneElement(
            children,
            getReferenceProps({ ref: reference, ...children.props })
        )}
        <FloatingPortal>
            <AnimatePresence>
                {open && <motion.div
                    initial={{ opacity: 0, ...translate }}
                    animate={{ opacity: 1, translateX: 0, translateY: 0 }}
                    exit={{ opacity: 0, ...translate }}
                    {...getFloatingProps({
                        ref: floating,
                        className: 'Tooltip',
                        style: {
                            position: strategy,
                            top: y ?? 0,
                            left: x ?? 0,
                        }
                    })}
                >
                    {content}
                </motion.div>}
            </AnimatePresence>
        </FloatingPortal>
    </>);
};

Layout transitions

Finally, we got to the most interesting part of the article. And the trickiest one — it took me a few hours to make this transition, not gonna lie. So let's save you a few hours and get right to it.

Imagine a user who is only starting to use complex software. There are numerous buttons in toolbar (think 'PowerPoint'), a lot of them are new to the user. It's natural to hover the mouse over such a button and expect some sort of tip/help message to appear. I do this constantly, and I'm sure you do this too. Now imagine there are ten of these buttons and the user hovers over each of them one-by-one. Every time they need to wait before tip appears — I can see how this could quickly become irritating.

To solve this problem, Floating UI provides <FloatingDelayGroup> component. You wrap a group of tooltips (toolbar in our example) with this component, and it keeps track of open tooltips and reduces delay to open if needed.

import { FloatingDelayGroup } from "@floating-ui/react";
// Your layout
<FloatingDelayGroup delay={600} timeoutMs={300}>
    <div>
        <Tooltip content="Hello!">
            <button>Hover me</button>
        </Tooltip>
        <Tooltip content="Hello again!">
            <button>And me</button>
        </Tooltip>
        <Tooltip content="Lorem ipsum dolor sit amet consectetur adipiscing elit nulla proin, erat auctor purus congue lobortis laoreet rutrum eu duis aptent, parturient facilisis libero hendrerit rhoncus arcu nam fusce.">
            <button>Don't hover me</button>
        </Tooltip>
    </div>
</FloatingDelayGroup>

Because we previously hardcoded a delay within the component, we need to update the code to use the delay provided by FloatingDelayGroup. We do that by using two hooks: useDelayGroupContext to get delay from group and useDelayGroup to let floating group know about our tooltip. Put this on the top of the component (before useState hook):

const { delay, isInstantPhase } = useDelayGroupContext();
const showDelayFinal = (typeof delay === 'number' ? delay : delay.open) || showDelay;
const hideDelayFinal = (typeof delay === 'number' ? delay : delay.close) || hideDelay;

We verify the type of delay from group (since it may be either a number or an object) and use it if it's not 0 (0 is the default value when the tooltip is outside of the group), else we use delays from the tooltip props. Now we need to update our useHover hook to use those variables:

useHover(context, { delay: showDelayFinal, restMs: hideDelayFinal }),

And we need to let the group know about our lil tooltip:

// After useInteractions hook
const tooltipId = useId();
useDelayGroup(context, { id: tooltipId });

That's all for FloatingDelayGroup. Now, when you move the pointer from one reference element to another (in the same group) the next tooltip should open instantly.

Now let's get to the bread and butter of this tutorial and make our Tooltip move from one element to another instead of hiding and reappearing again. We'll do this with the help of layout transitions from Framer Motion.

When talking about animation, we usually think about it as 'gradually increasing height from 100px to 200px'. And this is correct, of course, it's what all animations are about. However, once we get to more advanced use-cases, it becomes a lot harder. A canonical example from Framer Motion is 'how do you animate between justify-content: flex-start and justify-content: flex-end?' Correct answer is: you don't, Framer Motion does this for you 😄. If your motion element has changed its size or position, Framer Motion is able to automatically detect this and animate it, all you need to do is add a layout prop to your element.

justify-content: flex-end
export const LayoutTransitionExample = () => {
    const [start, setStart] = useState(false);
    
    return (<div style={{display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'stretch'}}>
        <div style={{
            display: 'flex', 
            justifyContent: start ? 'flex-start' : 'flex-end', 
            minWidth: '400px', 
            padding: '10px', 
            borderRadius: '18px', 
            border: '1px solid var(--color-primary)'}}
        >
            <motion.div layout style={{background: 'white', borderRadius: 9999, height: '1rem', width: '1rem'}} />
        </div>
 
        <code>justify-content: {start ? 'flex-start' : 'flex-end'}</code>
 
        <Button onClick={() => setStart(p => !p)}>Toggle</Button>
    </div>);
};

If that looks interesting, you should definitely check out Framer Motion documentation, they explain everything super clear and have good examples. If you're very curious and want to know how it works under the hood, check Nanda's article.

Framer Motion can not only animate position or size change of element, it can "morph" an element (that was just unmounted) into another element (which just appeared), as long as they have the same layoutId prop. So think about this: when the pointer leaves one reference element and hovers another one, the tooltip of first element will be unmounted, but there will be a new tooltip with the same layoutId — so Framer Motion will animate this transition and for the user it will look like tooltip just moved to another element. To achieve this effect we need to adjust our layout a bit and wrap our tooltips in <LayoutGroup>:

import { LayoutGroup } from "framer-motion";
import { FloatingDelayGroup } from "@floating-ui/react";
// Your layout
<FloatingDelayGroup delay={600} timeoutMs={300}>
    <LayoutGroup>
        <div>
            <Tooltip content="Hello!">
                <button>Hover me</button>
            </Tooltip>
            <Tooltip content="Hello again!">
                <button>And me</button>
            </Tooltip>
            <Tooltip content="Lorem ipsum dolor sit amet consectetur adipiscing elit nulla proin, erat auctor purus congue lobortis laoreet rutrum eu duis aptent, parturient facilisis libero hendrerit rhoncus arcu nam fusce.">
                <button>Don't hover me</button>
            </Tooltip>
        </div>
    </LayoutGroup>
</FloatingDelayGroup>

Then pass a layoutId='tooltip' prop to our tooltip :

<motion.div
    // ...
    layoutId='tooltip'
    {...getFloatingProps({
        // ...
    })}
>
    {content}
</motion.div>

But if you try hovering the tooltip now, you'll see that the animation is still very junky. Why does my tooltip show in the upper left corner of the screen before moving to the reference element? Looks ugly!

Let me explain why this happens. For Floating UI to correctly position a floating element, it needs to know its dimensions. And to measure the element, it should be rendered on page. So Floating UI does exactly this: renders floating element at x=0,y=0 (which is top left corner), and then measures tooltip's size in useLayoutEffect hook and changes x/y state variable, which causes re-render and now the floating element is placed at correct coordinates. Because Floating UI uses useLayoutEffect (which is run before the browser paints freshly rendered content) you don't see this position change. For you, the tooltip appears as rendered at the correct position right away. But for Framer Motion, which is unaware of these Floating UI shenanigans, it appears as if the element has just changed position and needs to be animated.

How we're going to work around this? Well, Floating UI just needs to measure a tooltip, but it doesn't have to be the exact one we'll position. We're going to keep track of the tooltip state (hidden/initial/positioned) and render different elements based on this state. Luckily for us, Floating UI will let us know if an element is positioned or not. The lifecycle of a tooltip will look like this:

  1. Tooltip starts as unmounted.
  2. When Floating UI triggers the opening (in the onOpenChange callback), we change the state to initial.
  3. When isPositioned changes to true, we switch state to positioned
  4. When <AnimatePresence> finishes animating the unmount, we switch state to unmounted

Let's implement this step by step. Firstly, add a new useState hook:

// ...
const [open, setOpen] = useState(false);
// unmounted -> initial -> positioned -> unmounted
const [state, setState] = useState<'unmounted' | 'initial' | 'positioned'>('unmounted');
// ...

Now let's update our onOpenChange callback:

onOpenChange(open) {
    setOpen(open);
    if (open) {
        // We need additional check because onOpenChange will be triggered when we switch between tooltip elements
        if (state === 'unmounted') setState('initial');
    }
},

isPositioned is returned from the useFloating hook, and we can track its changes in the useLayoutEffect hook:

// ...
const { x, y, reference, floating, strategy, context, isPositioned, placement: computedPlacement } = useFloating({
    // ...
});
// ...
useLayoutEffect(() => {
    if (isPositioned && state !== 'positioned') {
        setState('positioned');
    }
}, [isPositioned]);

We use layout effect here because we'd like to switch to the actual tooltip as soon as possible and the regular useEffect might be delayed.

And lastly, AnimatePresence has a onExitComplete which we'll use to mark our tooltip as unmounted:

// ...
<AnimatePresence onExitComplete={() => setState('unmounted')}>
    // ...
</AnimatePresence>
// ...

Now we just need to conditionally render different elements based on our state variable:

<FloatingPortal>
    {/* This element used to measure its size for position calculation, and later we render true tooltip */}
    {(open && state === 'initial') && <div
        {...getFloatingProps({
            ref: floating,
            className: 'Tooltip',
            style: {
                position: strategy,
                visibility: 'hidden',
                top: 0,
                left: 0,
            }
        })}
    >
        {content}
    </div>}
    <AnimatePresence onExitComplete={() => setState('unmounted')}>
        {(open && state === 'positioned') && <motion.div
            initial={{ opacity: 0, ...translate }}
            animate={{ opacity: 1, translateX: 0, translateY: 0 }}
            exit={{ opacity: 0, ...translate }}
            transition={{ duration: 0.2 }}
            layoutId='tooltip'
            {...getFloatingProps({
                ref: floating,
                className: 'Tooltip',
                style: {
                    position: strategy,
                    top: y ?? 0,
                    left: x ?? 0,
                }
            })}
        >
            {content}
        </motion.div>}
    </AnimatePresence>
</FloatingPortal>

At this point, you should have a properly working layout animation. But you might notice small twitching when you move the pointer from one element to another. This happens because Framer Motion still tries to play render animation. Fortunately, this is easily fixable: we already have a isInstantPhase variable from our useDelayGroupContext which we can use to conditionally disable animation:

<motion.div
    initial={isInstantPhase ? {} : { opacity: 0, ...translate }}
     // ...
>
    {content}
</motion.div>

Now you know how to make a cool animated tooltip. Congratulations! 🎉🎉

Here is how our tooltip looks like at this stage:

'use client';
import {
    FloatingPortal, Placement, autoUpdate, flip, offset, shift, useDelayGroup, useDelayGroupContext, useDismiss,
    useFloating, useFocus, useHover, useInteractions, useRole
} from '@floating-ui/react';
import './Tooltip.css';
import { ReactNode, cloneElement, useId, useLayoutEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
 
export type TooltipProps = {
    content: ReactNode;
    showDelay?: number;
    hideDelay?: number;
    placement?: Placement;
    children: JSX.Element;
}
 
export const Tooltip = ({ content, showDelay = 500, hideDelay = 200, placement = 'top', children }: TooltipProps) => {
    const { delay, isInstantPhase } = useDelayGroupContext();
    const showDelayFinal = (typeof delay === 'number' ? delay : delay.open) || showDelay;
    const hideDelayFinal = (typeof delay === 'number' ? delay : delay.close) || hideDelay;
    const [open, setOpen] = useState(false);
 
    // unmounted -> initial -> positioned -> unmounted
    const [state, setState] = useState<'unmounted' | 'initial' | 'positioned'>('unmounted');
 
    const { x, y, reference, floating, strategy, context, isPositioned, placement: computedPlacement } = useFloating({
        placement,
        open,
        onOpenChange(open) {
            setOpen(open);
            if (open) {
                // We need additional check because onOpenChange will be triggered when we switch between tooltip elements
                if (state === 'unmounted') setState('initial');
            }
        },
        middleware: [offset(5), flip(), shift({ padding: 8 })],
        whileElementsMounted: autoUpdate
    });
 
    const { getReferenceProps, getFloatingProps } = useInteractions([
        useHover(context, { delay: showDelayFinal, restMs: hideDelayFinal }),
        useFocus(context),
        useRole(context, { role: 'tooltip' }),
        useDismiss(context),
    ]);
 
    const tooltipId = useId();
    useDelayGroup(context, { id: tooltipId });
 
    useLayoutEffect(() => {
        if (isPositioned && state !== 'positioned') {
            setState('positioned');
        }
    }, [isPositioned]);
 
    const translate = {
        'top': { translateY: 5, },
        'bottom': { translateY: -5, },
        'left': { translateX: 5 },
        'right': { translateX: -5 },
    }[computedPlacement.includes('-') ? computedPlacement.split('-')[0] : computedPlacement];
 
    return (<>
        {cloneElement(
            children,
            getReferenceProps({ ref: reference, ...children.props })
        )}
        <FloatingPortal>
            {/* This element used to measure its size for position calculation, and later we render true tooltip */}
            {(open && state === 'initial') && <div
                {...getFloatingProps({
                    ref: floating,
                    className: 'Tooltip',
                    style: {
                        position: strategy,
                        visibility: 'hidden',
                        top: 0,
                        left: 0,
                    }
                })}
            >
                {content}
            </div>}
            <AnimatePresence onExitComplete={() => setState('unmounted')}>
                {(open && state === 'positioned') && <motion.div
                    initial={isInstantPhase ? {} : { opacity: 0, ...translate }}
                    animate={{ opacity: 1, translateX: 0, translateY: 0 }}
                    exit={{ opacity: 0, ...translate }}
                    transition={{ duration: 0.2 }}
                    layoutId='tooltip'
                    {...getFloatingProps({
                        ref: floating,
                        className: 'Tooltip',
                        style: {
                            position: strategy,
                            top: y ?? 0,
                            left: x ?? 0,
                        }
                    })}
                >
                    {content}
                </motion.div>}
            </AnimatePresence>
        </FloatingPortal>
    </>);
};

Fixing bugs & making code pretty

But we're not finished yet. There are still a couple of things which we can improve.

  • When animating from small tooltip to large (and vice versa) text gets noticeably distorted.
  • Since all tooltips share the same layoutId there will be junky animations when the user moves the pointer from reference element in one group to element in the other group.
  • Having to wrap each group in FloatingDelayGroup and then in LayoutGroup is just annoying.

Let's start tackling the issue with text distortion. Due to the way Framer Motion makes these transitions, we can't get rid of it completely. But we can make it less noticeable. To achieve this, we'll just animate the tooltip's content alongside the tooltip itself.

<motion.div
    // ...
    {...getFloatingProps({
        // ...
    })}
>
    <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
    >
        {content}
    </motion.div>
</motion.div>

We wrap the content of the tooltip in a separate div, which will fade-out and fade-in as we switch from one tooltip to another.

As for the other two issues, we can solve them together. Let's create a new <TooltipGroup> component which will include both FloatingDelayGroup and LayoutGroup and also provide isolation of layout animations inside the group. To pass data from TooltipGroup down to individual Tooltips, we are going to use context.

const TooltipGroupContext = createContext<{ groupId: string | undefined, placement: Placement }>({
    groupId: undefined,
    placement: 'top',
});

Because we're creating our own group component, it makes sense to allow the developer to set placement once for the entire group rather than for each tooltip. groupId is a unique ID we'll generate in the group component and pass down to our tooltips to use as the value of layoutId prop.

export type TooltipGroupProps = {
    showDelay?: number;
    hideDelay?: number;
    timeout?: number;
    placement?: Placement;
    children: ReactNode,
};
 
export const TooltipGroup = ({ showDelay = 600, hideDelay = 200, timeout = 300, placement = 'top', children }: TooltipGroupProps) => {
    const groupId = useId();
 
    return (<TooltipGroupContext.Provider value={{ groupId, placement }}>
        <FloatingDelayGroup delay={{ open: showDelay, close: hideDelay }} timeoutMs={timeout}>
            <LayoutGroup>
                {children}
            </LayoutGroup>
        </FloatingDelayGroup>
    </TooltipGroupContext.Provider>)
};

And finally, we need to update our tooltip to work with tooltip group:

// On top of component
const { placement: groupPlacement, groupId } = useContext(TooltipGroupContext);
const placementFinal = placement || groupPlacement;
 
// Update useFloating hook
const { x, y, reference, floating, strategy, context, isPositioned, placement: computedPlacement } = useFloating({
    // ...
    placement: placementFinal,
    // ...
});
 
// Update layoutId prop
 
<motion.div
    // ...
    layoutId={groupId}
    {...getFloatingProps({
        // ...
    })}
>
    <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
    >
        {content}
    </motion.div>
</motion.div>

Now we're officially finished. This component is ready to be used in your next project :)

You use it like this:

<TooltipGroup>
    <Tooltip content="I tried so hard...">
        <button>We're in group</button>
    </Tooltip>
    <Tooltip content="... and got so far">
        <button>try moving mouse</button>
    </Tooltip>
    <Tooltip content="But in the end I never gonna give you up">
        <button>to this button</button>
    </Tooltip>
</TooltipGroup>

And full code can be found on CodeSandbox.

That's all! You just made your front-end muscle a bit stronger. I hope you liked this article. It turned out to be much longer than I expected, but here we are. If you know someone who would be interested in these kinds of tutorials, share this article and your honest review with them (and I'll send you a picture of a cat, agreed?).

If you have questions or suggestions, send me a message on Twitter. And if you would like to know about new tutorials on making beautiful components with React and Framer Motion (I plan to write a couple of those for sure) — sign up for my newsletter. No spam, maybe some exclusive tips and tricks. Peace.

Published at 24 April 2023
Last updated at 18 March 2024
Was this interesting or useful?
I publish a newsletter with articles I found interesting and announces of my new posts. You can leave your email and get new issues delivered to your inbox!
Alternatively you can subscribe to my RSS feed to know about new posts.
Or follow me on Twitter, where I sometimes post about new articles, my pet projects, and web dev in general.
If you really like the article, you can give me monies, and I'll buy myself tasty coffee to write even more.

You may also like: