Published on

The Ultimate Guide to Building a Simple Tooltip on Hover with HTML, CSS, and JavaScript

Authors

'The Ultimate Guide to Building a Simple Tooltip on Hover with HTML, CSS, and JavaScript'

Learn how to create beautiful, responsive, and accessible tooltips from scratch. This comprehensive guide covers everything from a CSS-only approach to advanced JavaScript positioning and accessibility best practices.

Table of Contents

The Ultimate Guide to Building a Simple Tooltip on Hover with HTML, CSS, and JavaScript

Tooltips are unsung heroes of user interface design. They're the quiet assistants that provide context, clarify icons, and offer extra information without cluttering the screen. When done right, they feel like a natural, intuitive part of the user experience. But when done wrong, they can be annoying, obstructive, and downright inaccessible.

In this comprehensive guide, we'll embark on a journey to build the perfect tooltip. We'll start with a simple, elegant solution using only HTML and CSS. Then, we'll level up with JavaScript to make our tooltips smarter, more responsive, and accessible to all users.

Whether you're a beginner looking to sharpen your front-end skills or a seasoned developer seeking a refresher on best practices, you're in the right place. Let's get building!

What We'll Cover:

  • The HTML Foundation: Structuring our tooltip for success.
  • The CSS-Only Approach: Styling a beautiful tooltip with a classic triangle pointer.
  • Handling Multiple Positions: Creating modifier classes for top, right, bottom, and left tooltips.
  • The JavaScript Enhancement: Dynamically positioning tooltips to avoid viewport collisions.
  • Accessibility (A11y) is Key: Making our tooltips usable for everyone.
  • The Final Code: A complete, production-ready solution.

Section 1: The Foundation - A Solid HTML Structure

Before we can style anything, we need a clean, semantic HTML structure. The core idea is to have a trigger element (the thing you hover over) and the tooltip content itself. A common and effective pattern is to use data-* attributes to hold the tooltip text. This keeps our content and structure neatly separated.

Here’s a simple and robust structure:

<p>
  Hover over this 
  <span class="tooltip-container" data-tooltip="I am a tooltip!">
    magic word
  </span> 
  to see the tooltip.
</p>

<p>
  You can also add tooltips to buttons:
  <button class="tooltip-container" data-tooltip="Click me to do something amazing.">
    Hover Over Me
  </button>
</p>

Why this structure?

  1. tooltip-container: This class identifies the elements that will trigger a tooltip. We'll use this as our hook for both CSS and JavaScript.
  2. data-tooltip: This custom data attribute is the perfect place to store our tooltip's text. It's semantic, easy to access with CSS (attr()) and JavaScript (dataset), and doesn't interfere with styling or behavior.
  3. Flexibility: This works on inline elements like <span> and block-level elements like <button> or <div>.

At this stage, nothing will happen visually. We've just laid the groundwork. Now, let's bring it to life with CSS.

Section 2: The Visuals - The CSS-Only Approach

For many simple use cases, a CSS-only tooltip is all you need. It's lightweight, fast, and doesn't require any JavaScript. The magic lies in using pseudo-elements (::before and ::after) and the :hover pseudo-class.

Let's break down the CSS. First, we'll set up the container and the basic tooltip styles.

Step 1: Positioning the Container

We need to establish a positioning context for the tooltip. By setting the container to position: relative, we can position the tooltip absolutely relative to it.

.tooltip-container {
  position: relative;
  cursor: pointer; /* Indicates to the user that it's interactive */
}

Step 2: Creating the Tooltip with a Pseudo-Element

We'll use the ::before pseudo-element for the tooltip's body and ::after for its triangular arrow. We'll grab the text directly from our data-tooltip attribute using the attr() CSS function.

/* Tooltip Body */
.tooltip-container::before {
  content: attr(data-tooltip); /* Get the text from the data-attribute */
  position: absolute;
  bottom: 100%; /* Position it above the container */
  left: 50%;
  transform: translateX(-50%); /* Center the tooltip horizontally */
  margin-bottom: 10px; /* Space between the element and the tooltip */

  /* Styling */
  background-color: #333;
  color: #fff;
  padding: 8px 12px;
  border-radius: 5px;
  font-size: 14px;
  white-space: nowrap; /* Prevent the tooltip from breaking into multiple lines */

  /* Hide it by default */
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s ease, visibility 0.3s ease;
  z-index: 10;
}

