Back

Reactにおける軽量なTooltipアプローチ

Reactにおける軽量なTooltipアプローチ

Tooltipが必要になる場面があります。アイコンボタンのヒントや、省略されたラベルのコンテキスト表示などです。完全なUIフレームワークやTippy.jsのようなレガシーライブラリに手を伸ばす前に、実際に必要なコードがいかに少ないかを考えてみましょう。

このガイドでは、Reactにおける軽量なTooltipパターンを解説します。最もシンプルなネイティブアプローチから、最小限のカスタムフック、Floating UIのようなヘッドレスTooltipライブラリまで、各ステップでバンドルサイズを肥大化させることなく機能を追加していきます。

重要なポイント

  • ネイティブのtitle属性はコストゼロでTooltipを提供しますが、スタイリングとキーボードサポートに欠けます
  • CSS-onlyのTooltipはJavaScriptなしでスタイリングとフォーカス状態を提供しますが、ビューポート衝突を処理できません
  • 最小限のカスタムフックはバンドルサイズを小さく保ちながらプログラマティックな制御を提供します
  • Floating UIは約3kBで衝突検出とビューポート認識を提供します
  • 適切なARIA属性を使用し、重要な情報をTooltipのみに配置しないことで、常にアクセシビリティを優先してください

ネイティブのTitle属性から始める

ブラウザ組み込みのtitle属性は、JavaScriptゼロのベースラインです:

<button title="Save your changes">💾</button>

これは機能しますが、制限があります。Tooltipは遅延後に表示され、スタイルを変更できず、キーボードフォーカス時には表示されません。それでも、本当にシンプルなケースでは、コストはかかりません。

CSS-onlyのReact Tooltip

JavaScriptロジックなしでスタイル付きTooltipを実現するには、CSSで基本的なホバー状態を処理します:

function IconButton({ label, children }) {
  return (
    <button className="tooltip-trigger" aria-describedby="tooltip-id">
      {children}
      <span className="tooltip" id="tooltip-id" role="tooltip">{label}</span>
    </button>
  )
}
.tooltip-trigger {
  position: relative;
}

.tooltip {
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s;
}

.tooltip-trigger:hover .tooltip,
.tooltip-trigger:focus .tooltip {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .tooltip {
    transition: none;
  }
}

このCSS-onlyパターンはprefers-reduced-motionを尊重し、ホバーとフォーカスの両方でトリガーされます。React Tooltipのアクセシビリティについては、role="tooltip"とボタンのaria-labelがスクリーンリーダーのコンテキストを提供します。

制限は何でしょうか?衝突検出がありません。Tooltipがビューポートの端近くに配置されると、切り取られてしまいます。

最小限のカスタムフック

ライブラリなしでプログラマティックな制御が必要な場合、小さなフックがうまく機能します:

import { useState, useCallback } from 'react'

function useTooltip() {
  const [isOpen, setIsOpen] = useState(false)

  const triggerProps = {
    onMouseEnter: useCallback(() => setIsOpen(true), []),
    onMouseLeave: useCallback(() => setIsOpen(false), []),
    onFocus: useCallback(() => setIsOpen(true), []),
    onBlur: useCallback(() => setIsOpen(false), []),
  }

  return { isOpen, triggerProps }
}

使用方法:

function TooltipButton({ hint, children }) {
  const { isOpen, triggerProps } = useTooltip()
  const id = `tooltip-${React.useId()}`

  return (
    <span style={{ position: 'relative', display: 'inline-block' }}>
      <button {...triggerProps} aria-describedby={isOpen ? id : undefined}>
        {children}
      </button>
      {isOpen && (
        <span id={id} role="tooltip" className="tooltip">
          {hint}
        </span>
      )}
    </span>
  )
}

aria-describedbyの関係性により、ボタンとそのTooltipが支援技術のために接続されます。これは重要です:Tooltipはアクセシブルなラベルを補完するものであり、置き換えるものではありません。重要な情報をTooltipのみに配置しないでください。

ヘッドレスTooltipライブラリ: Floating UI

適切な配置(衝突検出、反転、シフト)が必要な場合、Floating UIが現代的な選択肢です。これはPopper.jsの後継で、約3kBの重さです。

Floating UIのReact Tooltipはスタイリングについての意見を持たないプリミティブを提供します:

