APG Patterns

Tooltip

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

Demo

Accessibility Features

WAI-ARIA Roles

WAI-ARIA States & Properties

aria-describedby

References the tooltip element to provide an accessible description for the trigger element.

Applied to Trigger element (wrapper)
When Only when tooltip is visible
Reference aria-describedby (opens in new tab)

aria-hidden

Indicates whether the tooltip is hidden from assistive technology.

Values true (hidden) | false (visible)
Default true
Reference aria-hidden (opens in new tab)

Keyboard Support

Key Action
Escape Closes the tooltip
Tab Standard focus navigation; tooltip shows when trigger receives focus

Focus Management

  • Tooltip never receives focus - Per APG, tooltips must not be focusable. If interactive content is needed, use a Dialog or Popover pattern instead.
  • Focus triggers display - When the trigger element receives focus, the tooltip appears after the configured delay.
  • Blur hides tooltip - When focus leaves the trigger element, the tooltip is hidden.

Mouse/Pointer Behavior

  • Hover triggers display - Moving the pointer over the trigger shows the tooltip after the delay.
  • Pointer leave hides - Moving the pointer away from the trigger hides the tooltip.

Important Notes

Note: The APG Tooltip pattern is currently marked as "work in progress" by the WAI. This implementation follows the documented guidelines, but the specification may evolve. View APG Tooltip Pattern (opens in new tab)

Visual Design

This implementation follows best practices for tooltip visibility:

  • High contrast - Dark background with light text ensures readability
  • Dark mode support - Colors invert appropriately in dark mode
  • Positioned near trigger - Tooltip appears adjacent to the triggering element
  • Configurable delay - Prevents accidental activation during cursor movement

Source Code

Tooltip.astro
---
export type TooltipPlacement = "top" | "bottom" | "left" | "right";

export interface Props {
  /** Tooltip content */
  content: string;
  /** Default open state */
  defaultOpen?: boolean;
  /** Delay before showing tooltip (ms) */
  delay?: number;
  /** Tooltip placement */
  placement?: TooltipPlacement;
  /** Custom tooltip ID */
  id?: string;
  /** Whether the tooltip is disabled */
  disabled?: boolean;
  /** Additional class name for the wrapper */
  class?: string;
  /** Additional class name for the tooltip content */
  tooltipClass?: string;
}

const {
  content,
  defaultOpen = false,
  delay = 300,
  placement = "top",
  id,
  disabled = false,
  class: className = "",
  tooltipClass = "",
} = Astro.props;

const tooltipId = id ?? `tooltip-${crypto.randomUUID().slice(0, 8)}`;

const placementClasses: Record<TooltipPlacement, string> = {
  top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
  bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
  left: "right-full top-1/2 -translate-y-1/2 mr-2",
  right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
---

<apg-tooltip
  class:list={["apg-tooltip-trigger", "relative inline-block", className]}
  data-delay={delay}
  data-disabled={disabled ? "true" : undefined}
  data-tooltip-id={tooltipId}
  data-default-open={defaultOpen ? "true" : undefined}
>
  <slot />
  <span
    id={tooltipId}
    role="tooltip"
    aria-hidden="true"
    class:list={[
      "apg-tooltip",
      "absolute z-50 px-3 py-1.5 text-sm",
      "bg-gray-900 text-white rounded-md shadow-lg",
      "dark:bg-gray-100 dark:text-gray-900",
      "pointer-events-none whitespace-nowrap",
      "transition-opacity duration-150",
      placementClasses[placement],
      "opacity-0 invisible",
      tooltipClass,
    ]}
  >
    {content}
  </span>
</apg-tooltip>

<script>
  class ApgTooltip extends HTMLElement {
    private timeout: ReturnType<typeof setTimeout> | null = null;
    private isOpen = false;
    private tooltipEl: HTMLElement | null = null;
    private delay: number;
    private disabled: boolean;
    private tooltipId: string;

    constructor() {
      super();
      this.delay = 300;
      this.disabled = false;
      this.tooltipId = "";
    }

    connectedCallback() {
      this.delay = parseInt(this.dataset.delay ?? "300", 10);
      this.disabled = this.dataset.disabled === "true";
      this.tooltipId = this.dataset.tooltipId ?? "";
      this.tooltipEl = this.querySelector(`#${this.tooltipId}`);

      if (this.dataset.defaultOpen === "true") {
        this.showTooltip();
      }

      this.addEventListener("mouseenter", this.handleMouseEnter);
      this.addEventListener("mouseleave", this.handleMouseLeave);
      this.addEventListener("focusin", this.handleFocusIn);
      this.addEventListener("focusout", this.handleFocusOut);
      document.addEventListener("keydown", this.handleKeyDown);
    }

    disconnectedCallback() {
      if (this.timeout) {
        clearTimeout(this.timeout);
      }
      this.removeEventListener("mouseenter", this.handleMouseEnter);
      this.removeEventListener("mouseleave", this.handleMouseLeave);
      this.removeEventListener("focusin", this.handleFocusIn);
      this.removeEventListener("focusout", this.handleFocusOut);
      document.removeEventListener("keydown", this.handleKeyDown);
    }

    private handleMouseEnter = () => {
      this.scheduleShow();
    };

    private handleMouseLeave = () => {
      this.hideTooltip();
    };

    private handleFocusIn = () => {
      this.scheduleShow();
    };

    private handleFocusOut = () => {
      this.hideTooltip();
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape" && this.isOpen) {
        this.hideTooltip();
      }
    };

    private scheduleShow() {
      if (this.disabled) return;
      if (this.timeout) {
        clearTimeout(this.timeout);
      }
      this.timeout = setTimeout(() => {
        this.showTooltip();
      }, this.delay);
    }

    private showTooltip() {
      if (this.disabled || !this.tooltipEl) return;
      this.isOpen = true;
      this.tooltipEl.setAttribute("aria-hidden", "false");
      this.tooltipEl.classList.remove("opacity-0", "invisible");
      this.tooltipEl.classList.add("opacity-100", "visible");
      this.setAttribute("aria-describedby", this.tooltipId);
    }

    private hideTooltip() {
      if (this.timeout) {
        clearTimeout(this.timeout);
        this.timeout = null;
      }
      if (!this.tooltipEl) return;
      this.isOpen = false;
      this.tooltipEl.setAttribute("aria-hidden", "true");
      this.tooltipEl.classList.remove("opacity-100", "visible");
      this.tooltipEl.classList.add("opacity-0", "invisible");
      this.removeAttribute("aria-describedby");
    }
  }

  customElements.define("apg-tooltip", ApgTooltip);
</script>

Usage

Example
---
import Tooltip from './Tooltip.astro';
---

<Tooltip
  content="Save your changes"
  placement="top"
  delay={300}
>
  <button>Save</button>
</Tooltip>

API

Prop Type Default Description
content string - Tooltip content (required)
defaultOpen boolean false Default open state
delay number 300 Delay before showing (ms)
placement 'top' | 'bottom' | 'left' | 'right' 'top' Tooltip position
id string auto-generated Custom ID
disabled boolean false Disable the tooltip

This implementation uses a Web Component (<apg-tooltip>) for client-side interactivity.

Resources