A Step-by-Step Guide to Building a Custom Tooltip in React using useRef and Hooks
Andrew McCallum
Posted on July 11, 2023
In this blog post, I will guide you through the process of creating a reusable and extensible tooltip component using the useRef hook and custom hooks. By following along, you will not only gain a deeper understanding of how the useRef hook functions, but also learn how to implement your own hooks. It's an excellent opportunity to enhance your knowledge and skills in React development.
If you want to follow along, you may want to use https://codesandbox.io and I'd start by creating the following file structure. Otherwise, keep reading and find a working example at the end.
├── src
│ ├── App.tsx
│ ├── Tooltip.tsx
│ ├── Tooltip.hooks.tsx
│ ├── Tooltip.css
Firstly, we are going to create our Tooltip component. This will be a simple component that just renders its children. It will also receive an elementRef
as a prop that will come in handy later.
// Tooltip.tsx
import React, { FC, RefObject } from "react";
type TooltipProps = {
elementRef: RefObject<HTMLElement>;
children: React.ReactNode;
};
const Tooltip: FC<TooltipProps> = ({ children, elementRef }) => {
return <div>{children}</div>;
};
export default Tooltip;
In our App.tsx
, we are now going to render a button to which we attach a ref. We also render our Tooltip component and pass our button ref into it.
// App.tsx
import React, { useRef } from "react";
import Tooltip from "./Tooltip";
export default function App() {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<>
<Tooltip elementRef={buttonRef}>
<span>I'm a tooltip</span>
</Tooltip>
<div className="App">
<button className="button" ref={buttonRef}>
Hello world
</button>
</div>
</>
);
}
Now we are going to create our custom hook. This hook will contain the logic for making the tooltip visible and positioning it on the screen.
// Tooltip.hooks.tsx
import { useState } from "react";
type Position = {
top?: number;
left?: number;
width?: number;
};
export function useTooltip() {
const [isVisible, setIsVisible] = useState<boolean>(false);
const [position, setPosition] = useState<Position>({});
return {
position: {
top: position.top ?? 0,
left: position.left ?? 0,
width: position.width ?? 0,
},
isVisible,
};
}
Let's now use our useTooltip hook in our Tooltip component. If our tooltip is not visible, we'll return nothing, and we will make sure we pass the top and left positions into our div's inline styles. We need to use inline styles here because the values are dynamic, so we don't want to hardcode them in our CSS file.
// Tooltip.tsx
import React, { FC, RefObject } from "react";
import { useTooltip } from "./Tooltip.hooks";
import "./Tooltip.css"; // importing our styles here
type TooltipProps = {
elementRef: RefObject<HTMLElement>;
children: React.ReactNode;
};
const Tooltip: FC<TooltipProps> = ({ children, elementRef }) => {
const { position, isVisible } = useTooltip();
if (!isVisible) {
return null;
}
return (
<div
className="tooltip-container" // adding className here for later use
style={{
top: position.top,
left: position.left,
}}
>
{children}
</div>
);
};
export default Tooltip;
We want the useTooltip hook to have some context about the element we are hovering over, so we are going to pass our button ref from the Tooltip component into the hook. While we're here, we are also going to add a couple of functions that will be used when the user hovers over our button. These are onMouseEnter for when the cursor hovers over the element, and onMouseLeave for when the cursor exits the hover. When these events occur, we will set our tooltip to visible.
// Tooltip.hooks.tsx
import { useState, RefObject, useCallback, useEffect } from "react";
type Position = {
top?: number;
left?: number;
width?: number;
};
type UseTooltipProps = {
ref: RefObject<HTMLElement>;
};
export function useTooltip({ ref }: UseTooltipProps) {
const [isVisible, setIsVisible] = useState<boolean>(false);
const [position, setPosition] = useState<Position>({});
useEffect(() => {
if (!ref.current) {
return;
}
if (isVisible) {
const { left, width, bottom } = ref.current.getBoundingClientRect();
setPosition({
top: bottom,
left: left,
width,
});
}
if (!isVisible) {
setPosition({});
}
}, [isVisible, ref]);
const onMouseEnter = useCallback(() => {
setIsVisible(true);
}, []);
const onMouseLeave = useCallback(() => {
setIsVisible(false);
}, []);
return {
position: {
top: position.top ?? 0,
left: position.left ?? 0,
width: position.width ?? 0,
},
isVisible,
onMouseEnter,
onMouseLeave,
};
}
As you can see, we have defined two new functions: onMouseEnter and onMouseLeave, which we define using useCallback. This allows us to cache the function definition between re-renders and ensures that whenever we set our position or isVisible values, the functions don't get redefined, avoiding unnecessary renders that can result in a flashing component.
In our onMouseEnter function, we set isVisible to true so that the Tooltip is actually rendered, and in our onMouseLeave function, we set it to false.
You may also notice that we are setting the position of our tooltip in a useEffect that gets called whenever isVisible changes. This will make sense later, but essentially, we only want to set our tooltip's position once it has rendered because we are going to need to calculate its dimensions to position it correctly.
For now, we are aligning the left side of our button with the left side of our tooltip and the top of our tooltip with the bottom of our button. We will improve this shortly.
Now we can update our Tooltip component to pass our button ref and attach the onMouseEnter / onMouseLeave events to it.
// Tooltip.tsx
import React, { FC, RefObject, useEffect } from "react";
import { useTooltip } from "./Tooltip.hooks";
import "./Tooltip.css";
type TooltipProps = {
elementRef: RefObject<HTMLElement>;
children: React.ReactNode;
};
const Tooltip: FC<TooltipProps> = ({ children, elementRef }) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const {
position,
isVisible,
onMouseEnter,
onMouseLeave,
} = useTooltip({
ref: elementRef,
tooltipRef,
});
useEffect(() => {
const element = elementRef?.current;
if (element) {
element.addEventListener("mouseenter", onMouseEnter);
element.addEventListener("mouseleave", onMouseLeave);
}
// cleans up event listeners by removing them when the component is unmounted
return () => {
if (element) {
element.removeEventListener("mouseenter", onMouseEnter);
element.removeEventListener("mouseleave", onMouseLeave);
}
};
}, [elementRef, onMouseEnter, onMouseLeave]);
if (!isVisible) {
return null;
}
return (
<div
ref={tooltipRef}
className="tooltip-container" // adding className here for later use
style={{
top: position.top,
left: position.left,
}}
>
{children}
</div>
);
};
export default Tooltip;
If we add some CSS to our Tooltip, we should be able to start seeing this come together.
/* Tooltip.css */
.tooltip-container {
position: absolute;
font-size: 14px;
line-height: 20px;
background-color: #2c3a43;
color: white;
padding: 16px;
margin: 0;
}
.tooltip-container::before {
content: "";
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 16px solid transparent;
border-right: 16px solid transparent;
border-bottom: 18px solid #2c3a43;
}
You'll notice the tooltip isn't positioned where we want it, so let's go ahead and fix its position.
We're going to update the onMouseEnter function to do some simple math. We want our tooltip to be pointing to the middle of our button, so we need to align the middle of our tooltip div with the middle of the button element. To do this, our useTooltip hook needs to be aware of the Tooltip's div element and be able to get some dimensions from it.
We get the dimensions of the tooltip within a useEffect
because that ensures the isVisible
is set to true. That way the element has actually rendered and has a width set. Otherwise, if the element has not rendered, it would have a width of zero and we wouldn't be able to calculate the middle point.
Next we will align our tooltip below our button by providing a verticalOffset
// Tooltip.hooks.tsx
import React, { RefObject, useCallback, useEffect, useState } from "react";
type UseTooltipProps = {
ref: RefObject<HTMLElement>;
tooltipRef: RefObject<HTMLDivElement>;
};
type Position = {
top?: number;
left?: number;
width?: number;
};
export function useTooltip({ ref, tooltipRef }: UseTooltipProps) {
const [isVisible, setIsVisible] = useState<boolean>(false);
const [position, setPosition] = useState<Position>({});
useEffect(() => {
if (!ref.current) {
return;
}
if (isVisible) {
const { left, width, bottom } = ref.current.getBoundingClientRect();
const tooltipWidth = tooltipRef?.current?.getBoundingClientRect().width || 0;
const middle = left + width / 2 - tooltipWidth / 2;
const verticalOffset = 12 // If you change the size of the arrow in the css class tooltip-container::before then you will need to change this value
setPosition({
top: bottom + verticalOffset,
left: middle,
width,
});
}
if (!isVisible) {
setPosition({});
}
}, [isVisible, ref, tooltipRef]);
const onMouseEnter = useCallback(() => {
setIsVisible(true);
}, []);
const onMouseLeave = useCallback(() => {
setIsVisible(false);
}, []);
return {
position: {
top: position.top ?? 0,
left: position.left ?? 0,
width: position.width ?? 0,
},
isVisible,
onMouseEnter,
onMouseLeave,
};
}
Finally, we can update our Tooltip component to create a ref for our Tooltip div and pass it to our hook so that the aforementioned calculations can be done.
// Tooltip.tsx
import React, { FC, RefObject, useEffect, useRef } from "react";
import { useTooltip } from "./Tooltip.hooks";
import "./Tooltip.css";
type TooltipProps = {
elementRef: RefObject<HTMLElement>;
children: React.ReactNode;
};
const Tooltip: FC<TooltipProps> = ({ children, elementRef }) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const {
position,
isVisible,
onMouseEnter,
onMouseLeave,
} = useTooltip({
ref: elementRef,
tooltipRef,
});
useEffect(() => {
const element = elementRef?.current;
if (element) {
element.addEventListener("mouseenter", onMouseEnter);
element.addEventListener("mouseleave", onMouseLeave);
}
// cleans up event listeners by removing them when the component is unmounted
return () => {
if (element) {
element.removeEventListener("mouseenter", onMouseEnter);
element.removeEventListener("mouseleave", onMouseLeave);
}
};
}, [elementRef, onMouseEnter, onMouseLeave]);
if (!isVisible) {
return null;
}
return (
<div
ref={tooltipRef}
className="tooltip-container" // adding className here for later use
style={{
top: position.top,
left: position.left,
}}
>
{children}
</div>
);
};
export default Tooltip;
There we have it! Our tooltip should now display below our button when we hover over it.
One thing that makes this component highly extensible is the method we use to pass the tooltip content as children. While many components only accept a text prop, we enable users to send a React node, granting them the ability to render any component with their preferred styles. However, a potential downside of this approach, especially in a design system context, is the need to maintain consistency with the intended designs. In such cases, it may be advisable to provide some level of prescription regarding how the tooltip content should be rendered.
In conclusion, we have learned how to create a versatile and reusable tooltip component using React, useRef, and custom hooks. By leveraging useRef, we were able to create a tooltip that can be attached to any element on the screen. The use of custom hooks allowed us to handle the tooltip's visibility and positioning logic in a clean and reusable manner. With the flexibility of passing React nodes as tooltip content, we can customise the appearance and behaviour of our tooltips to suit our specific needs.
Posted on July 11, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 23, 2024