Custom Tooltips without jQuery
Note: If you decide to download the files, the script is already separated into its own .js file to keep things clean. However, you can still use the same configuration settings shown in this tutorial to customize how your tooltips look and behave!
A lot of people still use jQuery for custom tooltips, but you can get a cleaner look with just a bit of modern JavaScript. This script is also fully accessible by default. It makes sure screen readers can still find your info and even pops up for keyboard users tabbing through your site. Oh, and it works great with touch too!
1. The CSS
First, we need to style the tooltip. This is what the actual popup looks like. You can put this in your CSS file or inside <style> tags.
The pointer-events: none; line is important because it makes the tooltip "invisible" to your mouse clicks, so it won't get in the way if you are trying to click a link underneath it.
<style>
/* The tooltip box */
.custom-tooltip {
/* Change these to match your site's design */
padding: 8px 12px;
background-color: #333;
color: #fff;
border-radius: 4px;
font-size: 14px;
white-space: nowrap;
/* Essential for the positioning logic */
position: fixed; /* Ensures the tooltip follows your mouse */
z-index: 9999; /* Ensures it stays on top of other content */
pointer-events: none; /* Prevents the tooltip from blocking mouse clicks */
/* Starting state (Hidden) */
opacity: 0;
visibility: hidden;
}
/* This class will be added by JS to show the tooltip */
.custom-tooltip.visible {
opacity: 1;
visibility: visible;
}
</style>
2. The JavaScript
To make this easy to manage, the code is split into two parts: the Configuration at the top and the Core Logic below it.
This setup allows you to change how the tooltips behave just by editing the top section without having to worry about accidentally breaking the script while adjusting your settings (hopefully).
By default, the script is set up to find every element with a title="" attribute. If you are happy with that, you can just paste this entire block at the bottom of your page (right before the closing </body> tag) and it will work immediately. If you want to tweak things, you can learn how to do that in the customization section below.
<script>
// Configuration: Pick your elements and styles here
document.addEventListener('DOMContentLoaded', () => {
initTooltips({
selector: '[title]', // Targets anything with a title attribute
tooltipClass: 'custom-tooltip', // The CSS class used for styling the box
position: 'bottom right', // Options: top, bottom, left, right, center
offset: 10, // Distance from the cursor in pixels
animate: true, // Set to false for instant showing/hiding
allowHTML: false // Set to true or false to allow HTML in tooltips
});
});
// Core Logic: It is best to leave this part as it is
function initTooltips(userConfig = {}) {
// Defaults (DO NOT change these!)
const config = Object.assign({ selector: '[title]', tooltipClass: 'custom-tooltip', position: 'bottom right', offset: 10, animate: true, allowHTML: false }, userConfig);
// Create and add the tooltip element to the page
let tooltip = document.createElement('div');
tooltip.className = config.tooltipClass;
// Assign an ID and role for accessibility linking
tooltip.id = 'df-custom-tooltip';
tooltip.setAttribute('role', 'tooltip');
if (config.animate) {
tooltip.style.transition = 'opacity 0.2s ease, visibility 0.2s';
}
document.body.appendChild(tooltip);
// Helper to set if HTML is allowed within tooltips
const setContent = (content) => {
if (config.allowHTML) {
tooltip.innerHTML = content;
} else {
tooltip.textContent = content;
}
};
// Convert selector to string if it's an array
const selectorString = Array.isArray(config.selector) ?
config.selector.join(',') :
config.selector;
// Find and set up each target element
const elements = document.querySelectorAll(selectorString);
elements.forEach(el => {
const titleText = el.getAttribute('title');
if (!titleText) return;
// Ensure we don't conflict with existing accessibility attributes
const hasAria = el.hasAttribute('aria-label') ||
el.hasAttribute('aria-labelledby') ||
el.hasAttribute('aria-describedby');
// Link the element to the tooltip for screen readers if no label exists
if (!hasAria) {
el.setAttribute('aria-describedby', tooltip.id);
}
// Move title to data-title and remove the native title attribute
el.setAttribute('data-title', titleText);
el.removeAttribute('title');
// Calculate and apply the correct position
const updatePosition = (e) => {
const offset = config.offset;
let left, top;
// If triggered by keyboard (focus), use element bounds instead of pointer position
if (e && e.type === 'focus') {
const rect = el.getBoundingClientRect();
left = rect.left + rect.width / 2;
top = rect.top + rect.height / 2;
} else {
left = e.clientX;
top = e.clientY;
}
// Horizontal alignment
if (config.position.includes('right')) left += offset;
else if (config.position.includes('left')) left -= (tooltip.offsetWidth + offset);
else left -= (tooltip.offsetWidth / 2);
// Vertical alignment
if (config.position.includes('bottom')) top += offset;
else if (config.position.includes('top')) top -= (tooltip.offsetHeight + offset);
else top -= (tooltip.offsetHeight / 2);
// Viewport edge detection
const padding = 5;
const maxX = window.innerWidth - tooltip.offsetWidth - padding;
const maxY = window.innerHeight - tooltip.offsetHeight - padding;
// Prevent the tooltip from leaving the screen
left = Math.max(padding, Math.min(left, maxX));
top = Math.max(padding, Math.min(top, maxY));
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
};
// Show tooltip on keyboard focus
el.addEventListener('focus', (e) => {
setContent(el.getAttribute('data-title'));
tooltip.classList.add('visible');
updatePosition(e);
});
// Hide tooltip when focus is lost
el.addEventListener('blur', () => {
tooltip.classList.remove('visible');
});
// Show tooltip on pointer down (mobile tap) or pointer enter (mouse hover)
el.addEventListener('pointerdown', (e) => {
setContent(el.getAttribute('data-title'));
tooltip.classList.add('visible');
updatePosition(e);
});
el.addEventListener('pointerenter', (e) => {
if (e.pointerType === 'mouse') {
setContent(el.getAttribute('data-title'));
tooltip.classList.add('visible');
}
});
// Follow pointer movement
el.addEventListener('pointermove', (e) => {
updatePosition(e);
});
// Hide tooltip on pointer leave
el.addEventListener('pointerleave', (e) => {
if (e.pointerType === 'mouse') {
tooltip.classList.remove('visible');
}
});
el.addEventListener('click', () => {
tooltip.classList.remove('visible');
});
});
// Hide tooltip on scroll or clicking elsewhere
const hideAll = () => tooltip.classList.remove('visible');
window.addEventListener('scroll', hideAll, { passive: true });
window.addEventListener('wheel', hideAll, { passive: true });
document.addEventListener('pointerdown', (e) => {
const isTrigger = [...elements].some(el => el.contains(e.target));
if (!isTrigger) hideAll();
});
}
</script>
3. Customizing
The configuration section at the top of the script makes it easy to change how the tooltips behave. Here is how to tweak the settings:
Safety first
By default, allowHTML is set to false to keep your site secure. Only turn this on if you fully trust the text inside your tooltips. If you are loading content from a database or a source where users can type their own text, it's best to keep this off to prevent malicious code from running on your site.
Targeting specific elements
The selector tells the script which items on your page should trigger a tooltip. By default, it looks for everything with a title attribute, but you can change this to be as specific as you want.
If you only want to add the tooltips for items within a specific container, e.g. in your #content, you can do it like this:
selector: '#content [title]',
If you only want to show tooltips for icons that have a specific class (like .menu-icon), you would change the selector to match that:
selector: '.menu-icon',
If you want to target multiple different types of elements at once, you can also use a list (an array):
selector: ['.menu-icon', '.help-icon', 'img[title]'],
Adjusting the position
The position setting controls where the tooltip appears relative to your mouse cursor. You can use keywords like top, bottom, left, right, or center. You can also combine them:
'top center': Centers the tooltip directly above the cursor.'bottom right': Places it to the bottom-right (the classic style).'left center': Centers it to the left of the cursor.
Using a custom style
The tooltipClass sets which CSS class will be used to style the tooltip. If you created a different CSS class in the first step and want to use that instead of the default, just update the name here:
tooltipClass: 'my-custom-style',
Example: A custom setup
Here is what your configuration might look like if you wanted to target specific icons, center the tooltip above the mouse, and use a custom style:
initTooltips({
selector: ['.help-button', '.info-link'],
position: 'top center',
tooltipClass: 'blue-tooltip-theme',
offset: 10,
animate: true,
allowHTML: true
});
4. The full code
Here is how everything looks when it is all put together in one file. You can copy this into an empty .html file to test it out.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Simple JS Tooltips</title>
<style>
body { font-family: sans-serif; font-size: 14px; width: 900px; margin: 50px auto;}
a { text-decoration: none; color: #777777; }
/* The tooltip box */
.custom-tooltip {
/* Change these to match your site's design */
padding: 8px 12px;
background-color: #333;
color: #fff;
border-radius: 4px;
font-size: 14px;
white-space: nowrap;
/* Essential for the positioning logic */
position: fixed; /* Ensures the tooltip follows your mouse */
z-index: 9999; /* Ensures it stays on top of other content */
pointer-events: none; /* Prevents the tooltip from blocking mouse clicks */
/* Starting state (Hidden) */
opacity: 0;
visibility: hidden;
}
/* This class will be added by JS to show the tooltip */
.custom-tooltip.visible {
opacity: 1;
visibility: visible;
}
</style>
</head>
<body>
<div class="box">
<h2>Hover over these:</h2>
<p>Check out this <a href="#" title="This is a tooltip!">link with a tooltip</a>.</p>
<p>Even <button title="Works on all other elements as well!">Buttons</button> work perfectly.</p>
</div>
<script>
// Configuration: Pick your elements and styles here
document.addEventListener('DOMContentLoaded', () => {
initTooltips({
selector: '[title]', // Targets anything with a title attribute
tooltipClass: 'custom-tooltip', // The CSS class used for styling the box
position: 'bottom right', // Options: top, bottom, left, right, center
offset: 10, // Distance from the cursor in pixels
animate: true, // Set to false for instant showing/hiding
allowHTML: false // Set to true or false to allow HTML in tooltips
});
});
// Core Logic: It is best to leave this part as it is
function initTooltips(userConfig = {}) {
// Defaults (DO NOT change these!)
const config = Object.assign({ selector: '[title]', tooltipClass: 'custom-tooltip', position: 'bottom right', offset: 10, animate: true, allowHTML: false }, userConfig);
// Create and add the tooltip element to the page
let tooltip = document.createElement('div');
tooltip.className = config.tooltipClass;
// Assign an ID and role for accessibility linking
tooltip.id = 'df-custom-tooltip';
tooltip.setAttribute('role', 'tooltip');
if (config.animate) {
tooltip.style.transition = 'opacity 0.2s ease, visibility 0.2s';
}
document.body.appendChild(tooltip);
// Helper to set if HTML is allowed within tooltips
const setContent = (content) => {
if (config.allowHTML) {
tooltip.innerHTML = content;
} else {
tooltip.textContent = content;
}
};
// Convert selector to string if it's an array
const selectorString = Array.isArray(config.selector) ?
config.selector.join(',') :
config.selector;
// Find and set up each target element
const elements = document.querySelectorAll(selectorString);
elements.forEach(el => {
const titleText = el.getAttribute('title');
if (!titleText) return;
// Ensure we don't conflict with existing accessibility attributes
const hasAria = el.hasAttribute('aria-label') ||
el.hasAttribute('aria-labelledby') ||
el.hasAttribute('aria-describedby');
// Link the element to the tooltip for screen readers if no label exists
if (!hasAria) {
el.setAttribute('aria-describedby', tooltip.id);
}
// Move title to data-title and remove the native title attribute
el.setAttribute('data-title', titleText);
el.removeAttribute('title');
// Calculate and apply the correct position
const updatePosition = (e) => {
const offset = config.offset;
let left, top;
// If triggered by keyboard (focus), use element bounds instead of pointer position
if (e && e.type === 'focus') {
const rect = el.getBoundingClientRect();
left = rect.left + rect.width / 2;
top = rect.top + rect.height / 2;
} else {
left = e.clientX;
top = e.clientY;
}
// Horizontal alignment
if (config.position.includes('right')) left += offset;
else if (config.position.includes('left')) left -= (tooltip.offsetWidth + offset);
else left -= (tooltip.offsetWidth / 2);
// Vertical alignment
if (config.position.includes('bottom')) top += offset;
else if (config.position.includes('top')) top -= (tooltip.offsetHeight + offset);
else top -= (tooltip.offsetHeight / 2);
// Viewport edge detection
const padding = 5;
const maxX = window.innerWidth - tooltip.offsetWidth - padding;
const maxY = window.innerHeight - tooltip.offsetHeight - padding;
// Prevent the tooltip from leaving the screen
left = Math.max(padding, Math.min(left, maxX));
top = Math.max(padding, Math.min(top, maxY));
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
};
// Show tooltip on keyboard focus
el.addEventListener('focus', (e) => {
setContent(el.getAttribute('data-title'));
tooltip.classList.add('visible');
updatePosition(e);
});
// Hide tooltip when focus is lost
el.addEventListener('blur', () => {
tooltip.classList.remove('visible');
});
// Show tooltip on pointer down (mobile tap) or pointer enter (mouse hover)
el.addEventListener('pointerdown', (e) => {
setContent(el.getAttribute('data-title'));
tooltip.classList.add('visible');
updatePosition(e);
});
el.addEventListener('pointerenter', (e) => {
if (e.pointerType === 'mouse') {
setContent(el.getAttribute('data-title'));
tooltip.classList.add('visible');
}
});
// Follow pointer movement
el.addEventListener('pointermove', (e) => {
updatePosition(e);
});
// Hide tooltip on pointer leave
el.addEventListener('pointerleave', (e) => {
if (e.pointerType === 'mouse') {
tooltip.classList.remove('visible');
}
});
el.addEventListener('click', () => {
tooltip.classList.remove('visible');
});
});
// Hide tooltip on scroll or clicking elsewhere
const hideAll = () => tooltip.classList.remove('visible');
window.addEventListener('scroll', hideAll, { passive: true });
window.addEventListener('wheel', hideAll, { passive: true });
document.addEventListener('pointerdown', (e) => {
const isTrigger = [...elements].some(el => el.contains(e.target));
if (!isTrigger) hideAll();
});
}
</script>
</body>
</html>
And you are all set! <3