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.
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:
And CSS for our tooltip:
Surprisingly small amount of code, isn't it? Here is how it looks like:
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:
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:
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.
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
:
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.
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:
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.
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):
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:
And we need to let the group know about our lil tooltip:
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
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>
:
Then pass a layoutId='tooltip'
prop to our tooltip :
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:
- Tooltip starts as
unmounted
. - When Floating UI triggers the opening (in the
onOpenChange
callback), we change the state toinitial
. - When
isPositioned
changes totrue
, we switch state topositioned
- When
<AnimatePresence>
finishes animating the unmount, we switch state tounmounted
Let's implement this step by step. Firstly, add a new useState hook:
Now let's update our onOpenChange callback:
isPositioned
is returned from the useFloating
hook, and we can track its changes in the useLayoutEffect
hook:
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:
Now we just need to conditionally render different elements based on our state variable:
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:
Now you know how to make a cool animated tooltip. Congratulations! 🎉🎉
Here is how our tooltip looks like at this stage:
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 inLayoutGroup
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.
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.
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.
And finally, we need to update our tooltip to work with tooltip group:
Now we're officially finished. This component is ready to be used in your next project :)
You use it like this:
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.