Danny 🎠 6 days ago
I LOVE THE NEW DESIGN SO MUCH AAAAAAAAAAH
nick 🤞 6 days ago
the new designs are finally here! still a few rough edges here and there, but we'll smooth them out. if you spot any bugs, tell us <3

Custom Tooltips without jQuery

Written by nick • 17.02.2026

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