Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Create accessible tooltips

Tooltips are accessible to keyboard users and screen readers with proper ARIA attributes and focus handling.

Utilities
Quick take
Typical fix time 20 min
  • Tooltips must be keyboard accessible (focusable trigger)
  • Use aria-describedby to associate tooltip with trigger
  • Allow hover and focus to show tooltip, Escape to dismiss
  • Ensure sufficient contrast and don't hide essential info in tooltips
Why it matters: Inaccessible tooltips leave keyboard and screen reader users without important contextual information, creating an unequal experience and potential confusion.

Rule Details

Accessible tooltips provide supplementary information without excluding keyboard or screen reader users.

Code Example

<div class="tooltip-container">
  <button
    type="button"
    aria-describedby="tooltip-1"
    class="tooltip-trigger"
  >
    Settings
  </button>
  <div
    id="tooltip-1"
    role="tooltip"
    class="tooltip"
  >
    Configure your preferences
  </div>
</div>

Why It Matters

Inaccessible tooltips leave keyboard and screen reader users without important contextual information, creating an unequal experience and potential confusion.

Accessibility Requirements

RequirementImplementation
Keyboard accessibleShow on focus, not just hover
Programmatically associatedUse aria-describedby
DismissibleClose with Escape key
PersistentStay visible while hovered/focused
Non-essentialDon't hide critical info in tooltips

React Tooltip Component

import { useState, useRef, useEffect, useId } from 'react'
 
interface TooltipProps {
  content: string
  children: React.ReactElement
  position?: 'top' | 'bottom' | 'left' | 'right'
  delay?: number
}
 
export function Tooltip({
  content,
  children,
  position = 'top',
  delay = 300
}: TooltipProps) {
  const [isVisible, setIsVisible] = useState(false)
  const tooltipId = useId()
  const timeoutRef = useRef<NodeJS.Timeout>()
  const triggerRef = useRef<HTMLElement>(null)
 
  const showTooltip = () => {
    timeoutRef.current = setTimeout(() => setIsVisible(true), delay)
  }
 
  const hideTooltip = () => {
    clearTimeout(timeoutRef.current)
    setIsVisible(false)
  }
 
  // Handle Escape key
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && isVisible) {
        hideTooltip()
      }
    }
 
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [isVisible])
 
  // Clone child to add props
  const trigger = React.cloneElement(children, {
    ref: triggerRef,
    'aria-describedby': isVisible ? tooltipId : undefined,
    onMouseEnter: showTooltip,
    onMouseLeave: hideTooltip,
    onFocus: showTooltip,
    onBlur: hideTooltip,
  })
 
  return (
    <div className="tooltip-wrapper">
      {trigger}
      {isVisible && (
        <div
          id={tooltipId}
          role="tooltip"
          className={`tooltip tooltip--${position}`}
        >
          {content}
        </div>
      )}
    </div>
  )
}

Usage

<Tooltip content="Save your current progress">
  <button type="button">
    <SaveIcon aria-hidden="true" />
    <span className="sr-only">Save</span>
  </button>
</Tooltip>
 
<Tooltip content="Required field" position="right">
  <label htmlFor="email">
    Email <span aria-hidden="true">*</span>
  </label>
</Tooltip>

Advanced Tooltip with Floating UI

import { useFloating, offset, flip, shift, arrow } from '@floating-ui/react'
import { useState, useRef, useId } from 'react'
 
interface AdvancedTooltipProps {
  content: React.ReactNode
  children: React.ReactElement
}
 
export function AdvancedTooltip({ content, children }: AdvancedTooltipProps) {
  const [isOpen, setIsOpen] = useState(false)
  const arrowRef = useRef(null)
  const tooltipId = useId()
 
  const { refs, floatingStyles, context } = useFloating({
    open: isOpen,
    onOpenChange: setIsOpen,
    placement: 'top',
    middleware: [
      offset(8),
      flip(),
      shift({ padding: 8 }),
      arrow({ element: arrowRef })
    ],
  })
 
  return (
    <>
      {React.cloneElement(children, {
        ref: refs.setReference,
        'aria-describedby': isOpen ? tooltipId : undefined,
        onMouseEnter: () => setIsOpen(true),
        onMouseLeave: () => setIsOpen(false),
        onFocus: () => setIsOpen(true),
        onBlur: () => setIsOpen(false),
      })}
 
      {isOpen && (
        <div
          ref={refs.setFloating}
          id={tooltipId}
          role="tooltip"
          style={floatingStyles}
          className="tooltip"
        >
          {content}
          <div ref={arrowRef} className="tooltip-arrow" />
        </div>
      )}
    </>
  )
}

Icon Button with Tooltip

interface IconButtonProps {
  icon: React.ReactNode
  label: string
  onClick: () => void
  tooltip?: string
}
 
