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.tsx
import { cn } from "@/lib/utils";
import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ReactNode,
} from "react";

export type TooltipPlacement = "top" | "bottom" | "left" | "right";

export interface TooltipProps {
  /** Tooltip content */
  content: ReactNode;
  /** Trigger element */
  children: ReactNode;
  /** Controlled open state */
  open?: boolean;
  /** Default open state (uncontrolled) */
  defaultOpen?: boolean;
  /** Callback when open state changes */
  onOpenChange?: (open: boolean) => void;
  /** Delay before showing tooltip (ms) */
  delay?: number;
  /** Tooltip placement */
  placement?: TooltipPlacement;
  /** Custom tooltip ID for SSR */
  id?: string;
  /** Whether the tooltip is disabled */
  disabled?: boolean;
  /** Additional class name for the wrapper */
  className?: string;
  /** Additional class name for the tooltip content */
  tooltipClassName?: string;
}

export const Tooltip: React.FC<TooltipProps> = ({
  content,
  children,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  delay = 300,
  placement = "top",
  id: providedId,
  disabled = false,
  className,
  tooltipClassName,
}) => {
  const generatedId = useId();
  const tooltipId = providedId ?? `tooltip-${generatedId}`;

  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const isOpen = isControlled ? controlledOpen : internalOpen;

  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const triggerRef = useRef<HTMLSpanElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);

  const setOpen = useCallback(
    (value: boolean) => {
      if (!isControlled) {
        setInternalOpen(value);
      }
      onOpenChange?.(value);
    },
    [isControlled, onOpenChange]
  );

  const showTooltip = useCallback(() => {
    if (disabled) return;
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    timeoutRef.current = setTimeout(() => {
      setOpen(true);
    }, delay);
  }, [delay, disabled, setOpen]);

  const hideTooltip = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
    setOpen(false);
  }, [setOpen]);

  // Handle Escape key
  useEffect(() => {
    if (!isOpen) return;

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        hideTooltip();
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => {
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, [isOpen, hideTooltip]);

  // Cleanup timeout on unmount
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  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",
  };

  return (
    <span
      ref={triggerRef}
      className={cn("apg-tooltip-trigger", "relative inline-block", className)}
      onMouseEnter={showTooltip}
      onMouseLeave={hideTooltip}
      onFocus={showTooltip}
      onBlur={hideTooltip}
      aria-describedby={isOpen && !disabled ? tooltipId : undefined}
    >
      {children}
      <span
        ref={tooltipRef}
        id={tooltipId}
        role="tooltip"
        aria-hidden={!isOpen}
        className={cn(
          "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],
          isOpen ? "opacity-100 visible" : "opacity-0 invisible",
          tooltipClassName
        )}
      >
        {content}
      </span>
    </span>
  );
};

export default Tooltip;

Usage

Example
import { Tooltip } from './Tooltip';

function App() {
  return (
    <Tooltip
      content="Save your changes"
      placement="top"
      delay={300}
    >
      <button>Save</button>
    </Tooltip>
  );
}

API

Prop Type Default Description
content ReactNode - Tooltip content (required)
children ReactNode - Trigger element (required)
open boolean - Controlled open state
defaultOpen boolean false Default open state (uncontrolled)
onOpenChange (open: boolean) => void - Callback when open state changes
delay number 300 Delay before showing (ms)
placement 'top' | 'bottom' | 'left' | 'right' 'top' Tooltip position relative to trigger
id string auto-generated Custom ID for SSR
disabled boolean false Disable the tooltip

Testing

Testing Overview

The Tooltip component tests are organized into priority levels based on APG compliance requirements.

Test Categories

High Priority: APG Core Compliance

Test APG Requirement
role="tooltip" exists Tooltip container must have tooltip role
aria-hidden when closed Hidden tooltips must not be read by AT
aria-describedby when visible Trigger must reference tooltip only when visible
Escape key closes tooltip Keyboard dismissal support
Focus shows tooltip Keyboard accessibility
Blur hides tooltip Focus management

Medium Priority: Accessibility Validation