Key Concepts Here:

  • content: attr(data-tooltip): This is the magic trick that pulls the text from our HTML into the CSS.
  • position: absolute: This takes the tooltip out of the normal document flow and allows us to position it precisely relative to the tooltip-container.
  • bottom: 100%: This places the bottom edge of the tooltip at the top edge of the container.
  • left: 50%; transform: translateX(-50%);: This is a classic CSS technique for perfect horizontal centering of an absolutely positioned element.
  • opacity: 0; visibility: hidden;: This is the standard way to hide an element accessibly. opacity: 0 makes it invisible, while visibility: hidden removes it from screen readers and prevents interaction. We'll toggle these on hover.
  • transition: This ensures a smooth fade-in/fade-out effect instead of an abrupt appearance.

Step 3: Creating the Triangle Arrow

No tooltip is complete without that little pointer arrow. We'll create this using the ::after pseudo-element and a clever border trick.

/* Tooltip Arrow */
.tooltip-container::after {
  content: '';
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-bottom: 4px; /* Position it just below the tooltip body */

  /* The triangle trick */
  border-width: 6px;
  border-style: solid;
  border-color: #333 transparent transparent transparent;

  /* Hide it by default */
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s ease, visibility 0.3s ease;
  z-index: 10;
}

The triangle is formed by giving an empty element a thick border and then making three of the four border colors transparent. The remaining visible border forms the triangle.

Step 4: Showing the Tooltip on Hover

Finally, we use the :hover pseudo-class on our container to reveal the tooltip and its arrow.

.tooltip-container:hover::before,
.tooltip-container:hover::after {
  opacity: 1;
  visibility: visible;
}

And just like that, you have a fully functional, stylish, CSS-only tooltip!

Section 3: Handling Different Positions

A tooltip that only appears on top isn't very flexible. What if our element is at the top of the page? The tooltip would be cut off. Let's create modifier classes to control the position.

We'll define classes like tooltip-top, tooltip-right, tooltip-bottom, and tooltip-left. Here's the HTML:

<span 
  class="tooltip-container tooltip-right" 
  data-tooltip="I'm on the right!"
>
  Right Tooltip
</span>

Now for the CSS. We'll set up the default to be tooltip-top and then override the properties for the other positions.

/* Default is Top */
.tooltip-container::before { ... } /* from before */
.tooltip-container::after { ... } /* from before */

/* -- Position Right -- */
.tooltip-container.tooltip-right::before {
  bottom: auto;
  top: 50%;
  left: 100%;
  transform: translateY(-50%);
  margin-bottom: 0;
  margin-left: 10px;
}

.tooltip-container.tooltip-right::after {
  bottom: auto;
  top: 50%;
  left: 100%;
  transform: translateY(-50%);
  margin-bottom: 0;
  margin-left: 4px;
  border-color: transparent #333 transparent transparent;
}

/* -- Position Bottom -- */
.tooltip-container.tooltip-bottom::before {
  top: 100%;
  bottom: auto;
  margin-bottom: 0;
  margin-top: 10px;
}

.tooltip-container.tooltip-bottom::after {
  top: 100%;
  bottom: auto;
  margin-bottom: 0;
  margin-top: 4px;
  border-color: transparent transparent #333 transparent;
}

/* -- Position Left -- */
.tooltip-container.tooltip-left::before {
  bottom: auto;
  top: 50%;
  right: 100%;
  left: auto;
  transform: translateY(-50%);
  margin-bottom: 0;
  margin-right: 10px;
}

.tooltip-container.tooltip-left::after {
  bottom: auto;
  top: 50%;
  right: 100%;
  left: auto;
  transform: translateY(-50%);
  margin-bottom: 0;
  margin-right: 4px;
  border-color: transparent transparent transparent #333;
}

This is powerful, but it has a major weakness. We, the developers, have to manually decide the best position for the tooltip. If the user resizes their browser or uses a mobile device, our tooltip-right might suddenly be pushed off the screen. This is where JavaScript comes to the rescue.

Section 4: Leveling Up with JavaScript for Dynamic Positioning

The biggest limitation of the CSS-only approach is its lack of awareness of the viewport. A truly robust tooltip should be smart enough to reposition itself to stay visible.

Our goal with JavaScript is to:

  1. Dynamically create the tooltip element instead of relying on pseudo-elements.
  2. Listen for mouse events.
  3. On hover, calculate the tooltip's position.
  4. Check if it will overflow the viewport.
  5. If it overflows, reposition it automatically.

Step 1: Modifying the HTML and CSS

Since JavaScript will now handle the creation and positioning, we can simplify our CSS. We no longer need the pseudo-elements. Instead, we'll create a real <span> element for the tooltip.

Our HTML remains the same:

<span class="tooltip-container" data-tooltip="I am a smart tooltip!">Hover me</span>

Our new, simpler CSS:

.tooltip-container {
  position: relative;
  cursor: pointer;
}

.tooltip {
  position: absolute;
  background-color: #333;
  color: #fff;
  padding: 8px 12px;
  border-radius: 5px;
  font-size: 14px;
  white-space: nowrap;
  z-index: 10;
  
  /* Hidden by default */
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s ease;
}

.tooltip.active {
  opacity: 1;
  visibility: visible;
}

/* We can still use a pseudo-element for the arrow! */
.tooltip::after {
  content: '';
  position: absolute;
  border-width: 6px;
  border-style: solid;
}

/* Arrow positions based on data-attribute set by JS */
.tooltip[data-position='top']::after {
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border-color: #333 transparent transparent transparent;
}

.tooltip[data-position='bottom']::after {
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  border-color: transparent transparent #333 transparent;
}

/* ... add right and left arrow styles as well */

Notice we're now using a .tooltip class and an .active class to control visibility. This gives JavaScript full control.

Step 2: The JavaScript Logic

Now for the main event. We'll write a script that initializes all tooltips on the page.

document.addEventListener('DOMContentLoaded', () => {
  const tooltipTriggers = document.querySelectorAll('.tooltip-container');

  tooltipTriggers.forEach(trigger => {
    let tooltip = null; // To hold the tooltip element

    trigger.addEventListener('mouseenter', () => {
      const tooltipText = trigger.dataset.tooltip;
      if (!tooltipText) return; // Don't do anything if there's no tooltip text

      // 1. Create the tooltip element
      tooltip = document.createElement('span');
      tooltip.className = 'tooltip';
      tooltip.textContent = tooltipText;
      document.body.appendChild(tooltip);

      // 2. Position the tooltip
      positionTooltip(trigger, tooltip);

      // 3. Show the tooltip
      // We use a tiny timeout to allow the browser to paint the tooltip 
      // before we apply the transition class.
      setTimeout(() => {
        tooltip.classList.add('active');
      }, 10);
    });

    trigger.addEventListener('mouseleave', () => {
      if (tooltip) {
        tooltip.classList.remove('active');
        // Remove the tooltip from the DOM after the transition ends
        tooltip.addEventListener('transitionend', () => {
          if (tooltip) {
            tooltip.remove();
            tooltip = null;
          }
        });
      }
    });
  });
});

function positionTooltip(trigger, tooltip) {
  const triggerRect = trigger.getBoundingClientRect();
  const tooltipRect = tooltip.getBoundingClientRect();
  const viewportWidth = window.innerWidth;
  const viewportHeight = window.innerHeight;
  const gap = 10; // The space between the trigger and the tooltip

  let top, left;
  let position = 'top'; // Default position

  // Try positioning on top
  top = triggerRect.top - tooltipRect.height - gap;
  left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2);

  // Check if it overflows the top
  if (top < 0) {
    position = 'bottom';
    top = triggerRect.bottom + gap;
  }

  // Check for left/right overflow
  if (left < 0) {
    left = gap;
  }
  if (left + tooltipRect.width > viewportWidth) {
    left = viewportWidth - tooltipRect.width - gap;
  }
  
  tooltip.style.top = `${top + window.scrollY}px`;
  tooltip.style.left = `${left + window.scrollX}px`;
  tooltip.dataset.position = position;
}

What's happening in this script?

  1. We find all .tooltip-container elements.
  2. On mouseenter, we create a <span> with the tooltip text and append it to the document.body. Appending to the body prevents positioning issues with parent elements that have overflow: hidden.
  3. The positionTooltip function is the core of our logic. It uses getBoundingClientRect() to get the dimensions and location of the trigger and the newly created tooltip.
  4. It calculates the ideal 'top' position and then checks if that position is outside the viewport (top < 0).
  5. If it overflows, it flips the position to 'bottom'.
  6. It also performs checks for horizontal overflow, ensuring the tooltip never goes off the left or right side of the screen.
  7. Finally, it applies the calculated top and left styles. We add window.scrollY and window.scrollX to account for page scrolling.
  8. On mouseleave, we remove the .active class to fade it out and then remove the element from the DOM entirely once the transition finishes.

This is a huge improvement! Our tooltips are now context-aware and much more user-friendly.

Section 5: Don't Forget Accessibility (A11y)

A tooltip that only works on hover is inaccessible to keyboard-only users and can be confusing for screen reader users. This is a critical step that turns a good tooltip into a great one.

WAI-ARIA to the Rescue

The Web Accessibility Initiative’s Accessible Rich Internet Applications (WAI-ARIA) spec gives us the tools we need.

  1. role="tooltip": We'll add this to our tooltip element to tell screen readers what it is.
  2. aria-describedby: We'll add this to the trigger element. It points to the ID of the tooltip, creating a programmatic link between them. This tells screen readers, "The description for this element can be found in that other element."

Keyboard Support

We need to show the tooltip when the trigger element receives focus (e.g., via the Tab key) and hide it when it loses focus.

We'll use the focusin and focusout events. These are better than focus and blur because they bubble, which makes event delegation simpler.

Updated JavaScript with A11y

Let's integrate these improvements into our script.

// ... (keep the positionTooltip function as is) ...

document.addEventListener('DOMContentLoaded', () => {
  const tooltipTriggers = document.querySelectorAll('.tooltip-container');

  tooltipTriggers.forEach((trigger, index) => {
    const tooltipId = `tooltip-${index}`;
    trigger.setAttribute('aria-describedby', tooltipId);
    let tooltip = null;

    const showTooltip = () => {
      const tooltipText = trigger.dataset.tooltip;
      if (!tooltipText) return;

      tooltip = document.createElement('span');
      tooltip.id = tooltipId;
      tooltip.className = 'tooltip';
      tooltip.setAttribute('role', 'tooltip');
      tooltip.textContent = tooltipText;
      document.body.appendChild(tooltip);

      positionTooltip(trigger, tooltip);

      setTimeout(() => {
        tooltip.classList.add('active');
      }, 10);
    };

    const hideTooltip = () => {
      if (tooltip) {
        tooltip.classList.remove('active');
        tooltip.addEventListener('transitionend', () => {
          tooltip.remove();
          tooltip = null;
        }, { once: true }); // Ensure the event listener is removed after firing
      }
    };

    // Use focusin/focusout for keyboard and mouseenter/mouseleave for mouse
    trigger.addEventListener('mouseenter', showTooltip);
    trigger.addEventListener('focusin', showTooltip);
    trigger.addEventListener('mouseleave', hideTooltip);
    trigger.addEventListener('focusout', hideTooltip);

    // Bonus: Hide tooltip on 'Escape' key press
    trigger.addEventListener('keydown', (event) => {
        if (event.key === 'Escape') {
            hideTooltip();
        }
    });
  });
});

A11y Enhancements:

  • We now dynamically generate a unique id for each tooltip and set aria-describedby on the trigger to point to it.
  • The tooltip element gets role="tooltip".
  • We've abstracted the showTooltip and hideTooltip logic into their own functions.
  • We now listen for focusin and focusout in addition to mouseenter and mouseleave, providing full support for both mouse and keyboard users.
  • As a nice UX touch, pressing the Escape key while focused on the trigger will now also dismiss the tooltip.

Conclusion: Putting It All Together

We've come a long way! We started with a simple HTML structure, built a stylish CSS-only tooltip, and then supercharged it with JavaScript for dynamic positioning and robust accessibility.

You now have a powerful, reusable, and professional-grade tooltip solution that you can drop into any project. The final result is a component that not only looks good but also provides a seamless experience for all users, regardless of how they browse the web.

Remember, the best UI components are the ones that feel helpful but never get in the way. By combining thoughtful CSS, smart JavaScript, and a commitment to accessibility, you can create tooltips that truly enhance your user's journey.

Happy coding!