DEV Community

Cover image for Build a tooltip component
Phuoc Nguyen
Phuoc Nguyen

Posted on • Originally published at phuoc.ng

Build a tooltip component

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.

interface TooltipProps {
    children: React.ReactNode;
    tip: string;
}

const Tooltip: React.FC<TooltipProps> = ({ children, tip }) => {
    ...
});
Enter fullscreen mode Exit fullscreen mode

Here's how we can use the tooltip with this design:

<Tooltip tip="A sample tip content">
    <div>Hover me</div>
</Tooltip>
Enter fullscreen mode Exit fullscreen mode

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.

const triggerRef = React.useRef();

// Render
<div
    ref={triggerRef}
    onMouseEnter={handleMouseEnter}
    onMouseLeave={handleMouseLeave}
>
    {children}
</div>
Enter fullscreen mode Exit fullscreen mode

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.

const [isOpen, setIsOpen] = React.useState(false);

const handleMouseEnter = () => setIsOpen(true);
const handleMouseLeave = () => setIsOpen(false);
Enter fullscreen mode Exit fullscreen mode

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.

{
    isOpen && ReactDOM.createPortal(
        <div className="tip__content">
            {tip}
        </div>,
        document.body
    )
}
Enter fullscreen mode Exit fullscreen mode

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:

.tip__content {
    background-color: rgb(15 23 42);
    color: #fff;

    position: absolute;
    top: 0;
    left: 0;
}
Enter fullscreen mode Exit fullscreen mode

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.

const tipRef = React.useRef();

// Render
{
    isOpen && ReactDOM.createPortal(
        <div className="tip__content" ref={tipRef}>
            ...
        </div>,
        document.body
    )
}
Enter fullscreen mode Exit fullscreen mode

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:

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]);
Enter fullscreen mode Exit fullscreen mode

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.

tipRef.current.style.transform = `translate(${left}px, ${top}px)`;
Enter fullscreen mode Exit fullscreen mode

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.

.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;
}
Enter fullscreen mode Exit fullscreen mode

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.

const Tooltip = ({ children, tip }) => {
    // Render
    children({
        ref: triggerRef,
        show,
        hide,
    });
};
Enter fullscreen mode Exit fullscreen mode

Now users have full control over how to show or hide the tooltip. They can rely on the same mouse events as before.

<Tooltip tip="A sample tip content">
{
    ({ ref, show, hide }) => (
        <div ref={ref} onMouseEnter={show} onMouseLeave={hide}>
            Hover me
        </div>
    )
}
</Tooltip>
Enter fullscreen mode Exit fullscreen mode

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.

<Tooltip tip="...">
{
    ({ ref, show, hide }) => (
        <div ref={ref} onClick={show}>
            ...
        </div>
    )
}
</Tooltip>
Enter fullscreen mode Exit fullscreen mode

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.

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,
});
Enter fullscreen mode Exit fullscreen mode

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


It's highly recommended that you visit the original post to play with the interactive demos.

If you found this series 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!

If you want more helpful content like this, feel free to follow me:

Top comments (0)