Tooltip
要素がキーボードフォーカスを受けたとき、またはマウスがホバーしたときに、要素に関連する情報を表示するポップアップ。
🤖 AI 実装ガイドデモ
アクセシビリティ
WAI-ARIA ロール
-
tooltip- 要素の説明を表示するコンテキストポップアップ
WAI-ARIA tooltip role (opens in new tab)
WAI-ARIA ステート & プロパティ
aria-describedby
トリガー要素にアクセシブルな説明を提供するために、ツールチップ要素を参照します。
| 適用先 | トリガー要素(ラッパー) |
| タイミング | ツールチップが表示されている時のみ |
| 参照 | aria-describedby (opens in new tab) |
aria-hidden
ツールチップが支援技術から隠されているかどうかを示します。
| 値 | true(非表示)| false(表示)
|
| デフォルト | true |
| 参照 | aria-hidden (opens in new tab) |
キーボードサポート
| キー | アクション |
|---|---|
| Escape | ツールチップを閉じる |
| Tab | 標準のフォーカスナビゲーション。トリガーがフォーカスを受け取るとツールチップが表示される |
フォーカス管理
- ツールチップはフォーカスを受け取らない - APGに従い、ツールチップはフォーカス可能であってはいけません。インタラクティブなコンテンツが必要な場合は、DialogまたはPopoverパターンを使用してください。
- フォーカスが表示をトリガーする - トリガー要素がフォーカスを受け取ると、設定された遅延後にツールチップが表示されます。
- ぼかしがツールチップを非表示にする - フォーカスがトリガー要素を離れると、ツールチップは非表示になります。
マウス/ポインター動作
- ホバーが表示をトリガーする - ポインターをトリガー上に移動すると、遅延後にツールチップが表示されます。
- ポインター離脱で非表示 - ポインターをトリガーから離すと、ツールチップが非表示になります。
重要な注意事項
注意: APG Tooltipパターンは現在WAIによって「作業中」とマークされています。この実装は文書化されたガイドラインに従っていますが、仕様は進化する可能性があります。 View APG Tooltip Pattern (opens in new tab)
ビジュアルデザイン
この実装は、ツールチップの視認性のベストプラクティスに従っています:
- 高コントラスト - 暗い背景に明るいテキストで可読性を確保
- ダークモード対応 - ダークモードで色が適切に反転
- トリガーの近くに配置 - ツールチップはトリガー要素に隣接して表示
- 設定可能な遅延 - カーソル移動中の誤作動を防止
ソースコード
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',
'rounded-md bg-gray-900 text-white shadow-lg',
'dark:bg-gray-100 dark:text-gray-900',
'pointer-events-none whitespace-nowrap',
'transition-opacity duration-150',
placementClasses[placement],
isOpen ? 'visible opacity-100' : 'invisible opacity-0',
tooltipClassName
)}
>
{content}
</span>
</span>
);
};
export default Tooltip; 使い方
Example
import { Tooltip } from './Tooltip';
function App() {
return (
<Tooltip
content="Save your changes"
placement="top"
delay={300}
>
<button>Save</button>
</Tooltip>
);
} API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
content | ReactNode | - | ツールチップの内容(必須) |
children | ReactNode | - | トリガー要素(必須) |
open | boolean | - | 制御された開閉状態 |
defaultOpen | boolean | false | デフォルトの開閉状態(非制御) |
onOpenChange | (open: boolean) => void | - | 開閉状態が変更されたときのコールバック |
delay | number | 300 | 表示までの遅延時間(ミリ秒) |
placement | 'top' | 'bottom' | 'left' | 'right' | 'top' | トリガーに対するツールチップの位置 |
id | string | auto-generated | SSR用のカスタムID |
disabled | boolean | false | ツールチップを無効化 |
テスト
テスト概要
ToolチップコンポーネントのテストはAPG準拠要件に基づいて優先度レベルに分類されています。
テストカテゴリ
高優先度: APGコア準拠
| テスト | APG要件 |
|---|---|
| role="tooltip" が存在する | ツールチップコンテナはtooltipロールを持つ必要がある |
| 閉じている時はaria-hidden | 非表示のツールチップは支援技術から読み取られてはならない |
| 表示時はaria-describedby | トリガーは表示時のみツールチップを参照する必要がある |
| Escapeキーでツールチップを閉じる | キーボードによる解除サポート |
| フォーカスでツールチップを表示 | キーボードアクセシビリティ |
| ぼかしでツールチップを非表示 | フォーカス管理 |
中優先度: アクセシビリティ検証
| テスト | WCAG要件 |
|---|---|
| axe違反なし(非表示状態) | WCAG 2.1 AA準拠 |
| axe違反なし(表示状態) | WCAG 2.1 AA準拠 |
| ツールチップはフォーカス可能ではない | APG: ツールチップはフォーカスを受け取ってはならない |
低優先度: Props & 拡張性
| テスト | 機能 |
|---|---|
| placement propで位置を変更 | 配置のカスタマイズ |
| disabled propで表示を防ぐ | 無効化機能 |
| delay propでタイミングを制御 | 遅延のカスタマイズ |
| id propでカスタムIDを設定 | SSR/カスタムIDサポート |
| 制御されたopen状態 | 外部状態制御 |
| onOpenChangeコールバック | 状態変更通知 |
| className継承 | スタイルのカスタマイズ |
テストの実行
# すべてのToolチップテストを実行
npm run test -- tooltip
# 特定のフレームワークのテストを実行
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 Core Compliance
describe('APG: ARIA Attributes', () => {
it('has role="tooltip"', () => {
render(
<Tooltip content="This is a tooltip">
<button>Hover me</button>
</Tooltip>
);
expect(screen.getByRole('tooltip', { hidden: true })).toBeInTheDocument();
});
it('has aria-hidden="true" when hidden', () => {
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('has aria-hidden="false" when visible', 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('sets aria-describedby only when visible', 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;
// No aria-describedby when hidden
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: Keyboard Interaction', () => {
it('closes with Escape key', 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('shows on focus', 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('closes on focus out', 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('Hover Interaction', () => {
afterEach(() => {
vi.useRealTimers();
});
it('shows on hover', 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('closes on hover out', 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('shows after 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);
// Hidden immediately before delay
expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute('aria-hidden', 'true');
// Visible after delay
await waitFor(
() => {
expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
},
{ timeout: 200 }
);
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no WCAG 2.1 AA violations (hidden state)', async () => {
const { container } = render(
<Tooltip content="This is a tooltip">
<button>Hover me</button>
</Tooltip>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no WCAG 2.1 AA violations (visible state)', 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 does not receive focus', () => {
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('can change position with 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('does not show tooltip when disabled', 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);
// Does not show because disabled (delay=0 so immediate)
expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute('aria-hidden', 'true');
});
it('can set custom ID with id prop', () => {
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('calls onOpenChange when state changes', 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('can be controlled with 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: Extensibility
describe('HTML Attribute Inheritance', () => {
it('merges className correctly', () => {
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('applies 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');
});
});
}); リソース
- WAI-ARIA APG: Tooltip パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist