Tooltip

A tooltip is a popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.

<button 
  class="btn variant-destructive icon-only sz-md" 
  aria-describedby="tooltip-1"
  data-tooltip-position="top"
  data-tooltip="tooltip-1"
  data-tooltip-content="Top tooltip"
  data-tooltip-offset = "4"
>
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
      <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
  </svg>
  <span class="sr-only">Delete</span>
</button>

Installation

1. Add Component’s typescript

To install the Tooltip component, copy and paste the following typescript code into your project.

document.addEventListener('DOMContentLoaded', () => {
  const triggers = document.querySelectorAll('[data-tooltip]') as NodeListOf<HTMLElement>;
  let activeTooltip: HTMLElement | null = null;

  triggers.forEach((trigger) => {
      const tooltipTemplate = document.getElementById('tooltip-template') as HTMLTemplateElement;
      const tooltip = document.importNode(tooltipTemplate.content, true).firstElementChild as HTMLElement;

      const content = trigger.getAttribute('data-tooltip-content');
      const position = trigger.getAttribute('data-tooltip-position') || 'top';
      const id = trigger.getAttribute('aria-describedby');
      const offset = parseInt(trigger.getAttribute('data-tooltip-offset') || '8');

      if (content) tooltip.textContent = content;
      tooltip.setAttribute('id', id as string);
      tooltip.style.position = 'fixed';
      tooltip.style.transform = position === 'top' ? 'translateY(0.5rem)' : position === 'bottom' ? 'translateY(-0.5rem)' : position === 'left' ? 'translateX(0.5rem)' : 'translateX(-0.5rem)';
      document.body.appendChild(tooltip);

      const updateTooltipPosition = () => {
          const triggerRect = trigger.getBoundingClientRect();
          const tooltipRect = tooltip.getBoundingClientRect();

          const positions = {
              top: {
                  top: triggerRect.top - tooltipRect.height - offset,
                  left: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2,
              },
              bottom: {
                  top: triggerRect.bottom + offset,
                  left: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2,
              },
              left: {
                  top: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2,
                  left: triggerRect.left - tooltipRect.width - offset,
              },
              right: {
                  top: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2,
                  left: triggerRect.right + offset,
              },
          };

          const preferredCoords = positions[position as keyof typeof positions];
          const fallbackPosition = getFallbackPosition(position);
          const fallbackCoords = positions[fallbackPosition];

          const finalCoords = isInViewport(preferredCoords, tooltipRect)
              ? preferredCoords
              : fallbackCoords;

          tooltip.style.top = `${finalCoords.top}px`;
          tooltip.style.left = `${finalCoords.left}px`;
      };

      const showTooltip = () => {
          tooltip.setAttribute('data-tooltip-position', position);
          tooltip.style.visibility = 'visible';
          tooltip.style.opacity = '1';
          tooltip.style.transform = 'translateY(0)';
          activeTooltip = tooltip;
          updateTooltipPosition();
          window.addEventListener('scroll', updateTooltipPosition);
          window.addEventListener('resize', updateTooltipPosition);
      };

      const hideTooltip = () => {
          tooltip.style.visibility = 'hidden';
          tooltip.style.opacity = '0';
          tooltip.style.transform = position === 'top' ? 'translateY(0.5rem)' : position === 'bottom' ? 'translateY(-0.5rem)' : position === 'left' ? 'translateX(0.5rem)' : 'translateX(-0.5rem)';
          if (activeTooltip === tooltip) activeTooltip = null;
          window.removeEventListener('scroll', updateTooltipPosition);
          window.removeEventListener('resize', updateTooltipPosition);
      };

      let isHoveringTrigger = false;
      let isHoveringTooltip = false;

      const conditionalHide = () => {
          if (!isHoveringTrigger && !isHoveringTooltip) {
              hideTooltip();
          }
      };

      trigger.addEventListener('mouseenter', () => {
          isHoveringTrigger = true;
          showTooltip();
      });

      trigger.addEventListener('mouseleave', () => {
          isHoveringTrigger = false;
          setTimeout(conditionalHide, 100); // Small delay to handle mouse movement
      });

      tooltip.addEventListener('mouseenter', () => {
          isHoveringTooltip = true;
          showTooltip();
      });

      tooltip.addEventListener('mouseleave', () => {
          isHoveringTooltip = false;
          setTimeout(conditionalHide, 100);
      });

      trigger.addEventListener('focus', showTooltip);
      trigger.addEventListener('blur', hideTooltip);
  });

  document.addEventListener('keydown', (event) => {
      if (event.key === 'Escape' && activeTooltip) {
          activeTooltip.style.visibility = 'hidden';
          activeTooltip.style.opacity = '0';
          activeTooltip = null;
      }
  });

  function getFallbackPosition(position: string) {
      switch (position) {
          case 'top':
              return 'bottom';
          case 'bottom':
              return 'top';
          case 'left':
              return 'right';
          case 'right':
              return 'left';
          default:
              return 'top';
      }
  }

  function isInViewport(coords: { top: number; left: number }, tooltipRect: DOMRect) {
      const { top, left } = coords;
      return (
          top >= 36 &&
          left >= 36 &&
          top + tooltipRect.height <= window.innerHeight &&
          left + tooltipRect.width <= window.innerWidth
      );
  }
});

2. Add Tooltip Template

Copy and paste the following HTML code into your project to create a tooltip template, preferably at the end of the <body> tag.

<template id="tooltip-template">
  <div 
      role="tooltip" 
      data-rounded="large"
      class="card variant-mixed px-3 text-sm py-1.5 z-[100] size-fit fixed transition-[opacity,scale,transform] duration-150 ease-in-out"
  >
  </div>
</template>

Position Bottom

<button 
  class="btn variant-destructive icon-only sz-md" 
  aria-describedby="tooltip-2"
  data-tooltip-position="bottom"
  data-tooltip="tooltip-2"
  data-tooltip-content="Bottom tooltip"
  data-tooltip-offset = "4"
>
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
      <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
  </svg>
  <span class="sr-only">Delete</span>
</button>

Position Left

<button 
  class="btn variant-destructive icon-only sz-md" 
  aria-describedby="tooltip-3"
  data-tooltip-position="left"
  data-tooltip="tooltip-3"
  data-tooltip-content="Left tooltip"
  data-tooltip-offset = "4"
>
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
      <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
  </svg>
  <span class="sr-only">Delete</span>
</button>

Position Right

<button 
  class="btn variant-destructive icon-only sz-md" 
  aria-describedby="tooltip-4"
  data-tooltip-position="right"
  data-tooltip="tooltip-4"
  data-tooltip-content="Right tooltip"
  data-tooltip-offset = "4"
>
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
      <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
  </svg>
  <span class="sr-only">Delete</span>
</button>