Custom Tooltip component with React and TypeScript (+ Next.js)
Tooltips are an essential part of modern interfaces and most of the popular UI frameworks have such components. However, as is often the case, you might want to create a custom tooltip that is more suitable for your specific needs. I recently came across the react-tooltip
library, which I didn’t like at all. It used some old school techniques, like adding data attributes to the target elements. The library lacked the flexibility I needed, so in less than an hour, I created a custom component, that worked like a charm. Let me show how I created a pretty neat Tooltip component using React. In the end, I also show how to implement it in Next.js.
Initial setup
We will use create-react-app
to setup the project and styled-components
for the styling.
npx create-react-app react-custom-tooltip --template typescript
npm install --save-dev styled-components
When we have an element like a tooltip or a dialog, it’s a good practice to render this element inside a portal. Let’s add the HTML wrapper for our portal. We need to change public/index.html and the body should look like this:
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<div id="portal"></div>
</body>
The next step is to create the skeleton for the Tooltip component and the styling for it. Below the code, you will find more explanation about it.
import React from "react";
import styled from "styled-components";
import { createPortal } from "react-dom";
function Tooltip() {
const portalRef = document.getElementById("portal") as HTMLElement;
return createPortal(
<StyledTooltipContainer>
<div className="content"></div>
</StyledTooltipContainer>,
portalRef
);
}
interface StyledTooltipContainerProps {
readonly top?: number;
readonly left?: number;
}
const StyledTooltipContainer = styled.div<StyledTooltipContainerProps>`
position: absolute;
top: ${({ top }) => top || 0}px;
left: ${({ left }) => left || 0}px;
width: 100px;
height: 50px;
background: #282727;
.content {
position: relative;
width: 100%;
height: 100%;
&::after {
position: absolute;
content: "";
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 8px solid #282727;
bottom: -8px;
left: 0;
}
}
`;
export default Tooltip;
As you can see, we don’t return JSX, but rather we call the function createPortal
with the component as a first argument, and a ref to the portal wrapper as a second argument.
Regarding the styling, here are the most important points:
- The tooltip wrapper is “absolute” positioned and its
top
andleft
values are set dynamically. We want to set these coordinates manually, so that we can display the component everywhere on the page. - The :
:after
element represents the small arrow that shows below the tooltip. I use this hack, in order to avoid using SVG’s for it. Feel free to change the border values.
Context and useTooltip hook
In order to be able to invoke the Tooltip from any part of our app, we need to create a custom hook and wrap our app into Context Provider. Let’s start with the Context:
import React, { createContext, useState } from "react";
type ContextProps = {
show: boolean;
setShow: (value: boolean) => void;
top: number;
setTop: (value: number) => void;
left: number;
setLeft: (value: number) => void;
content: string;
setContent: (value: string) => void;
};
type TooltipProviderProps = {
children: any;
};
export const TooltipContext = createContext<ContextProps>({} as ContextProps);
export function ToolTipProvider({ children }: TooltipProviderProps) {
const [show, setShow] = useState(false);
const [top, setTop] = useState(0);
const [left, setLeft] = useState(0);
const [content, setContent] = useState("");
return (
<TooltipContext.Provider
value={{ show, setShow, top, setTop, left, setLeft, content, setContent }}
>
{children}
</TooltipContext.Provider>
);
}
Unlike my other React tutorials, I don’t use the useReducer
hook here and rely on the useState
hook for the state. Our state is relatively simple, so this is sufficient. If it becomes more complex, I would suggest using useReducer
instead.
Here are the 4 main values of our state:
show
– whether we show the tooltip or nottop
andleft
– the position of the Tooltipcontent
– the text inside the Tooltip
Now, let’s create the useTooltip
custom hook:
import { useCallback, useContext } from "react";
import {TooltipContext} from "../context/TooltipContext";
function useTooltip() {
const {
show,
setShow,
top,
setTop,
left,
setLeft,
content,
setContent
} = useContext(TooltipContext);
const showTooltip = useCallback(
(top: number, left: number, content: string) => {
setTop(top);
setLeft(left);
setShow(true);
setContent(content);
},
[setTop, setLeft, setShow, setContent]
);
const hideTooltip = () => {
setShow(false);
};
return {
showTooltip,
hideTooltip,
isTooltipVisible: show,
top,
left,
content
};
}
export default useTooltip;
Again, the logic is relatively simple. We expose all the values that will allow us to set and extract values from our Tooltip state.
Now that the custom hook is ready, let’s implement it in the Tooltip.tsx
:
function Tooltip() {
const portalRef = document.getElementById("portal") as HTMLElement;
const { isTooltipVisible, top, left } = useTooltip();
return createPortal(
isTooltipVisible ? (
<StyledTooltipContainer top={top} left={left}>
<div className="content"></div>
</StyledTooltipContainer>
) : null,
portalRef
);
}
..and wrap the application in a ToolTipProvider
in index.tsx
:
import { render } from "react-dom";
import ToolTipProvider from "./context/TooltipContext";
import App from "./App";
const rootElement = document.getElementById("root");
render(
<ToolTipProvider>
<App />
</ToolTipProvider>,
rootElement
);
Implementation
Now, let’s put our code in action. Here is the content of App.jsx
:
import React, { useRef, RefObject } from "react";
import "./styles.css";
import useTooltip from "./hooks/useTooltip";
import Tooltip from "./components/Tooltip";
export default function App() {
const { showTooltip } = useTooltip();
const leftBtnRef = useRef<HTMLElement>();
const rightBtnRef = useRef<HTMLElement>();
const handleClick = (elRef: RefObject<HTMLElement>) => {
const { y, x } = elRef.current.getBoundingClientRect();
const elementId = elRef?.current?.id;
showTooltip(y - 60, x, `Tooltip on the ${elementId}`);
};
return (
<div className="App">
<button
ref={leftBtnRef}
id="left"
onClick={() => handleClick(leftBtnRef)}
>
Show the tooltip on the left
</button>
<button
ref={rightBtnRef}
id="right"
onClick={() => handleClick(rightBtnRef)}
>
Show the tooltip on the right
</button>
<Tooltip />
</div>
);
}
In the example above, we use refs
and get the element’s coordinates with getBoundingClientRect()
function. Then, we simply call the showTooltip(
) function from the useTooltip()
hook with the coordinates and the content as arguments.
Here is the code in action:
A few words about Next.js
In Next.js, there is no entry index.html
file and it’s a bit more different to implement a React portal. Instead of the index.html
, you should use the _document.js
file and add the portal wrapper element there:
import Document, { Html, Head, Main, NextScript } from "next/document";
class MainDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps };
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
<div id="portal"></div>
</body>
</Html>
);
}
}
export default MainDocument;
The second tricky part is the call of the createPortal()
function. You should explicitly check if the component is called on the server or the client, and use the simple hack below:
function Tooltip() {
const portalRef = document.getElementById("portal") as HTMLElement;
const { isTooltipVisible, top, left, content } = useTooltip();
const [isBrowser, setIsBrowser] = useState(false);
useEffect(() => {
setIsBrowser(true);
}, []);
if(isBrowser) {
return createPortal(
isTooltipVisible ? (
<StyledTooltipContainer top={top} left={left}>
<div className="content">{content}</div>
</StyledTooltipContainer>
) : null,
portalRef
);
} else {
return null
}
}
There is certainly room for improvement, but this is the most basic implementation of the Tooltip. For example, you can reposition the Tooltip whenever the screen size changes or close it on backdrop click. If you liked this tutorial, you can check other React related tutorials in my blog: