Custom external tooltip in ChartJS (React, TypeScript, ChakraUI)
Last updated on
Contents
Problem
My application is in React and TypeScript. I am using ChartJS to draw line charts and I want to completely customize the tooltips displayed on my chart.
Requirements
- ChartJS
- React
- react-chartjs-2
A line chart with tooltip enabled
Here is an example of a line chart with tooltips enabled. We will build on it to implement an external tooltip.
import {
CategoryScale,
Chart,
LineElement,
LinearScale,
PointElement,
Tooltip,
} from "chart.js";
import { Line } from "react-chartjs-2";
Chart.register([
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
]);
const App = () => <LineChart />;
const LineChart = () => {
return (
<Line
data={FAKE_DATA}
options={{
interaction: {
intersect: false,
mode: "index",
},
plugins: {
tooltip: {
enabled: true,
},
},
}}
/>
);
};
Adding the tooltip
We want to use the external
1 property on the tooltip plugin. We will be checking the tooltip opacity to show and hide our custom tooltip.
const LineChart = () => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false)
const closeTooltip = () => { setIsTooltipOpen(false) }
const openTooltip = () => { setIsTooltipOpen(true) }
return (
<Line
data={FAKE_DATA}
options={{
interaction: {
intersect: false,
mode: "index",
},
plugins: {
tooltip: {
enabled: false,
external: ({ tooltip }) => {
if (tooltip.opacity === 0 && isTooltipOpen) {
closeTooltip()
return
}
openTooltip()
}
},
},
}}
/>
{isTooltipOpen && <CustomTooltip />}
)
}
Now that the tooltip is displayed, we only need to pass it data.
First, we explicit the type of the data:
import { TooltipItem } from "chart.js";
type TooltipData = {
dataPoints: TooltipItem<"line">[];
};
Then, we can update the data and pass it to our custom tooltip:
const LineChart = () => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const [data, setData] = useState<TooltipData>({ dataPoints: [] });
const resetData = () => {
setData({ dataPoints: [] });
};
const closeTooltip = () => {
setIsTooltipOpen(false);
};
const openTooltip = () => {
setIsTooltipOpen(true);
};
return (
<Box>
<Line
data={FAKE_DATA}
options={{
interaction: {
intersect: false,
mode: "index",
},
plugins: {
tooltip: {
enabled: false,
external: ({ tooltip }) => {
if (tooltip.opacity === 0 && isTooltipOpen) {
resetData();
closeTooltip();
return;
}
const newData = {
dataPoints: tooltip.dataPoints,
};
setData(newData);
openTooltip();
},
},
},
}}
/>
{isTooltipOpen && <CustomTooltip {...data} />}
</Box>
);
};
The custom tooltip displays a box of color per data point next to their values.
const CustomTooltip = (data: TooltipData) => (
<Tooltip
isOpen={true}
label={
<Box>
{data.dataPoints.map((point) => (
<Flex gridGap="1rem" key={`${point.datasetIndex}-${point.dataIndex}`}>
<Box
backgroundColor={point.dataset.borderColor as string}
boxSize="1rem"
/>
{point.formattedValue}
</Flex>
))}
</Box>
}
hasArrow
placement="right"
>
<Box boxSize="1rem" />
</Tooltip>
);
And here it is! The tooltip displays when hovering the graph.
Placing the tooltip on the chart
Right now, the tooltip is under the chart. Ideally, we want it to be on the chart where the tooltip is supposed to be.
For that, our custom tooltip will be in position: absolute
and its parent will be position: relative
.
const LineChart = () => {
...
return (
<Box position="relative">
<Line
...
/>
{isTooltipOpen && <CustomTooltip {...data} />}
</Box>
)
}
const CustomTooltip = (data: TooltipData) => (
<Tooltip
...
>
<Box boxSize="1rem" position="absolute" top="35%" left={data.left} />
</Tooltip>
);
Previously, we set top: 35%
on CustomTooltip
and left
properties. left
depends on the tooltip position.
const LineChart = () => {
...
return (
<Box position="relative">
<Line
data={FAKE_DATA}
options={{
interaction: {
intersect: false,
mode: "index",
},
plugins: {
tooltip: {
enabled: false,
external: ({ tooltip }) => {
if (tooltip.opacity === 0 && isTooltipOpen) {
resetData()
closeTooltip()
return
}
const newData = {
dataPoints: tooltip.dataPoints,
left: tooltip.caretX // use caretX value instead of x
}
setData(newData)
openTooltip()
}
},
},
}}
/>
{isTooltipOpen && <CustomTooltip {...data} />}
</Box>
)
}
TooltipData
type is modified accordingly.
type TooltipData = {
dataPoints: TooltipItem<"line">[];
left: number;
};
Optimize rendering
It works well. All that is left is improving the render.
Right now, it’s rerendering way too much due to how the external
callback works. A quick way to check that is to add a simple state to count how many times the external
callback is called.
...
external: ({ tooltip }) => {
if (tooltip.opacity === 0 && isTooltipOpen) {
resetData()
closeTooltip()
return
}
const newData = {
dataPoints: tooltip.dataPoints,
left: tooltip.caretX
}
setData(newData)
openTooltip()
increaseCounter()
}
...
An easy way to fix that is to update the data only when the new data is different from the previous one.
In this case, we only check if data.left
is different from newData.left
but sometimes you need to compare the rest of the data for a better comparison.
const arePositionsDifferent = (d1: TooltipData, d2: TooltipData) =>
d1.left !== d2.left;
...
external: ({ tooltip }) => {
if (tooltip.opacity === 0 && isTooltipOpen) {
resetData()
closeTooltip()
return
}
const newData = {
dataPoints: tooltip.dataPoints,
left: tooltip.caretX
}
if (arePositionsDifferent(data, newData)) {
setData(newData)
openTooltip()
increaseCounter()
}
}
...
Here is the result:
Final code
Here is the final code resulting from this post.
FAKE_DATA
can be found in this repository.
import { Box, Flex, Tooltip } from "@chakra-ui/react";
import {
CategoryScale,
Chart,
LineElement,
LinearScale,
PointElement,
Tooltip as ChartTooltip,
TooltipItem,
} from "chart.js";
import { useState } from "react";
import { Line } from "react-chartjs-2";
import { FAKE_DATA } from "./utils";
Chart.register([
CategoryScale,
LinearScale,
PointElement,
LineElement,
ChartTooltip,
]);
export const App = () => <LineChart />;
type TooltipData = {
dataPoints: TooltipItem<"line">[];
left: number;
};
const LineChart = () => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const [data, setData] = useState<TooltipData>({ dataPoints: [], left: -1 });
const resetData = () => {
setData({ dataPoints: [], left: -1 });
};
const closeTooltip = () => {
setIsTooltipOpen(false);
};
const openTooltip = () => {
setIsTooltipOpen(true);
};
return (
<Box position="relative">
<Line
data={FAKE_DATA}
options={{
interaction: {
intersect: false,
mode: "index",
},
plugins: {
tooltip: {
enabled: false,
external: ({ tooltip }) => {
if (tooltip.opacity === 0 && isTooltipOpen) {
resetData();
closeTooltip();
return;
}
const newData = {
dataPoints: tooltip.dataPoints,
left: tooltip.caretX,
};
if (arePositionsDifferent(data, newData)) {
setData(newData);
openTooltip();
}
},
},
},
}}
/>
{isTooltipOpen && <CustomTooltip {...data} />}
</Box>
);
};
const arePositionsDifferent = (d1: TooltipData, d2: TooltipData) =>
d1.left !== d2.left;
const CustomTooltip = (data: TooltipData) => (
<Tooltip
isOpen={true}
label={
<Box>
{data.dataPoints.map((point) => (
<Flex gridGap="1rem" key={`${point.datasetIndex}-${point.dataIndex}`}>
<Box
backgroundColor={point.dataset.borderColor as string}
boxSize="1rem"
/>
{point.formattedValue}
</Flex>
))}
</Box>
}
hasArrow
placement="right"
>
<Box boxSize="1rem" position="absolute" top="35%" left={data.left} />
</Tooltip>
);
You can also find an example in this repository.
Documentation & tutorials
If you want to learn more about ChartJS, I recommend checking out:
- The documentation of ChartJS. It is a great documentation, well written and mostly complete.
- @ChartJS-tutorials on YouTube. The videos are well done and cover a lot of subjects.