← Back toMaster of React ref

Build a tooltip component

Written byPhuoc Nguyen
Created
26 Oct, 2023
Tooltips are small boxes that pop up when a user hovers over an element on a website or application. They're like little helpers that can contain helpful information, such as definitions for technical terms, explanations of features. Tooltips can greatly enhance the user experience by providing quick and easy access to additional information without cluttering the main interface.
They come in handy when there isn't enough space to show all the information or if the content isn't critical to the main action of the page. Tooltips can also be used to clarify icons, images, or other interactive elements on a webpage. They're versatile, easy to use, and have become a staple in modern web design.
In this post, we're going to learn how to build a tooltip component in React using refs, which we've been following in this series. Our Tooltip component will have two properties: `children` for displaying the trigger element, and `tip` for displaying the content of the tooltip.
tsx
interface TooltipProps {
children: React.ReactNode;
tip: string;
}

const Tooltip: React.FC<TooltipProps> = ({ children, tip }) => {
...
});
Here's how we can use the tooltip with this design:
tsx
<Tooltip tip="A sample tip content">
<div>Hover me</div>
</Tooltip>

Create a trigger element for the tooltip

To display a tooltip, you need an element that triggers the tooltip when you hover over it. The easiest way to do this is to wrap the whole thing in a wrapper element.
In this example, we use the `useRef()` hook to create a reference to the trigger element and attach it to the wrapper element using the `ref` attribute. Don't worry about the `handleMouseEnter` and `handleMouseLeave` functions for now. We'll cover those soon.
tsx
const triggerRef = React.useRef();

