In this article, we are going to implement a tooltip in plain JavaScript and CSS. A tooltip is a message which appears when a cursor is positioned over an icon, image, links, or other elements in order to give more information to the user. These are usually non-interactble and not essential in a page.
The first step for building this tooltip will be to create a HTML, CSS and JavaScript file. Alternatively you can follow along by creating a CodePen.
Our base index.html
file looks something like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<link rel="stylesheet" href="./style.css" />
<title>Vanilla JavaScript and CSS Tooltip</title>
</head>
<body>
<main>
<p>
Geckos are a group of usually small, usually nocturnal lizards. They are
found on every continent except Australia.
</p>
<p>
Many species of gecko have adhesive toe pads which enable them to climb
walls and even windows.
</p>
</main>
<script src="./main.js"></script>
</body>
</html>
It includes two paragraphs where we will eventually place some tooltips and links to our stylesheet and main JavaScript file, where will have the tooltip logic.
Whenever developing a component, my first question is which API I would like it to have. My assumption is that a tooltip should be lean (without much boilerplate code), easy to use (and wrap around content) and accessible. Given these conditions we can start with something like:
<span data-tooltip="Hello world!">Australia</span>.
This snippet is not yet accessible (actually there isn't even a tooltip here yet), but we can do that with a bit of JavaScript.
Before we get to it, we can add some styling to the page, so we see the tooltips better:
main {
font-family: Arial, Helvetica, sans-serif;
line-height: 1.5rem;
font-size: 16px;
padding: 5%;
}
[data-tooltip] {
text-decoration: underline;
}
And we can actually add some tooltips, with varying amounts of content. Since it should be possible to use tooltips inside paragraphs we opt for <span>
, which is a valid child of <p>
and displays itself as inline by default, only taking as much space as it's content requires. Using data-tooltip
allow us to store extra information without other hacks such as non-standard attributes, or extra properties on the DOM.
<main>
<p>
Geckos are a group of usually small, usually nocturnal lizards. They are
found on every continent except
<span
data-tooltip="Geckos are a group of usually small, usually nocturnal lizards."
>Australia</span
>.
</p>
<p>
Many species of gecko have
<span data-tooltip="Hi there!">adhesive</span> toe pads which enable them to
climb walls and even windows.
</p>
</main>
So far we only have spans indicating where tooltips should be placed and with which content, so our next step should be to add them to our HTML. This will allow us to properly build the tooltip, but also expose them to search engine robots and make them accessible to screen readers.
const tooltips = document.querySelectorAll("[data-tooltip]");
querySelectorAll
allows us to get all "tooltips" that should be placed in the document using a CSS selector. It gives us back an iterable HTML collection with all the elements found.
tooltips.forEach((trigger) => {
let tooltip = document.createElement("div");
tooltip.setAttribute("role", "tooltip");
tooltip.setAttribute("inert", true);
tooltip.textContent = trigger.dataset.tooltip;
trigger.appendChild(tooltip);
});
We can take the elements and for each of them add an actual accessible tooltip:
role=tooltip
so screen readers know what the content is;inert
because the user should not be able to interact with it (otherwise, it would be a popup);After loading the page our HTML should have the following structure:
<main>
<p>
Geckos are a group of usually small, usually nocturnal lizards. They are
found on every continent except
<span
data-tooltip="Geckos are a group of usually small, usually nocturnal lizards."
>
Australia
<div role="tooltip" inert="true">
Geckos are a group of usually small, usually nocturnal lizards.
</div> </span
>.
</p>
<p>
Many species of gecko have
<span data-tooltip="Hi there!">
adhesive
<div role="tooltip" inert="true">Hi there!</div>
</span>
toe pads which enable them to climb walls and even windows.
</p>
</main>
In order to be able to see the tooltip in our page we can start by applying some width, height and positioning. The width: auto
allows the tooltip to grow in size as it wants, while never exceeding the size of it's parent (this takes into account margins and padding). We however set max-width: 20%
to ensure that a tooltip placed in the middle of the container doesn't become too large.
In terms of height, it's also set to height: auto
so the tooltip can grow in height in case there is too much content. word-wrap
is also left at it's default value (normal
) so text breaks down into multiple lines. min-height: 25px
ensures the tooltip always has an appropriate height, and line-height
of the same size ensures a single line is displayed aligned in the middle. font-size: 1rem
applies 1x the font size set in the root element of the page, which is good for accessibility because this is affected by what the font-size the user sets in their browser.
We apply a small margin at the top, so the tooltip is not too close to the content and padding as well, so there is space between the borders of the tooltip and the content. Most tooltips have a reduced opacity but only for the background, because the text should still have a good contrast. For this we can use a rgba
value (red, green, blue, alpha), where the last value indicates the opacity of the color. This allows us to only apply opacity to the background and not the content. Lastly border-radius
applies rounded corners to the element equally.
[role="tooltip"] {
width: auto;
max-width: 20%;
height: auto;
min-height: 25px;
line-height: 25px;
font-size: 1rem;
background-color: rgba(0, 0, 0, 0.7);
color: #ffffff;
border-radius: 5px;
margin-top: 10px;
padding: 10px 15px;
}
Note: Right now the tooltip is always displayed, we will handle the displaying and hiding in a further section.
Now that we have some basic styles we can actually position the tooltip correctly. Most of the times the desired position is in the middle of the hovered element, so that's what we are going to do.
First we attach an event handler to detect when the mouse enters an element that should display a tooltip. The mouseenter
event is used because it's only triggered when the mouse pointer enters the div element, while the mouseover
event is triggered when the mouse pointer enters the div element or its child elements (which would include the tooltip itself).
Once the event is triggered we try to find the tooltip nested inside using a querySelector
again but now for a single element. getBoundingClientRect
returns x
, y
referent to the position of the trigger element (e.g. a word) in the viewport and width
and height
of the trigger element.
Using this information and by also applying position: absolute
in it's CSS, we firstly position the element at the start of the word. By summing half of the width of the word, we then position the start of the tooltip at the middle of the word. Because we actually want the tooltip to start before the middle, so their middles' coincide we apply a transformation of transform: translateX(-50%)
. The schema below should help you understand the positioning better.
const displayTooltip = (e) => {
const trigger = e.target;
const tooltip = trigger.querySelector("[role=tooltip]");
const { x, y, width, height } = trigger.getBoundingClientRect();
tooltip.style.left = `${Math.floor(x + width / 2)}px`;
tooltip.style.top = `${Math.floor(y + height)}px`;
};
tooltips.forEach((trigger) => {
...
trigger.appendChild(tooltip);
trigger.addEventListener("mouseenter", displayTooltip);
});
[role="tooltip"] {
...
position: absolute;
transform: translateX(-50%);
}
Until now our tooltips are always displayed, and the mouseenter
event is just applying the correct positioning. For tooltips the desired behavior is that they are hidden by default and only displayed after the user has spent some time with their mouse over the element. Tooltips also usually have a fade-in effect so they are more subtle in their appearance. In order to implement this the best property to manipulate is the opacity
, while keeping the other display properties intact.
We will start by implementing the fade-in effect. The following CSS hides the tooltips by default, while keeping them present in the layout (so there are no jumps between elements on hover):
[role="tooltip"] {
opacity: 0;
}
The transition property here indicates that whenever the opacity
property changes it should be increased following a ease
function (which is the default and specifies a transition effect with a slow start, then fast and then slow again). This animation takes place for 0.1s
and gives an effect of fade-in to the tooltip.
[role="tooltip"].active {
opacity: 1;
transition: opacity 0.1s;
}
As you saw above, the opacity is only applied when the tooltip is considered active, so we need to add this class to our tooltip when it's hovered to actually apply the CSS:
const displayTooltip = (e) => {
const trigger = e.target;
const tooltip = trigger.querySelector("[role=tooltip]");
...
tooltip.classList.add("active");
};
const hideTooltip = (e) => {
const tooltip = e.target.querySelector("[role=tooltip]");
tooltip.classList.remove("active");
};
tooltips.forEach((trigger) => {
...
trigger.appendChild(tooltip);
trigger.addEventListener("mouseenter", displayTooltip);
trigger.addEventListener("mouseleave", hideTooltip);
});
Now that the tooltips have a fade-in effect we want to change them so they are only displayed after the user has spent a certain amount with their mouse over the element. A good amount of time I have seen for these animations is usually 300ms
but feel free to use any other you prefer.
Here we can opt for creating an anonymous function that wraps the displayTooltip
and delays it's call. This allows us to keep the logic for displaying the tooltip decoupled from the fact we want to wait some time before displaying it.
const DELAY = 300;
let tooltipTimer = null;
tooltips.forEach((trigger) => {
...
trigger.addEventListener("mouseenter", (e) => {
clearTimeout(tooltipTimer);
tooltipTimer = setTimeout(() => {
displayTooltip(e);
}, DELAY);
});
trigger.addEventListener("mouseleave", (e) => {
clearTimeout(tooltipTimer);
hideTooltip(e);
});
});
An important detail in this implementation is the cancellation of any previous display of a tooltip that might have been triggered. The reasoning is that we only want to display one tooltip at a time, so any previous tooltip scheduled animations should be cancelled.
Regarding the mouseleave
we also cancel the animation in case the user has left the element (i.e. word) before the 300ms
. Forgetting to do this usually leads to memory leaks and dangling events in your application (which can be tricky in your test suites).
As a nice extra we can add a nice triangular tip to our tooltip (no pun intended)! To do this we can add a pseudo element to our tooltip that has a triangle shape. Let's start by adding a pesudo element to the tooltip and positioning it:
[role="tooltip"]::before {
content: "";
position: absolute;
margin-top: -20px;
transform: translateX(-50%);
left: 50%;
}
Again we position the element absolutely, so it's positioned relative to its first positioned ancestor element, i.e. the tooltip. We apply margin-top: -20px
because we want to display it at the top of the tooltip, and 20px
is the padding of the tooltip plus the "height" of the triangle (it will also be 10px
).
In order to center the triangle in the middle of the tooltip, first we make it's start be at the middle of the tooltip by using left: 50%
. And after we apply translateX(-50%)
to push it's start a little bit before so the middles coincide.
This works because the left
property is based on the size of the parent element, i.e. the tooltip and the transform
property is based on the size of the target element, i.e. the triangle,
[role="tooltip"]::before {
...
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid rgba(0, 0, 0, 0.7);
}
In order to form the actual triangle, we use an old trick where by setting no width and height and just the bottom border we get a triangle. The actual width and height of the arrow is determined by the width of the border. For a great explanation on this check this article on CSS tricks.
See the Pen Animation to Explain CSS Triangles by Chris Coyier (@chriscoyier) on CodePen.
See the Pen JavaScript & CSS Tooltip by Diogo Redin (@diogoredin) on CodePen.
Some of the solutions here presented come from an amazing tutorial from the Chrome development team on how to develop a tooltip. The video includes some extra ideas on how to improve the accessibility of tooltips so I recommend you watch it: