Build an Simple Tooltip System With React
Demystifying the way to create a simple, customisable, and accessible popup system with React
Why Are We Using Tooltips
In our user interfaces, we often want to have to be a bit more specific about what icon buttons are for, but sometimes it could be hard to find the space to have a proper button with an icon and a text.
It is the case when we have a sharing bar with icons to share on Twitter, Facebook, LinkedIn alongs with copying the link and having a menu icon to handle additional actions.
To solve the label problem, we often use the following solution: add a tooltip that could be visible as the mouse gets over the button.
However, as an important User eXperience advice, tooltips must be used sparingly. You should always prioritize a button the label instead of them.
What Are We Building?
The tooltip we are going to create is easy to use, 100% customizable, can be placed in every side of the targeted component and is just an overload, meaning you will not have to update your existing code.
The tooltip will look like this, but as I told you before, it can be replaced with your own very easily as long as it respects the component signature
Image
Requirements
The first thing to have a modal system is to have a modal root, where the system will take place. To do so, we just need to have a new div#modal-root
element in our root document.
It is based on the very same fundations of my previous article: Build an Easy Popup System With React.
This base is important so the tooltip can be easily styled. With a separate root element, we are sure that the parent elements of the tooltip does not have styles that will make it harder for us to reach the perfect style.
To be sure that the tooltip will always be on top of the document, we just need to add the right z-index
on the application root and the modal-root.
Also, since the tooltip behavior is to popin to the user as the link is overed, we add an ARIA live region to the tooltip system so it can be announced to the user.
The aria live region is set to assertive because we want the readers to have the same behavior as the browser, which places the popup on top of everything else.
#root {
position: relative;
z-index: 1;
}
#modal-root {
position: relative;
z-index: 2;
}
#root {
position: relative;
z-index: 1;
}
#modal-root {
position: relative;
z-index: 2;
}
The tooltip components
The tooltip component is split into two different elements:
- A
ModalPortal
component that will link our modal to thediv#modal-root
element (the same as in Build an Easy Popup System With React) - A
TooltipView
component that aims to handle the visible part of the tooltip - A
withTooltip
high-order component that will handle the tooltip domain and that will be mounted to the target component
The ModalPortal component
The ModalPortal
component exists to link our popup to the div#modal-root
element that we have created. Here’s the code:
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface IModalPortalProps {
active: boolean;
children: React.ReactNode;
}
export default function ModalPortal({
active,
children,
}: IModalPortalProps): React.ReactPortal | null {
const elRef = useRef<HTMLDivElement>();
useEffect(() => {
if (window) {
elRef.current = window.document.createElement("div");
}
}, []);
useEffect(() => {
const modalRoot = window.document.getElementById("modal-root");
if (active && elRef.current && modalRoot !== null) {
const { current } = elRef;
modalRoot.appendChild(current);
return () => {
modalRoot.removeChild(current);
};
}
return () => {};
}, [active]);
if (elRef.current && active) {
return createPortal(children, elRef.current);
}
return null;
}
It is made of four sections:
- A
ref
corresponding to a simplediv
element, with the goal of holding the popup content. We do not use directly the root element so we are able to create two or more different popups if we want to stack them. - A first
useEffect
hook to create thediv
element. This is a security to make the system work also on SSR systems such as NextJs or Gatsby. - Another
useEffect
hook, to add the previously createddiv
in the portal when active, and remove it when inactive. It will prevent thediv#modal-root
element to contain plenty of empty divs. - The render part, which is null if neither the
div
element created does not exist or the popup is not currently active.
The TooltipView component
The TooltipView component is pretty simple, and contain three props:
children
, which will be the tooltip content.left
, which is the absolute left position where the tooltip will be placedtop
, which is the absolute top position where the tooltip will be placedposition
, which is an element of an enumeration of positions. It will be defaulted to theTOP
value because it is the most common place for a tooltip.
As poiting arrow, I chose a svg element to have something less angular, but we can choose everything you want.
import React from "react";
import classes from "./Tooltip.module.css";
import { TooltipComponentType, TooltipPosition } from "./withTooltip";
const TooltipView: TooltipComponentType = ({
children,
left,
top,
position = TooltipPosition.TOP,
}) => {
return (
<div
style={{ left, top }}
className={`${classes.TooltipWrapper} ${classes["Tooltip-" + position]}`}
>
<span className={`${classes.Tooltip}`}>{children}</span>
<svg
className={classes.TooltipArrow}
viewBox="0 0 20 20"
height={10}
width={10}
>
<path d="M0 0L20 0L12.5 15Q10 20, 7.5 15L0 0" />
</svg>
</div>
);
};
export default TooltipView;
Note there are references to the withTooltip high order component we will discuss later. Every type used in the tooltip is externalized so we can use the proper signature in any other component we want to create.
This component is the one that you will be able to replace with your own. The component will be automatically placed in the page, however, you would probably give him a max-width
, and the highest element in the tooltip -which is .TooltipWrapper
in my case, should have a position: absolute
CSS property.
Also, the positioning of the tooltip will need a little adjustment so it looks perfectly centered:
- For a top placed toolltip, we move left -50% to be perfectly centered, and top -100% so it will never overlap the content that activates the tooltip.
- For a right placed tooltip, we keep the same right position, but we move -50% top to get the perfect centering.
- For a bottom placed tooltip, we move left -50% to be perfectly centered, but keep the bottom value so the button will not overlap the content that activates the tooltip.
- For a left placed tootip, we move left -100% so our tooltip will not overlap the content, and to by -50% to get the perfect centering.
This is possibile because the transform
property get the element as reference and not the parent element sizing like height and width did.
The rest is very specific of my tooltip implementation but let us get some time to understand what is done.
First of all, the .TooltipWrapper
element has a maximum width of 350px, which will force the tooltip to get a new line if it is became too long. I also have added a 10px padding where I'm going to place the tooltip arrow.
The arrow will be placed and rotated based on the tooltip placement so it points to the content that activates the tooltip. You might want to remove this arrow in your case, since a lot of systems does not require it.
And that it! The tooltip is ready to get integrated in your system with the withTooltip
high order component.
.TooltipWrapper {
max-width: 350px;
padding: 10px;
position: absolute;
}
.Tooltip {
background-color: #2c3e48;
border-radius: 3px;
color: #ffffff;
display: block;
font-family: var(--sans-serif);
font-size: 14px;
font-weight: 500;
letter-spacing: -0.03em;
padding: 8px 16px;
pointer-events: none;
position: relative;
text-align: center;
}
.TooltipArrow {
fill: #2c3e48;
position: absolute;
}
.Tooltip-top {
transform: translate(-50%, -100%);
}
.Tooltip-top .TooltipArrow {
bottom: 0;
left: 50%;
right: auto;
top: auto;
transform: translate(-50%);
}
.Tooltip-right {
transform: translate(0, -50%);
}
.Tooltip-right .TooltipArrow {
top: 50%;
left: 0;
right: auto;
bottom: auto;
transform: rotate(90deg) translateX(-50%);
}
.Tooltip-bottom {
transform: translate(-50%, 0);
}
.Tooltip-bottom .TooltipArrow {
top: 0;
left: 50%;
right: auto;
bottom: auto;
transform: rotate(180deg) translateX(50%);
}
.Tooltip-left {
transform: translate(-100%, -50%);
}
.Tooltip-left .TooltipArrow {
top: 50%;
right: 0;
left: auto;
bottom: auto;
transform: rotate(-90deg) translateX(50%);
}
The withTooltip high-order component
This is the main part of the tooltip system, where all the logic stands. This high-order component exists to:
- Handle mouveover event
- Handle mouseout event
- Link the tooltip to the portal system
- Add a CSS transition
- Expose interfaces to implement a completely custom system
Handle mouseover event
The mousover event should calculate the offset position of the tooltip, based on the overed element: if the tooltip position is on the bottom, we need to add the element height as top offset, and if it's on the right, the element width as left offset.
The left and top values are also initialized at half the size of the container element. This will allow us to have a perfectly centered tooltip by just adding a translateX
or translateY
with a 50%
value.
Finally, we seek for all the left and top offset from the parent because the JavaScript API only give the offset from the parent with a position set to relative or absolute.
const onMouseOverCapture = useCallback(() => {
window.clearTimeout(refTimeout.current);
if (refSpan.current === null) return;
let el: HTMLElement | null = refSpan.current;
let left = el.offsetWidth / 2;
if (position === TooltipPosition.RIGHT) left = el.offsetWidth;
if (position === TooltipPosition.LEFT) left = 0;
let top = el.offsetHeight / 2;
if (position === TooltipPosition.BOTTOM) top = el.offsetHeight;
if (position === TooltipPosition.TOP) top = 0;
while (el) {
left += el.offsetLeft;
top += el.offsetTop;
el = el.offsetParent as HTMLElement | null;
}
setShow([left, top]);
}, [position]);
You will see a refSpan, a timeout and a setShow reference:
- The first one correspond to a wrapper element the mouse over is applied. We just getting sure it exists when the function is executed.
- The second one is a small added timeout before hiding the tooltip. It's reinitialized here to avoid a blink.
- The third is a binary state created to actually show the tooltip when overed and hide it otherwise.
Handle mouseout event
The mouseout event is pretty straightforward: we just reset the position to 0,0. I will use that specific position to hide the tooltip.
I used a 10ms timout on it, which is the best value I found so the system neither look slow nor blinky.
const onMouseOutCapture = useCallback(() => {
refTimeout.current = window.setTimeout(() => setShow([0, 0]), 10);
}, []);
Link the tooltip to the portal system
The linking part is made with a private WithTooltip component inside the High Order Component.
The is first a wrapping <span>
element, where the event listeners are binded. It is also the DOM reference we will be using to get the tooltip position. The CSS applies a display: inline-flex
to avoid centering issues.
The second part is the portal system, including the tooltip. Is is activated if either the left or top position values are set to something else than 0. The tooltip component got the left, top and position props to handle it perfectly in the custom component, as seen in the previous section about build the tooltip component.
The third part is to include the wrapped component with every prop to not disturb the original behavior.
const WithTooltip: React.FC<P & IWithTooltipProps> = ({
position = TooltipPosition.TOP,
...props
}) => {
return (
<span
ref={refSpan}
className={classes.WithTooltip}
onFocusCapture={onMouseOverCapture}
onBlurCapture={onMouseOutCapture}
onMouseOverCapture={onMouseOverCapture}
onMouseOutCapture={onMouseOutCapture}
>
<ModalPortal active={show[0] !== 0 || show[1] !== 0}>
<TooltipComponent left={show[0]} top={show[1]} position={position}>
{props.label}
</TooltipComponent>
</ModalPortal>
<WrappedComponent {...props} />
</span>
);
};