import {
  useFloating,
  offset,
  flip,
  shift,
  useHover,
  useFocus,
  useInteractions,
} from '@floating-ui/react'
import { useState } from 'react'

function Tooltip({ label, children }) {
  const [isOpen, setIsOpen] = useState(false)

  const { refs, floatingStyles, context } = useFloating({
    open: isOpen,
    onOpenChange: setIsOpen,
    middleware: [offset(6), flip(), shift()],
  })

  const hover = useHover(context)
  const focus = useFocus(context)
  const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus])

  return (
    <>
      <span ref={refs.setReference} {...getReferenceProps()}>
        {children}
      </span>
      {isOpen && (
        <div
          ref={refs.setFloating}
          style={floatingStyles}
          role="tooltip"
          {...getFloatingProps()}
        >
          {label}
        </div>
      )}
    </>
  )
}

これはビューポートの境界を自動的に処理します。flipミドルウェアはスペースが不足したときに再配置し、shiftは軸に沿ってTooltipを表示し続けます。

SSRの安全性については、Floating UIのフックはレンダリング中にwindowdocumentにアクセスしません。カスタムソリューションを構築する場合は、これらの参照をuseEffectまたはuseLayoutEffect内で保護してください。

Portalを使用するタイミング

Tooltipの親要素にoverflow: hiddenや複雑なスタッキングコンテキストがある場合、Tooltipをportalでレンダリングします:

import { createPortal } from 'react-dom'

{isOpen && createPortal(
  <div ref={refs.setFloating} style={floatingStyles} role="tooltip">
    {label}
  </div>,
  document.body
)}

これにより、クリッピングコンテナから脱出できます。Floating UIは、TooltipがDOM内のどこにレンダリングされても配置を処理します。

なぜTippy.jsではないのか?

Tippy.jsとreact-popperは長年にわたって優れたサービスを提供してきましたが、事実上レガシーです。Floating UIは同じ配置エンジンを提供しながら、より小さなフットプリントとフックによる優れたReact統合を実現しています。新しいプロジェクトでは、Floating UIやRadix UI TooltipのようなヘッドレスTooltipライブラリが実用的な選択肢です。

アプローチの選択

必要性に応じて複雑さを合わせます:

  • ネイティブtitle: コストゼロ、制御ゼロ
  • CSS-only: スタイル付き、アクセシブル、配置ロジックなし
  • カスタムフック: 完全な制御、最小限のコード、手動配置
  • Floating UI: 衝突検出、ビューポート認識、約3kB

まとめ

Tooltipは重くある必要はありません。シンプルに始め、ユースケースが要求するときに機能を追加し、すべての実装の中心にアクセシビリティを置いてください。ネイティブのtitle属性は基本的なケースで機能し、CSS-onlyソリューションはスタイリングニーズを処理し、衝突検出が必要な場合、Floating UIはレガシーライブラリの肥大化なしで提供します。

よくある質問

aria-describedbyを使用して、トリガー要素とTooltipコンテンツを接続します。Tooltip要素自体にrole tooltipを追加します。Tooltipテキストは簡潔で補足的なものにしてください。キーボードやスクリーンリーダーに依存するユーザーは、遅延またはホバーのみのコンテンツを見逃す可能性があるため、重要な情報をTooltipのみに配置しないでください。

CSS-onlyのTooltipには衝突検出がありません。Tooltipはビューポートの境界をチェックせずに親要素に対して相対的に配置されます。flipとshiftミドルウェアを使用したFloating UIを使用して、Tooltipがクリップされる場合に自動的に再配置します。または、overflow hiddenコンテナから脱出するためにTooltipをportalでレンダリングします。

Floating UIが新しいプロジェクトには推奨される選択肢です。これはTippy.jsを動かしていたPopper.jsの後継であり、フックによる優れたReact統合で約3kBのより小さなバンドルサイズを提供します。Tippy.jsはまだ機能しますが、レガシーと見なされており、現代の代替案よりも重いです。

Tooltipの親要素にoverflow hidden、overflow scroll、または切り取りを引き起こす複雑なスタッキングコンテキストがある場合にportalを使用します。Portalはdocument.bodyに直接Tooltipをレンダリングし、コンテナの制限から脱出します。Floating UIは、TooltipがDOMツリーのどこに表示されても、配置を正しく処理します。

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay