A glimpse of the customizable tooltip system in action, showcased in this CodePen demo, designed to provide ready-to-use code for you.

Have you ever found yourself frustrated with native HTML tooltips? That moment when you hover over an element, wait what feels like an eternity for the tooltip to appear, only to be greeted by an unstyled, small text box that barely fits your content? You're not alone.

As a UX engineer, I've found that tooltips - while crucial for progressive disclosure and keeping interfaces clean, often fall short in modern web applications. The native title attribute tooltips excel at accessibility but struggle to deliver the smooth, engaging experience users expect today.

Why native tooltips just don't cut it anymore

Let's face it - displaying additional information on hover shouldn't be this hard. Native tooltips have served us well, but they come with limitations that impact user experience:

  • That annoying delay before they appear
  • No control over styling or positioning
  • Zero animation capabilities
  • Awkward behavior on touch devices
  • Disappear the moment you try to move your mouse to interact with them

Sure, we could turn to existing tooltip libraries, but they often come with their own baggage: Framework lock-in (React, Vue, Angular). You pick your poison: Bloated bundle sizes; Accessibility compromises; Complex setup requirements to name a few.

A lightbulb moment

While working on a large-scale web application, I realized something important: we don't need to reinvent the wheel, we just need to make it roll better. What if we could enhance the native title attribute instead of replacing it? Keep what works (accessibility) and improve what doesn't (user experience)?

That's exactly what I set out to build - a framework-agnostic tooltip solution that:

  1. Works with existing HTML (just add a title attribute)
  2. Preserves the native title attribute until hover for accessibility
  3. Provides instant feedback with smooth animations
  4. Handles mobile devices intelligently
  5. Maintains perfect accessibility by restoring title on mouse out
  6. Doesn't require any framework

Two ways to use tooltips

Before diving into the technical details, let's look at how simple this system is to use:

1. Enhanced tooltips (default)

The system automatically enhances any element with a title attribute:

<!-- Just use regular title attributes -->
<span title="Your tooltip content goes here">Hover me</span>

2. Native tooltips (When needed)

Sometimes you might want to keep the native tooltip behavior for specific elements. The new hide-custom-tooltip attribute gives you that control:

<!-- This element will use the native browser tooltip -->
<span title="Uses native browser tooltip" hide-custom-tooltip>
    Native tooltip preferred
</span>

Let's see it in action

Before diving into the technical details, see how simple it is to use:

<!-- Basic usage with typewriter effect -->
<span title="Your tooltip content goes here">Hover over me</span>

Output: Hover over me

<!-- Interactive element example -->
<button title="Go to your profile settings">⚙️</button>
<button title="View your notifications">🔔</button>
<button title="Access the help center"></button>

Output:

<!-- Long content handling -->
<label for="phone">Phone Number</label>
<input type="tel" id="phone" name="phone" placeholder="123-456-7890" />
<i class="label-hint" title="We use your phone number to send important account updates or to verify your identity.">Why share my number?</i>

Output: Why share my number?

That's it. No extra attributes, no wrapper divs, no JavaScript initialization per element. The tooltip system automatically enhances any element with a title attribute, providing:

  • Instant appearance on hover
  • Engaging typewriter animation on first view
  • Smart positioning that always stays in viewport
  • Touch-optimized behavior for mobile devices
  • Perfect keyboard navigation support
  • Screen reader compatibility

Understanding the configuration magic

The tooltip system's behavior is controlled by a carefully crafted configuration object. These settings, refined through real use cases and extensive testing, provide a fine balance between responsiveness and usability while maintaining full accessibility.

Viewport intelligence

const TOOLTIP_CONFIG = {
   edgeThreshold: 200,      // Distance from viewport edges (in px) where tooltip position switches to prevent cutoff
        minWidth: {
            desktop: 288,        // Minimum width of tooltip on desktop devices (in px)
            mobile: 248          // Minimum width of tooltip on mobile devices (in px)
        },
        margin: {
            desktop: 20,         // Minimum spacing between tooltip and viewport edges on desktop (in px)
            mobile: 10           // Minimum spacing between tooltip and viewport edges on mobile devices
        }
};

These spatial settings ensure your tooltips always stay comfortably visible. All values can be customized through the configuration object to match your requirements.