// Render
<div
ref={triggerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</div>
To control whether or not the tooltip is displayed, we use an internal state called `isOpen`, which is initially set to `false`. When the user hovers over the trigger element, the `onMouseEnter` event is triggered and `handleMouseEnter` sets the state to `true`. Similarly, when the user moves their mouse away from the trigger element, `handleMouseLeave` sets `isOpen` back to `false`, causing the tooltip content to disappear.
ts
const [isOpen, setIsOpen] = React.useState(false);

const handleMouseEnter = () => setIsOpen(true);
const handleMouseLeave = () => setIsOpen(false);
When the `isOpen` state is `true`, we display the tooltip content using the `ReactDOM.createPortal` function. This function creates a portal that lets us render a React component into a different part of the DOM tree, outside of our root element. In this case, we attach the tooltip content to the body of the page. This way, the tooltip can be positioned absolutely and won't be constrained by its parent container.
tsx
{
isOpen && ReactDOM.createPortal(
<div className="tip__content">
{tip}
</div>,
document.body
)
}
To render our component and show it in the right place on the page, we use two arguments. The first argument is the component itself, and the second argument specifies where we want to render it. With `createPortal`, we can make sure that our tooltip is always on top of everything else on the page, no matter where it is in the DOM tree.
We use the `tip__content` CSS class to style our tooltip content. This class sets the background color to a dark blue and the text color to white. The `position` property is set to `absolute`, which lets us position the tooltip relative to its nearest positioned ancestor, which in this case is the body element. We set the `top` and `left` properties to 0, so the tooltip appears at the top left corner of its parent element, which is the body element. By setting these properties to 0, we make sure that the tooltip appears right above our trigger element.
Here's how we declare it:
css
.tip__content {
background-color: rgb(15 23 42);
color: #fff;

position: absolute;
top: 0;
left: 0;
}
Finally, we need to position the tooltip correctly in relation to its trigger element. To do this, we can use the `useRef()` hook again to create a reference to the tooltip content.
tsx
const tipRef = React.useRef();

// Render
{
isOpen && ReactDOM.createPortal(
<div className="tip__content" ref={tipRef}>
...
</div>,
document.body
)
}
In order to position the tooltip accurately with respect to its trigger element, we use an effect hook. This effect runs every time there is a change in `isOpen`, `triggerRef`, or `tipRef`. First, it checks if both refs exist and if the tooltip should be open (i.e., `isOpen` is true). If it should be open, the effect calculates where to position the tooltip based on its dimensions and those of its trigger element. This is done using a combination of JavaScript and CSS transformations.
Here's how we calculate the position for the tooltip:
tsx
React.useEffect(() => {
if (!isOpen || !triggerRef.current || !tipRef.current) {
return;
}
const triggerRect = triggerRef.current.getBoundingClientRect();
const tipRect = tipRef.current.getBoundingClientRect();
const top = triggerRect.y + window.pageYOffset + triggerRect.height + 8;
const left = triggerRect.x + window.pageXOffset + (triggerRect.width - tipRect.width) / 2;

tipRef.current.style.transform = `translate(${left}px, ${top}px)`;
}, [isOpen, triggerRef, tipRef]);
To position the tooltip content correctly, we use the `getBoundingClientRect()` method to get the dimensions and position of both the trigger element and the tooltip content.
The `triggerRect` object contains information about the size and position of the trigger element, including its `x` and `y` coordinates relative to the viewport. We add `window.pageYOffset` to these coordinates to account for any scrolling on the page.
To calculate the `left` property, we add the `x` coordinate of the trigger element using `triggerRect.x`, any horizontal scrolling using `window.pageXOffset`, and half the width of our tooltip content. This ensures that the tooltip is centered over our trigger element.
To make sure the tooltip appears in the right spot next to its trigger element, we use a CSS transformation on the tooltip content with the `transform` property. Then we set the `style` object's `transform` value to a string that includes the `top` and `left` properties.
ts
tipRef.current.style.transform = `translate(${left}px, ${top}px)`;
The `translate()` function has two arguments: one for moving along the x-axis (horizontal) and the other for moving along the y-axis (vertical).
Good practice
Using the `transform` property to position elements instead of directly setting their `top` and `left` properties has some major benefits. For one, it allows us to create hardware-accelerated animations that are smoother and more efficient. Modern browsers can use the power of the GPU to perform these transformations. Another perk of `transform` is that we can combine multiple transformations into a single operation. This means we can rotate and scale an element at the same time by chaining `rotate()` and `scale()` functions together in a single `transform` rule. Finally, `transform` helps us avoid triggering layout recalculations when we modify an element's position or size, which can really slow things down. All in all, using `transform` is a smart way to position elements on a page and create seamless animations.
To enhance the user experience, we can use CSS to add an arrow to our tooltip. This is done by creating a pseudo-element on the tooltip content and styling it with CSS.
css
.tip__content::after {
background-color: rgb(15 23 42);
content: '';

position: absolute;
top: -0.25rem;
left: 50%;
transform: translateX(-50%) rotate(45deg);

width: 0.5rem;
height: 0.5rem;
}
First, we use the `::after` selector to create a new element after the content of our tooltip. We then style it to match the background color of our tooltip and make it transparent using the `content` property.
Next, we position the arrow at the center top of our tooltip using CSS. We set its `position` property to `absolute`, its `top` property to `-0.25rem`, and its `left` property to `50%`. The negative top value moves the arrow up by a quarter of a rem (which is half of its height) so that it appears above our tooltip content. The left value centers it horizontally over our tooltip.
To give the arrow a triangular shape, we use CSS transforms. We first move it halfway across itself using `translateX(-50%)`. We then rotate it by 45 degrees clockwise around its center point using `rotate(45deg)`. This gives us a right-angled triangle with sides equal in length to half of the width of our arrow.
To make the arrow visible, we set its width and height properties to `0.5rem`.
With these styles applied, we now have a cool arrow pointing upwards from the center of the top edge of our tooltip. You can customize the arrow further using CSS to match the design of your application.
Check out the demo below. Simply hover your mouse over the main text to see the tooltip appear. It's that easy!
While the tooltip now provides the desired functionalities, it creates an additional `div` element on top of the existing children which can potentially disrupt the layout or existing behavior of the children. Fortunately, there are a few ways to address this issue. Let's explore these solutions in the following sections.

Passing ref and props to children components

The first approach is to pass the entire ref and methods for showing and hiding the tooltip to children. We've gone over this pattern in detail before.
To accomplish this, we use the `children` prop which is a function that returns some JSX. This function takes an object as an argument, with properties including a `ref`, `show`, and `hide`. These properties can be used to show or hide the tooltip when certain events occur.
tsx
const Tooltip = ({ children, tip }) => {
// Render
children({
ref: triggerRef,
show,
hide,
});
};
Now users have full control over how to show or hide the tooltip. They can rely on the same mouse events as before.
tsx
<Tooltip tip="A sample tip content">
{
({ ref, show, hide }) => (
<div ref={ref} onMouseEnter={show} onMouseLeave={hide}>
Hover me
</div>
)
}
</Tooltip>
In this example, we set the `ref` property as the reference for the target element using the `ref` attribute. The `show` and `hide` functions handle the `onMouseEnter` and `onMouseLeave` events, respectively. This means that the tooltip will appear when users hover their mouse over the trigger element and disappear when they move their mouse away from it.
The great thing about this approach is that you have complete control over the tooltip interaction. For instance, you can choose to display the tooltip when users click on the trigger element.
tsx
<Tooltip tip="...">
{
({ ref, show, hide }) => (
<div ref={ref} onClick={show}>
...
</div>
)
}
</Tooltip>
Take a look at the demo below:

Cloning the children

In this approach, we can create a new child element by cloning an existing one.
To clone a child element in our tooltip component, we use the `React.cloneElement()` function. This function creates a new React element that is a copy of the original element passed as its first argument. We can then add new props to this new element using an object literal.
tsx
const child = typeof children === "string"
? <span>{children}</span>
: React.Children.only(children);
const clonedEle = React.cloneElement(child, {
...child.props,
ref: mergeRefs([child.ref, triggerRef]),
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
});
Let's take a look at this code block. We first check whether the `children` prop is a string or a single React element. If it's a string, we wrap it in a `<span>` element so that it can be cloned properly. If it's already an element, we make sure that there is only one child element using the `React.Children.only()` function.
Next, we use `React.cloneElement()` to add new props to the child element. We copy all the existing props from our child into a new object using the spread operator (`...`). Then, we add three new properties: `ref`, `onMouseEnter`, and `onMouseLeave`.
The `ref` property is set to our merged ref created by using the `mergeRefs()` utility function. This combines the existing ref of the children with the ref representing the trigger element. You can check out the previous post to see how we can merge different refs together.
The `onMouseEnter` property is set to our `handleMouseEnter` method, and the `onMouseLeave` property is set to our `handleMouseLeave` method. By cloning the children and passing down these additional props, we can ensure that our tooltip works correctly without modifying any existing behavior of its children.
Take a look at the demo below:

Conclusion

Tooltips are incredibly useful for providing extra information to users without making the interface cluttered. By creating our own custom tooltip component, we can control how it looks and behaves, making it blend seamlessly with our application's design.
We've explored two different ways of implementing tooltips in React: passing ref and props to children components, and cloning the children. Both methods let us add tooltip functionality without changing any existing behavior of the children.
Moreover, we've discovered some best practices for positioning elements on a page using CSS transforms instead of directly setting their `top` and `left` properties. This strategy enables us to create hardware-accelerated animations and avoid triggering layout recalculations when modifying an element's position or size.
Overall, tooltips are an excellent way to enhance your web application's user experience. Armed with these techniques, you can easily create your own custom tooltips that blend seamlessly with your design.

See also

If you found this post helpful, please consider giving the repository a star on GitHub or sharing the post on your favorite social networks 😍. Your support would mean a lot to me!

Questions? 🙋

Do you have any questions about front-end development? If so, feel free to create a new issue on GitHub using the button below. I'm happy to help with any topic you'd like to learn more about, even beyond what's covered in this post.
While I have a long list of upcoming topics, I'm always eager to prioritize your questions and ideas for future content. Let's learn and grow together! Sharing knowledge is the best way to elevate ourselves 🥷.
Ask me questions

Recent posts ⚡

Newsletter 🔔

If you're into front-end technologies and you want to see more of the content I'm creating, then you might want to consider subscribing to my newsletter.
By subscribing, you'll be the first to know about new articles, products, and exclusive promotions.
Don't worry, I won't spam you. And if you ever change your mind, you can unsubscribe at any time.
Phước Nguyễn