Unleash your creativity
πŸŽƒ Danny 2 weeks ago
it's october y'all. you know what that means. SPOOKY SCARY SKELETONS πŸ•ΊπŸ»
Latest Additions Wish something
πŸ‚ nick 40 minutes ago
ugh can't get myself to design anything lately, so I guess no seasonal themes here this year </3

Custom Tooltips without jQuery

Custom Tooltips without jQuery
Written by nick
17.02.2026

1 Intro

A lot of people still use heavy jQuery plugins for custom tooltips, but you really don't need them anymore. While you can even build simple tooltips with pure CSS, JavaScript gives you full control over how they behave. A tiny bit of script lets your tooltips follow the cursor, prevents them from clipping off the screen edge, and supports keyboard focus, touch taps, and screen readers automatically.

2 Styling the tooltip

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>

3 The JavaScript magic

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>

4 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
});

5 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