Feature Desktop Mobile Benefits
Edge detection Customizable threshold Same as desktop • Auto-positioning near viewport edges
• Prevents content overflow
• Responsive-ready
Tooltip width Customizable base width Narrower, mobile-optimized • Optimal reading experience
• Content-aware sizing
• Consistent cross-device display
Screen margins Comfortable spacing Condensed spacing • Clean visual spacing
• Space-efficient layout
• Maintains hierarchy

Interaction timing

const TOOLTIP_CONFIG = {
   typewriterSpeed: 8,      // Delay between each character appearing in typewriter effect (in ms)
        longPressDelay: 200,     // Time user must hold touch before tooltip appears on interactive elements (in ms)
        hideDelay: 100,          // Delay before tooltip starts hiding animation (in ms)
        showDelay: 50,           // Delay before tooltip starts showing animation (in ms)
        idPrefix: 'tooltip',     // Prefix for tooltip element IDs, resulting in 'tooltip1', 'tooltip2', etc.
        touchMoveThreshold: 10   // Maximum pixels finger can move before cancelling tooltip on touch devices
};

Each millisecond has been carefully considered as all timing values can be adjusted to match your specific interaction needs.

Timing feature Configuration Trigger event User experience
Typewriter animation Adjustable speed per character On first hover only • Engaging initial reveal
• Immediate recognition
• Efficient repeat use
Touch response Adjustable hold duration On touch events • Intentional activation
• Reduced mistakes
• Natural touch feel
Exit animation Adjustable fade duration On mouse leave/touch end • Forgiving interaction
• Smooth transitions
• Natural motion
Entry animation Adjustable fade duration On hover/touch start • Immediate response
• Deliberate display
• Fluid interaction

Deep dive into the implementation

Let's examine each component of the system, starting with how we track tooltip states and content:

1. State management

// Core state management
let tooltipCounter = 0;
const states = new WeakMap();  // Store states per element
const tooltipContent = new Map();  // Store tooltip content by ID
let activeTooltip = null;  // Track currently active tooltip for mobile tap-outside handling

The tooltip system uses a combination of WeakMap and Map for efficient state management:
- WeakMap for element states allows automatic garbage collection
- Regular Map for content storage provides quick access
- Single activeTooltip reference handles mobile interactions

2. Configuration system

const TOOLTIP_CONFIG = {
        edgeThreshold: 200,      // Distance from viewport edges (in px) where tooltip position switches to prevent cutoff
        minWidth: {
            desktop: 288,        // Minimum width of tooltip on desktop devices (in px)
            mobile: 248          // Minimum width of tooltip on mobile devices (in px)
        },
        margin: {
            desktop: 20,         // Minimum spacing between tooltip and viewport edges on desktop (in px)
            mobile: 10           // Minimum spacing between tooltip and viewport edges on mobile devices
        },
        typewriterSpeed: 8,      // Delay between each character appearing in typewriter effect (in ms)
        longPressDelay: 200,     // Time user must hold touch before tooltip appears on interactive elements (in ms)
        hideDelay: 100,          // Delay before tooltip starts hiding animation (in ms)
        showDelay: 50,           // Delay before tooltip starts showing animation (in ms)
        idPrefix: 'tooltip',     // Prefix for tooltip element IDs, resulting in 'tooltip1', 'tooltip2', etc.
        touchMoveThreshold: 10   // Maximum pixels finger can move before cancelling tooltip on touch devices
    };
    let tooltipCounter = 0;
    const states = new WeakMap();        // Store states per element
    const tooltipContent = new Map();    // Store tooltip content by ID
    let activeTooltip = null;            // Track currently active tooltip for mobile tap-outside handling
    // Helper function to get responsive values
    function getResponsiveValue(configValue) {
        if (typeof configValue === 'object') {
            return ('ontouchstart' in window) ? configValue.mobile : configValue.desktop;
        }
        return configValue;
    }

3. Smart content display

The system intelligently handles content display for optimal user experience:

function showTooltip(element, tooltip) {
    const state = states.get(element);
    if (!state || state.isVisible || state.showTimeout) return;
    state.showTimeout = setTimeout(() => {
        state.showTimeout = null;
        state.isVisible = true;
        // Get content from memory instead of DOM
        const content = getTooltipContent(state.tooltipId);
        // Remove title attribute to prevent native tooltip
        element.removeAttribute('title');
        // Show tooltip with CSS class
        tooltip.classList.add('tooltip-visible');
        // Handle content display based on view history
        const contentWrapper = tooltip.querySelector('.tooltip-content');
        if (state.hasBeenShown) {
            // Instant display for repeat views
            contentWrapper.textContent = content;
            requestAnimationFrame(() => {
                positionTooltip(element, tooltip);
            });
        } else {
            // Engaging typewriter effect for first view only
            state.typewriterInterval = startTypewriter(contentWrapper, content, state);
            state.hasBeenShown = true;
            positionTooltip(element, tooltip);
        }
    }, TOOLTIP_CONFIG.showDelay);
}

4. Smart event management

The system handles different events based on device type:

function setupEventListeners(element, tooltip) {
    const state = states.get(element);
    const isInteractive = isInteractiveElement(element);
    // Desktop events
    if (!('ontouchstart' in window)) {
        element.addEventListener('mouseenter', () => showTooltip(element, tooltip));
        element.addEventListener('mouseleave', () => hideTooltip(element, tooltip));
        element.addEventListener('focus', () => showTooltip(element, tooltip));
        element.addEventListener('blur', () => hideTooltip(element, tooltip));
    }
    // Touch events with smart handling
    element.addEventListener('touchstart', (e) => {
        // Hide any existing tooltip first
        if (activeTooltip && activeTooltip.tooltipId !== state.tooltipId) {
            const existingTooltip = document.getElementById(activeTooltip.tooltipId);
            const existingElement = existingTooltip?.parentElement;
            if (existingElement && existingTooltip) {
                hideTooltip(existingElement, existingTooltip);
            }
        }
}

5. Position management

The positioning system handles multiple scenarios:

function positionTooltip(element, tooltip) {
        const rect = element.getBoundingClientRect();
        const viewportWidth = window.innerWidth;
        const viewportHeight = window.innerHeight;
        // Reset data attributes
        tooltip.removeAttribute('data-position');
        tooltip.removeAttribute('data-edge');
        const spaces = {
            top: rect.top,
            bottom: viewportHeight - rect.bottom,
            left: rect.left,
            right: viewportWidth - rect.right
        };
        // Determine vertical position
        if (spaces.bottom < TOOLTIP_CONFIG.edgeThreshold && spaces.top > tooltip.offsetHeight) {
            tooltip.setAttribute('data-position', 'top');
        } else {
            tooltip.setAttribute('data-position', 'bottom');
        }
        // Determine horizontal edge
        const margin = getResponsiveValue(TOOLTIP_CONFIG.margin);
        const tooltipRect = tooltip.getBoundingClientRect();
        const horizontalOverflow = tooltipRect.left < margin ||
            tooltipRect.right > (viewportWidth - margin);
        if (horizontalOverflow) {
            if (rect.left < viewportWidth / 2) {
                tooltip.setAttribute('data-edge', 'left');
            } else {
                tooltip.setAttribute('data-edge', 'right');
            }
        }
    }

6. Animation system

The typewriter effect is handled with performance in mind:

function startTypewriter(element, text, state) {
    let index = 0;
    element.textContent = '';
    // Use requestAnimationFrame for smooth animation
    requestAnimationFrame(() => {
        const interval = setInterval(() => {
            if (index < text.length) {
                element.textContent = text.substring(0, ++index);
            } else {
                clearInterval(interval);
            }
        }, TOOLTIP_CONFIG.typewriterSpeed);
        state.typewriterInterval = interval;
    });
    return null;
}

7. Memory management and cleanup

Proper cleanup is crucial for preventing memory leaks:

function cleanup() {
    // Clear content storage
    tooltipContent.clear();
    // Remove global event listeners
    document.removeEventListener('touchstart', handleDocumentTouchStart);
    // Clean up each tooltip
    document.querySelectorAll('.custom-tooltip').forEach(tooltip => {
        const element = tooltip.parentElement;
        const state = states.get(element);
        if (state) {
            // Restore original title
            if (state.originalTitle) {
                element.setAttribute('title', state.originalTitle);
            }
            // Clear all timeouts and intervals
            clearTimeout(state.touchTimeout);
            clearTimeout(state.showTimeout);
            clearTimeout(state.hideTimeout);
            clearInterval(state.typewriterInterval);
            state.observer?.disconnect();
            // Clean up state
            states.delete(element);
        }
        // Remove added accessibility attributes
        if (!isInteractiveElement(element)) {
            element.removeAttribute('tabindex');
        }
        tooltip.remove();
    });
    activeTooltip = null;
}

8. Mutation observer for dynamic content

The system stays responsive to content changes:

Understanding the structure

The tooltip system creates this HTML structure for each tooltip:

<!-- Original element -->
<span title="Your tooltip text">Hover me</span>

<!-- Gets transformed to -->
<span title="Your tooltip text" aria-describedby="tooltip1">
    Hover me
    <em id="tooltip1" class="custom-tooltip" role="tooltip">
        <span class="tooltip-content">Your tooltip text</span>
    </em>
</span>

Core features explained

  1. Smart Positioning System
// Automatically detects best position
if (spaces.bottom < TOOLTIP_CONFIG.edgeThreshold && spaces.top > tooltip.offsetHeight) {
    // Position above element
    tooltip.style.top = 'auto';
    tooltip.style.bottom = '100%';
} else {
    // Position below element
    tooltip.style.top = '100%';
    tooltip.style.bottom = 'auto';
}
  1. Device-Specific Behavior
// Desktop events
if (!('ontouchstart' in window)) {
    element.addEventListener('mouseenter', showTooltip);
    element.addEventListener('mouseleave', hideTooltip);
}

// Mobile events
element.addEventListener('touchstart', handleTouch, { passive: true });
  1. Memory Management
    The system uses WeakMap for state management, preventing memory leaks:
const states = new WeakMap();  // Store states per element
const tooltipContent = new Map();  // Store tooltip content by ID

This configuration object controls everything from responsive sizing to animation timing. Want faster animations? Just adjust typewriterSpeed. Need different margins for mobile? Easy to change.

Styling and customization

The SCSS is structured for both easy customization and robust functionality:

// Base styling for elements with tooltips
[aria-describedby]  {
    position: relative;
    background-color: var(--defused-100);
    border-radius: 0.2rem;
    padding: 0.2rem 0.4rem;
    &:hover {
        background-color: var(--highlighter-blue);
    }
    &:focus-visible {
        background-color: var(--highlighter-blue);
    }
}
[aria-describedby]:not(a):not(button) {
    cursor: help;
}

// Core tooltip styling
.custom-tooltip {
    display: block;
    background-color: var(--text-default);
    color: var(--text-invert);
    border-radius: $br-small;
    padding: 1rem;
    font-size: 1.1rem;
    font-weight: 400;
    font-style: normal;
    word-wrap: break-word;
    z-index: 3;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
    transition: opacity 0.4s ease, width 0.3s ease, transform 0.3s ease;
    overflow: hidden;
    font-family: $family-sans-serif;
    opacity: 0;
    visibility: hidden;

    /* Responsive Min-Width */
    &.desktop {
        min-width: 288px;
    }

    &.mobile {
        min-width: 248px;
    }

    &.tooltip-visible {
        opacity: 1;
        visibility: visible;
    }

    @include mixins.tablet {
        min-width: 300px;
    }

    .tooltip-content {
        display: inline-block;
        overflow-wrap: break-word;
        line-height: 1.5;
        color: var(--default-text) !important;
    }

    // Ensure relative positioning context
    position: absolute;
    left: 50%;
    top: 100%;
    transform: translate(-50%, 8px); // 8px gap
}

See it in action

Feel free to grab the code from this demo on CodePen and customize it to suit your needs. Before you leave, don’t forget to drop some claps to show your support! The complete solution includes:

  1. A single JavaScript file (no dependencies)
  2. A few SCSS rules for styling
  3. Simple initialization code

Final thoughts

Sometimes the best solutions aren't about reinventing the wheel, but about making it roll more smoothly. This tooltip system proves that we can enhance user experience without sacrificing accessibility or adding unnecessary complexity.

Try it out, and let me know how it works for you in the comments below!