export function IconButton({ icon, label, onClick, tooltip }: IconButtonProps) {
  const tooltipId = useId()
  const [showTooltip, setShowTooltip] = useState(false)
 
  return (
    <div className="icon-button-wrapper">
      <button
        type="button"
        onClick={onClick}
        aria-label={label}
        aria-describedby={tooltip && showTooltip ? tooltipId : undefined}
        onMouseEnter={() => setShowTooltip(true)}
        onMouseLeave={() => setShowTooltip(false)}
        onFocus={() => setShowTooltip(true)}
        onBlur={() => setShowTooltip(false)}
        className="icon-button"
      >
        {icon}
      </button>
 
      {tooltip && showTooltip && (
        <span id={tooltipId} role="tooltip" className="tooltip">
          {tooltip}
        </span>
      )}
    </div>
  )
}

Native Title Attribute (Limited)

<!-- Simple but limited accessibility -->
<button type="button" title="Save document">
  <svg aria-hidden="true"><!-- save icon --></svg>
  <span class="sr-only">Save</span>
</button>
 
<!-- Better: Custom tooltip with full control -->
<button
  type="button"
  aria-describedby="save-tooltip"
  aria-label="Save"
>
  <svg aria-hidden="true"><!-- save icon --></svg>
</button>
<div id="save-tooltip" role="tooltip" class="tooltip">
  Save document (Ctrl+S)
</div>

Styling

.tooltip-wrapper {
  position: relative;
  display: inline-block;
}
 
.tooltip {
  position: absolute;
  z-index: 1000;
  padding: 0.5rem 0.75rem;
  font-size: 0.875rem;
  background: #1a1a1a;
  color: #ffffff;
  border-radius: 4px;
  white-space: nowrap;
  pointer-events: none;
 
  /* Animation */
  opacity: 0;
  animation: tooltipFadeIn 0.15s ease-out forwards;
}
 
.tooltip--top {
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-bottom: 8px;
}
 
.tooltip--bottom {
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-top: 8px;
}
 
.tooltip--left {
  right: 100%;
  top: 50%;
  transform: translateY(-50%);
  margin-right: 8px;
}
 
.tooltip--right {
  left: 100%;
  top: 50%;
  transform: translateY(-50%);
  margin-left: 8px;
}
 
/* Arrow */
.tooltip::after {
  content: '';
  position: absolute;
  border: 6px solid transparent;
}
 
.tooltip--top::after {
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border-top-color: #1a1a1a;
}
 
@keyframes tooltipFadeIn {
  from { opacity: 0; transform: translateX(-50%) translateY(4px); }
  to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
 
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
  .tooltip {
    animation: none;
    opacity: 1;
  }
}
 
/* Ensure sufficient contrast */
.tooltip {
  /* WCAG requires 4.5:1 for normal text */
  /* #ffffff on #1a1a1a = 16.1:1 ✓ */
}

When to Use Tooltips vs Other Patterns

Use CasePattern
Supplementary hintTooltip
Essential instructionInline text
Complex contentPopover/Dialog
Form field helparia-describedby text
Icon-only buttonaria-label + optional tooltip

Verification

  1. Tab to trigger element - tooltip should appear
  2. Press Escape - tooltip should close
  3. Hover over trigger - tooltip appears after delay
  4. Move mouse to tooltip - should stay visible
  5. Test with screen reader (tooltip content announced)
  6. Verify tooltip doesn't block other content
  7. Check color contrast meets WCAG requirements
Avoid Essential Content in Tooltips

Never put essential information in tooltips. They're for supplementary hints only. Critical content should be visible by default or in the main UI.

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Verify tooltips are keyboard accessible, use appropriate ARIA roles (tooltip), and can be dismissed without moving focus.

Fix

Auto-fix issues

Implement tooltips with role='tooltip', aria-describedby, keyboard triggering, and Escape key dismissal.

Explain

Learn more

Explain the accessibility requirements for tooltips including keyboard access, ARIA attributes, and timing considerations.

Review

Code review

Review templates, server-rendered HTML, and shared components that output markup related to Create accessible tooltips. Flag exact elements, attributes, and routes where the rendered HTML violates the rule.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Nu Html Checker
validator.w3.orgTool
Tooltip Pattern – UX Patterns for Developers

Comprehensive UX pattern guide covering anatomy, accessibility, best practices, and implementation.

uxpatterns.devGuide

Rules that often go hand-in-hand with this one.

Make notifications accessible

Toast notifications and alerts are announced to screen readers using ARIA live regions and appropriate roles.

Accessibility
Make accordions keyboard navigable

Accordion components use proper ARIA attributes and keyboard interactions for screen reader accessibility.

Accessibility
Make carousels accessible

Carousels and sliders are accessible with pause controls, keyboard navigation, and proper ARIA attributes.

Accessibility
Make custom elements and Web Components accessible

Custom elements must implement ARIA reflection via ElementInternals, keyboard interaction, and form association so that screen readers and assistive technologies can interpret them correctly.

HTML

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385