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:
- Works with existing HTML (just add a
title
attribute) - Preserves the native
title
attribute until hover for accessibility - Provides instant feedback with smooth animations
- Handles mobile devices intelligently
- Maintains perfect accessibility by restoring title on mouse out
- 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>
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
- 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';
}
- 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 });
- 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:
- A single JavaScript file (no dependencies)
- A few SCSS rules for styling
- 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!