1. 導入:なぜツールチップを自作するのか?
Next.jsとTypeScriptを使ったモダンな開発では、外部ライブラリへの依存を減らし、パフォーマンスとカスタマイズ性を最大化することが重要です。
ツールチップはUI要素の中でも頻繁に使用されますが、外部ライブラリに頼らず、Reactのポータルとカスタムフックを使って実装することで、以下のメリットが得られます。
- パフォーマンス向上: 不要な依存関係を排除し、バンドルサイズを削減します。
- フルカスタマイズ: デザインや挙動を完全に制御できます。
- アクセシビリティ (A11y):
aria-describedby属性などを適切に使用し、WCAG(Web Content Accessibility Guidelines)に準拠した実装が可能です。
2. ツールチップの基本構造と技術選定
ツールチップ実装の核心は、「トリガー要素の近く」に「DOMツリーのルート(body直下)」に表示される要素を配置することです。
2.1 React Portalの利用(DOMの階層問題の解決)
通常のコンポーネントレンダリングでは、ツールチップは親コンポーネントの overflow: hidden や z-index の影響を受け、途中で途切れる可能性があります。
これを解決するために、React Portalを使用し、ツールチップ要素を document.body 直下など、DOMツリーの任意の場所にレンダリングします。これにより、親コンポーネントのスタイルに影響を受けず、意図した通りの表示が可能です。
2.2 TypeScriptによる型定義の設計
ツールチップのPropsやカスタムフックの戻り値には、以下の主要な型を定義し、コードの安全性を確保します。
TypeScript
// TooltipコンポーネントのProps型
type TooltipProps = {
content: React.ReactNode; // ツールチップ内に表示するコンテンツ
children: React.ReactElement; // ツールチップのトリガー要素(ボタンなど)
position?: 'top' | 'bottom' | 'left' | 'right'; // 表示位置の指定
};
// カスタムフックが返す状態とハンドラーの型
type TooltipState = {
isVisible: boolean;
coords: { x: number; y: number } | null; // 計算された座標
triggerRef: React.RefObject<HTMLElement>;
handleMouseEnter: () => void;
handleMouseLeave: () => void;
};
3. ステップ別実装:カスタムフックとコンポーネント
3.1 ステップ 1: 表示状態と位置を管理するカスタムフック
useCallback と useRef を利用し、表示状態と正確な位置座標を管理するカスタムフックを実装します。
TypeScript
// hooks/useTooltip.ts
import { useState, useRef, useCallback, useLayoutEffect } from 'react';
// ... TooltipState, TooltipProps の型定義は省略
export const useTooltip = (position: TooltipProps['position'] = 'top'): TooltipState => {
const [isVisible, setIsVisible] = useState(false);
const [coords, setCoords] = useState<TooltipState['coords']>(null);
const triggerRef = useRef<HTMLElement>(null); // トリガー要素のDOM参照
const handleMouseEnter = useCallback(() => setIsVisible(true), []);
const handleMouseLeave = useCallback(() => setIsVisible(false), []);
// 描画が完了した後で座標を計算
useLayoutEffect(() => {
if (isVisible && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
// 実際の位置決めロジック(例:上部中央に表示)
const newCoords = {
x: rect.left + rect.width / 2,
y: rect.top + window.scrollY,
};
setCoords(newCoords);
}
}, [isVisible, position]); // isVisibleが変更されるたびに再計算
return { isVisible, coords, triggerRef, handleMouseEnter, handleMouseLeave };
};
3.2 ステップ 2: Portalを利用したTooltipコンポーネント
ツールチップ本体のコンポーネントを作成し、カスタムフックと ReactDOM.createPortal を組み合わせて使用します。
TypeScript
// components/Tooltip.tsx
import { createPortal } from 'react-dom';
import { useTooltip } from '@/hooks/useTooltip';
// import { TooltipProps } from '...'; // 型定義をインポート
const Tooltip: React.FC<TooltipProps> = ({ content, children, position }) => {
const { isVisible, coords, triggerRef, handleMouseEnter, handleMouseLeave } = useTooltip(position);
// children要素にRefとイベントハンドラーを適用
const triggerElement = React.cloneElement(children, {
ref: triggerRef,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
'aria-describedby': isVisible ? 'tooltip-id' : undefined, // A11y対応
});
// ツールチップの内容(Portalでレンダリングされる)
const TooltipContent = coords && (
<div
id="tooltip-id" // aria-describedby と対応
className="tooltip-content"
role="tooltip"
style={{
position: 'absolute',
left: coords.x,
top: coords.y,
// ... CSSによるオフセット調整
}}
>
{content}
</div>
);
return (
<>
{triggerElement}
{/* ツールチップを表示する場合のみPortalを使用し、body直下にレンダリング */}
{isVisible && createPortal(TooltipContent, document.body)}
</>
);
};
3.3 ステップ 3: 正確な位置決めとCSS(経験の提示)
位置決めロジックを useTooltip フック内で行い、CSSで微調整を加えます。
useLayoutEffectの活用:useEffectではなく、useLayoutEffectを使用することで、DOMが描画された直後に座標計算が行われ、ツールチップのチラつきを防ぎます。- CSSでの調整: 上記のコードで計算された
coords.xはトリガー要素の中心点です。ツールチップ自体の幅(例: 100px)の半分だけ左に戻すCSS(transform: translateX(-50%);)を適用することで、完璧な中央揃えを実現します。
4. まとめとアクセシビリティ (A11y) への配慮
この方法では、React Portalとカスタムフックを活用することで、高性能で管理しやすいツールチップを実装できます。
| 要素 | 目的 | A11yの重要性 |
createPortal | スタイルや z-index の影響を回避 | – |
useLayoutEffect | 座標計算によるチラつき防止 | 高速なUX |
role="tooltip" | スクリーンリーダーにツールチップであることを伝達 | 必須 |
aria-describedby | トリガー要素とツールチップを関連付け | 必須 |
**Web開発におけるツールチップ実装では、単に見栄えが良いだけでなく、アクセシビリティを確保する設計が、現代の開発者の必須要件です。**この実装パターンを応用し、クリーンで使いやすいUIを実現しましょう。

コメント