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 }) => {
...
});
Here's how we can use the tooltip with this design:
<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.
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.
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.
{
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:
.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.
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:
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.
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 theirtop
andleft
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 oftransform
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 chainingrotate()
andscale()
functions together in a singletransform
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, usingtransform
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;
}
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,
});
};
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>
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>
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,
});
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)