Test WCAG Requirement
No axe violations (hidden state) WCAG 2.1 AA compliance
No axe violations (visible state) WCAG 2.1 AA compliance
Tooltip is not focusable APG: tooltips must not receive focus

Low Priority: Props & Extensibility

Test Feature
placement prop changes position Positioning customization
disabled prop prevents display Disable functionality
delay prop controls timing Delay customization
id prop sets custom ID SSR/custom ID support
controlled open state External state control
onOpenChange callback State change notification
className inheritance Style customization

Running Tests

# Run all Tooltip tests
npm run test -- tooltip

# Run tests for specific framework
npm run test -- Tooltip.test.tsx    # React
npm run test -- Tooltip.test.vue    # Vue
npm run test -- Tooltip.test.svelte # Svelte
Tooltip.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { describe, expect, it, vi } from "vitest";
import { Tooltip } from "./Tooltip";

describe("Tooltip", () => {
  // 🔴 High Priority: APG 準拠の核心
  describe("APG: ARIA 属性", () => {
    it('role="tooltip" を持つ', () => {
      render(
        <Tooltip content="This is a tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      expect(screen.getByRole("tooltip", { hidden: true })).toBeInTheDocument();
    });

    it("非表示時は aria-hidden が true", () => {
      render(
        <Tooltip content="This is a tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole("tooltip", { hidden: true });
      expect(tooltip).toHaveAttribute("aria-hidden", "true");
    });

    it("表示時は aria-hidden が false", async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole("button");

      await user.hover(trigger);
      await waitFor(() => {
        const tooltip = screen.getByRole("tooltip");
        expect(tooltip).toHaveAttribute("aria-hidden", "false");
      });
    });

    it("表示時のみ aria-describedby が設定される", async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole("button");
      const wrapper = trigger.parentElement;

      // 非表示時は aria-describedby がない
      expect(wrapper).not.toHaveAttribute("aria-describedby");

      await user.hover(trigger);
      await waitFor(() => {
        expect(wrapper).toHaveAttribute("aria-describedby");
      });

      const tooltipId = wrapper?.getAttribute("aria-describedby");
      const tooltip = screen.getByRole("tooltip");
      expect(tooltip).toHaveAttribute("id", tooltipId);
    });
  });

  describe("APG: キーボード操作", () => {
    it("Escape キーで閉じる", async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole("button");

      await user.hover(trigger);
      await waitFor(() => {
        expect(screen.getByRole("tooltip")).toHaveAttribute(
          "aria-hidden",
          "false"
        );
      });

      await user.keyboard("{Escape}");
      await waitFor(() => {
        expect(screen.getByRole("tooltip", { hidden: true })).toHaveAttribute(
          "aria-hidden",
          "true"
        );
      });
    });

    it("フォーカスで表示される", async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );

      await user.tab();
      expect(screen.getByRole("button")).toHaveFocus();

      await waitFor(() => {
        expect(screen.getByRole("tooltip")).toHaveAttribute(
          "aria-hidden",
          "false"
        );
      });
    });

    it("フォーカスアウトで閉じる", async () => {
      const user = userEvent.setup();
      render(
        <>
          <Tooltip content="This is a tooltip" delay={0}>
            <button>First</button>
          </Tooltip>
          <button>Second</button>
        </>
      );

      await user.tab();
      await waitFor(() => {
        expect(screen.getByRole("tooltip")).toHaveAttribute(
          "aria-hidden",
          "false"
        );
      });

      await user.tab();
      await waitFor(() => {
        expect(screen.getByRole("tooltip", { hidden: true })).toHaveAttribute(
          "aria-hidden",
          "true"
        );
      });
    });
  });

  describe("ホバー操作", () => {
    afterEach(() => {
      vi.useRealTimers();
    });

    it("ホバーで表示される", async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole("button");

      await user.hover(trigger);
      await waitFor(() => {
        expect(screen.getByRole("tooltip")).toHaveAttribute(
          "aria-hidden",
          "false"
        );
      });
    });

    it("ホバー解除で閉じる", async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole("button");

      await user.hover(trigger);
      await waitFor(() => {
        expect(screen.getByRole("tooltip")).toHaveAttribute(
          "aria-hidden",
          "false"
        );
      });

      await user.unhover(trigger);
      await waitFor(() => {
        expect(screen.getByRole("tooltip", { hidden: true })).toHaveAttribute(
          "aria-hidden",
          "true"
        );
      });
    });

    it("delay 後に表示される", async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={100}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole("button");

      await user.hover(trigger);

      // delay 前は非表示(直後)
      expect(screen.getByRole("tooltip", { hidden: true })).toHaveAttribute(
        "aria-hidden",
        "true"
      );

      // delay 後は表示
      await waitFor(
        () => {
          expect(screen.getByRole("tooltip")).toHaveAttribute(
            "aria-hidden",
            "false"
          );
        },
        { timeout: 200 }
      );
    });
  });

  // 🟡 Medium Priority: アクセシビリティ検証
  describe("アクセシビリティ", () => {
    it("axe による WCAG 2.1 AA 違反がない(非表示状態)", async () => {
      const { container } = render(
        <Tooltip content="This is a tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it("axe による WCAG 2.1 AA 違反がない(表示状態)", async () => {
      const user = userEvent.setup();
      const { container } = render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );

      await user.hover(screen.getByRole("button"));
      await waitFor(() => {
        expect(screen.getByRole("tooltip")).toHaveAttribute(
          "aria-hidden",
          "false"
        );
      });

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it("tooltip がフォーカスを受け取らない", () => {
      render(
        <Tooltip content="This is a tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole("tooltip", { hidden: true });
      expect(tooltip).not.toHaveAttribute("tabindex");
    });
  });

  describe("Props", () => {
    it("placement prop で位置を変更できる", () => {
      render(
        <Tooltip content="Tooltip" placement="bottom">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole("tooltip", { hidden: true });
      expect(tooltip).toHaveClass("top-full");
    });

    it("disabled の場合、tooltip が表示されない", async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="Tooltip" delay={0} disabled>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole("button");

      await user.hover(trigger);
      // disabled なので表示されない (delay=0 なので即時)
      expect(screen.getByRole("tooltip", { hidden: true })).toHaveAttribute(
        "aria-hidden",
        "true"
      );
    });

    it("id prop でカスタム ID を設定できる", () => {
      render(
        <Tooltip content="Tooltip" id="custom-tooltip-id">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole("tooltip", { hidden: true });
      expect(tooltip).toHaveAttribute("id", "custom-tooltip-id");
    });

    it("onOpenChange が状態変化時に呼び出される", async () => {
      const handleOpenChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Tooltip content="Tooltip" delay={0} onOpenChange={handleOpenChange}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole("button");

      await user.hover(trigger);
      await waitFor(() => {
        expect(handleOpenChange).toHaveBeenCalledWith(true);
      });

      await user.unhover(trigger);
      await waitFor(() => {
        expect(handleOpenChange).toHaveBeenCalledWith(false);
      });
    });

    it("controlled open prop で制御できる", () => {
      const { rerender } = render(
        <Tooltip content="Tooltip" open={false}>
          <button>Hover me</button>
        </Tooltip>
      );

      expect(screen.getByRole("tooltip", { hidden: true })).toHaveAttribute(
        "aria-hidden",
        "true"
      );

      rerender(
        <Tooltip content="Tooltip" open={true}>
          <button>Hover me</button>
        </Tooltip>
      );

      expect(screen.getByRole("tooltip")).toHaveAttribute(
        "aria-hidden",
        "false"
      );
    });
  });

  // 🟢 Low Priority: 拡張性
  describe("HTML 属性継承", () => {
    it("className が正しくマージされる", () => {
      render(
        <Tooltip content="Tooltip" className="custom-class">
          <button>Hover me</button>
        </Tooltip>
      );
      const wrapper = screen.getByRole("button").parentElement;
      expect(wrapper).toHaveClass("custom-class");
      expect(wrapper).toHaveClass("apg-tooltip-trigger");
    });

    it("tooltipClassName が適用される", () => {
      render(
        <Tooltip content="Tooltip" tooltipClassName="custom-tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole("tooltip", { hidden: true });
      expect(tooltip).toHaveClass("custom-tooltip");
    });
  });
});

Resources