Reactで、ツールチップを画面の端からはみ出さずに出す方法

​​React​createPortal,CSS,Floating UI,Playwright,TypeScript,useLayoutEffect

ツールチップを画面の端からはみ出さずに出すには、ボタンの位置とツールチップの大きさを測ってから置き場所を決めます。上に入らなければ下に回し、左右は画面の内側に収まるところまでずらします。ずらした分だけ矢印を逆に動かせば、矢印はボタンの真ん中を指したままです。

この記事では、その計算を小さなカスタムフックにまとめました。同じことをライブラリの Floating UI でする書き方と、CSS のアンカー位置指定だけでする書き方も載せ、3つとも同じ位置に出ることを確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、@floating-ui/react-dom 2.1.9、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で本番用に変換し、Playwright で操作した Chromium で、画面の大きさを 390×640px にして測りました。Firefox と Safari、スマートフォンの実機では試していません。

どの書き方が向いているか

この記事で作ったフック(以下、自作のフック)、Floating UI、CSS のアンカー位置指定のどれを選ぶかは、矢印が要るか、対応するブラウザ、transform の付いた要素の中で使うか、開いている途中で大きさが変わるか、の4つを見ると選べます。

  • 矢印が要らず、新しいブラウザだけに対応すればよい:CSS のアンカー位置指定がいちばん短く書けます(「CSS のアンカー位置指定だけで置く場合」の節)。
  • 矢印を付けたい、古いブラウザにも対応したい、transform の付いた要素の中で使う:自作のフックか Floating UI を使います(「位置を計算するフック」の節から)。
  • 開いている途中で、ツールチップの大きさが変わる:Floating UI が向いています(「Floating UI を使う場合」の節)。

CSS の版はツールチップをボタンの隣に描くので、祖先の transform の影響を受けます。カードに transform を付けると、ボタンに重なりました。ほかの2つは、ツールチップを body に出しています(transform を付けて試したのは、自作のフックだけです)。また、自作のフックはスクロールと画面の大きさの変化のときにしか計算し直しませんが、Floating UI はドキュメントによると、要素の大きさが変わったときも計算し直します。

3つの書き方は、次の節で説明する6つのボタンで同じように測って比べています。

真上に置くだけだと、端で切れる

比べるために、まず CSS だけでボタンの真上の真ん中に置く版を作りました。ボタンを position: relative の要素で包み、ツールチップを position: absolute で上に出す、よくある書き方です。

css
.naive-wrap { position: relative; }
.tooltip.naive { position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); }

幅 240px のツールチップを、画面の左上・右上・左端・中央・右端のボタンと、overflow: hidden のカードの中のボタンで開き、ツールチップの四隅の位置を測りました。座標は画面の左上が (0,0) です。

text
top-left  上に表示 左上(-84,-40) 右下(156,11) はみ出し:左に84px、上に40px
top-right 上に表示 左上(234,-40) 右下(474,11) はみ出し:右に84px、上に40px ページの横幅:474px
left      上に表示 左上(-84,208) 右下(156,259) はみ出し:左に84px
center    上に表示 左上(75,208) 右下(315,259) はみ出し:なし
right     上に表示 左上(234,208) 右下(474,259) はみ出し:右に84px ページの横幅:474px
in-card   上に表示 左上(-36,389) 右下(204,440) はみ出し:左に37px

(出力は、必要な列だけを残しています。ページの横幅は、474px になった行にだけ書いています)

中央のボタン以外は、どれも画面の外にはみ出しました。右にはみ出したときは、ページの横幅が 390px から 474px に広がり、横にスクロールできるページになっていました。カードの中のボタンでは、ツールチップのうちカードより上に出た部分が、カードに切られて見えませんでした。

上の画面では、右上のボタンのツールチップが画面の上と右で切れ、右端のボタンのツールチップも右で切れている。下の画面では、右上のボタンのツールチップはボタンの下に回り、右端のボタンのツールチップは画面の内側にずれて、どちらも矢印がボタンを指している
右上と右端のボタンでツールチップを開いたところ(上が CSS で真上に置いただけの版、下がこの記事のフックで位置を計算した版)

位置を計算するフック

ボタンとツールチップの位置は、getBoundingClientRect() で測ります。返る値は画面(ビューポート)の左上からの位置なので、ツールチップを position: fixed にすれば、計算した値をそのまま top と left に使えます。

ts
import { useLayoutEffect, useState, type RefObject } from 'react';

export type TooltipPosition = {
  top: number;
  left: number;
  arrowLeft: number;
  side: 'top' | 'bottom';
};

const GAP = 8; // ボタンとツールチップのすき間
const MARGIN = 8; // 画面の端から空ける幅
const ARROW_INSET = 12; // 矢印をツールチップの角に寄せすぎない

// anchorRef の要素の上(入らなければ下)に、画面からはみ出さない位置を計算する
export function useTooltipPosition(
  anchorRef: RefObject<HTMLElement | null>,
  tipRef: RefObject<HTMLElement | null>,
) {
  const [pos, setPos] = useState<TooltipPosition | null>(null);

  useLayoutEffect(() => {
    function update() {
      const anchor = anchorRef.current?.getBoundingClientRect();
      const tip = tipRef.current?.getBoundingClientRect();
      if (!anchor || !tip) return;
      // スクロールバーを除いた、見えている範囲の幅と高さ
      const vw = document.documentElement.clientWidth;
      const vh = document.documentElement.clientHeight;

      // 上に入らず、下のほうが広く空いていれば、下に出す
      const fitsTop = anchor.top - GAP - tip.height >= MARGIN;
      const side = fitsTop || anchor.top >= vh - anchor.bottom ? 'top' : 'bottom';
      const top = side === 'top' ? anchor.top - GAP - tip.height : anchor.bottom + GAP;

      // ボタンの真ん中に合わせてから、左右の端に収まるようにずらす
      const center = anchor.left + anchor.width / 2;
      const left = Math.min(Math.max(center - tip.width / 2, MARGIN), vw - tip.width - MARGIN);

      // 矢印は、ずらした分だけ逆に動かして、ボタンの真ん中を指す
      const arrowLeft = Math.min(Math.max(center - left, ARROW_INSET), tip.width - ARROW_INSET);

      setPos({ top, left, arrowLeft, side });
    }

    update();
    // capture: true にすると、ページの中のスクロールする箱のスクロールも受け取れる
    window.addEventListener('scroll', update, true);
    window.addEventListener('resize', update);
    return () => {
      window.removeEventListener('scroll', update, true);
      window.removeEventListener('resize', update);
    };
  }, [anchorRef, tipRef]);

  return pos;
}

画面の幅には window.innerWidth ではなく document.documentElement.clientWidth を使いました。MDN の clientWidth のページにあるとおり、<html> 要素の clientWidth は、スクロールバーを除いた画面の幅です。スクロールバーのある画面でも、ツールチップがスクロールバーの下にもぐらないようにできます。

上に入らないときも、すぐ下に回さず、下のほうが広いときだけ下に回します。上にも下にも入らないときに、少しでも広いほうに出すためです。左右は、真ん中に置いた位置を Math.max と Math.min で「左端から 8px」と「右端から 8px」のあいだに収めます。

計算は useLayoutEffect の中でします。useLayoutEffect のリファレンスには、ツールチップをいったんどこかに描いてから高さを測り、置き場所を決める例があります。useLayoutEffect はブラウザが画面を描く前に動くので、測るための仮の位置は画面に出ません。useEffect を使った場合との違いは、以前の記事「ReactのuseEffectとuseLayoutEffectの違いと使い分け」で、描かれたフレームを記録して確かめました。

ツールチップの部品

フックを使う部品です。ボタンにポインターを乗せたときと、フォーカスしたときに開きます。

tsx
import { useId, useRef, useState, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
import { useTooltipPosition } from './useTooltipPosition';

// ボタンにポインターを乗せるか、フォーカスしたあいだだけ、ツールチップを出す
export function Tooltip({ content, children }: { content: ReactNode; children: ReactNode }) {
  const [open, setOpen] = useState(false);
  const anchorRef = useRef<HTMLButtonElement>(null);
  const id = useId();

  return (
    <>
      <button
        ref={anchorRef}
        aria-describedby={open ? id : undefined}
        onPointerEnter={() => setOpen(true)}
        onPointerLeave={() => setOpen(false)}
        onFocus={() => setOpen(true)}
        onBlur={() => setOpen(false)}
      >
        {children}
      </button>
      {open && createPortal(<Bubble id={id} anchorRef={anchorRef}>{content}</Bubble>, document.body)}
    </>
  );
}

function Bubble({ id, anchorRef, children }: {
  id: string;
  anchorRef: RefObject<HTMLElement | null>;
  children: ReactNode;
}) {
  const tipRef = useRef<HTMLDivElement>(null);
  const pos = useTooltipPosition(anchorRef, tipRef);

  return (
    <div
      ref={tipRef}
      id={id}
      role="tooltip"
      className="tooltip"
      data-side={pos?.side}
      // 位置が決まるまでは、見えないまま左上に置いて大きさを測る
      style={pos ? { top: pos.top, left: pos.left } : { top: 0, left: 0, visibility: 'hidden' }}
    >
      {children}
      <span className="arrow" style={{ left: pos?.arrowLeft }} />
    </div>
  );
}

ツールチップは createPortal で document.body に出しています。リファレンスにも、ポータルを使うとモーダルやツールチップを、それを囲む要素の外に出せると書かれています。

position: fixed にしただけでも、overflow: hidden のカードには切られません(このあとの CSS の版で確かめています)。ポータルが役に立つのは、たとえば祖先の要素に transform などが付いているときです。MDN の position のページにあるとおり、祖先に transform、perspective、filter が付いていると、position: fixed の基準は画面ではなく、その祖先になります。カードに transform: translateZ(0) を付けて試すと、body に出したこの版のツールチップは、付ける前と同じ位置でした。

開いているあいだだけ Bubble を描くので、スクロールのリスナーも開いているあいだだけ付きます。ボタンには aria-describedby でツールチップの id を結び、ツールチップには role="tooltip" を付けました。MDN の tooltip ロールのページでは、ほかに、ポインターをツールチップの上に動かしても閉じないことと、Esc キーで閉じることも求めています。この記事では置き場所に絞ったので、この2つは作っていません。

見た目の CSS のうち、位置に関わる部分です。幅は 240px までにし、画面が狭いときも、左右に 8px ずつ空くようにしました。

css
.tooltip {
  position: fixed; z-index: 10; box-sizing: border-box;
  width: max-content; max-width: min(240px, calc(100vw - 16px));
  padding: 6px 10px; border-radius: 6px; background: #1f2a30; color: #fff; font-size: 13px;
}
.tooltip .arrow { position: absolute; width: 10px; height: 10px; background: inherit; transform: translateX(-50%) rotate(45deg); }
.tooltip[data-side^="top"] .arrow { bottom: -5px; }
.tooltip[data-side^="bottom"] .arrow { top: -5px; }

矢印は 10px の正方形を45度回したもので、フックが返した arrowLeft を矢印の真ん中の位置として使います。ツールチップが上にあるときは下の辺に、下にあるときは上の辺に付けます。

端に置いたボタンで比べた結果

真上に置くだけの版と同じ6つのボタンで測りました。

text
top-left  下に表示 左上(8,57) 右下(248,108) はみ出し:なし 矢印とボタンの中心の差:0px
top-right 下に表示 左上(142,57) 右下(382,108) はみ出し:なし 矢印とボタンの中心の差:0px
left      上に表示 左上(8,201) 右下(248,252) はみ出し:なし 矢印とボタンの中心の差:0px
center    上に表示 左上(75,201) 右下(315,252) はみ出し:なし 矢印とボタンの中心の差:0px
right     上に表示 左上(142,201) 右下(382,252) はみ出し:なし 矢印とボタンの中心の差:0px
in-card   上に表示 左上(8,382) 右下(248,433) はみ出し:なし 矢印とボタンの中心の差:0px

(出力は、必要な列だけを残しています)

画面の上の端にある左上と右上のボタンでは、ツールチップがボタンの下に回りました。左端と右端では、画面の端から 8px の位置で止まっています。どのボタンでも、矢印の真ん中とボタンの真ん中の横の位置は同じでした。カードの中のボタンでも、カードより上に出た部分は切れずに見えていました。ページの横幅は、どのボタンでも 390px のままでした。

開いたままスクロールしても、ついてくる

ツールチップを開いたままページをスクロールすると、ボタンは動きますが、position: fixed のツールチップはそのままでは動きません。フックで scroll と resize を待って計算し直しているのは、このためです。

ボタンにフォーカスしてツールチップを開き、ページのスクロール位置を変えていきました。

text
ページを 400px スクロール: ボタン top=300 → ツールチップは上(すき間 8px、left=8, right=248, 画面の幅 390)
ページを 600px スクロール: ボタン top=100 → ツールチップは上(すき間 8px、left=8, right=248, 画面の幅 390)
ページを 680px スクロール: ボタン top=20 → ツールチップは下(すき間 8px、left=8, right=248, 画面の幅 390)

ボタンが画面の上の端に近づくと、ツールチップは下に回りました。どの位置でも、ボタンとのすき間は 8px のままです。

ボタンにフォーカスしてツールチップを出したまま、ページを 60px ずつ3回下にスクロールすると、ボタンが上の端に近づいたところでツールチップがボタンの下に回り、位置の表示が「ボタンの下」に変わる。4回目に 60px 上に戻すと、ボタンの上に戻る
ツールチップを開いたままスクロールしたときの動き(下の帯は検証用に足した表示。位置が変わった瞬間をピンクで示す)

overflow: auto の箱の中にあるボタンでも試しました。箱の中のスクロールでは、ツールチップはボタンについてきました。

text
箱の中のボタンにフォーカス: ボタン top=261 → ツールチップは上(すき間 8px、left=8, right=248, 画面の幅 390)
箱を 50px スクロール: ボタン top=211 → ツールチップは上(すき間 8px、left=8, right=248, 画面の幅 390)

addEventListener の3つ目の引数の true は、ここで効いています。比べるため、検証用に addEventListener を差し替えて3つ目の引数を付けずに登録させ、同じ操作をすると、ページのスクロールにはついてきましたが、箱を 50px スクロールしたところで、ツールチップはボタンに重なりました。

text
箱を 50px スクロール: ボタン top=211 → ツールチップは重なる(すき間 NaNpx、left=8, right=248, 画面の幅 390)

箱の中のスクロールで起きた scroll のイベントは、window に普通に登録したリスナーには届かず、MDN の addEventListener のページで説明されている、キャプチャの段階で受け取るリスナーには届いた、ということです。

画面の幅を変えたときも試しました。右端のボタンのツールチップを開いたまま幅を 390px から 320px にすると、ツールチップは右端から 8px の位置に付き直しました。

text
幅 390: ボタン top=260 → ツールチップは上(すき間 8px、left=142, right=382, 画面の幅 390)
幅 320 に変えたあと: ボタン top=260 → ツールチップは上(すき間 8px、left=72, right=312, 画面の幅 320)

Floating UI を使う場合

自分で計算する代わりに、ライブラリの Floating UI を使うこともできます。位置決めだけなら、Floating UI の React のページにあるとおり、@floating-ui/react-dom を入れれば足ります。

tsx
import { useId, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useFloating, autoUpdate, offset, flip, shift, arrow } from '@floating-ui/react-dom';

// Floating UI で位置を決める版
export function FloatingTooltip({ content, children }: { content: ReactNode; children: ReactNode }) {
  const [open, setOpen] = useState(false);
  const arrowRef = useRef<HTMLSpanElement>(null);
  const id = useId();
  const { refs, floatingStyles, placement, middlewareData } = useFloating({
    strategy: 'fixed',
    placement: 'top',
    middleware: [
      offset(8),
      flip({ padding: 8 }),
      shift({ padding: 8 }),
      arrow({ element: arrowRef, padding: 8 }),
    ],
    whileElementsMounted: autoUpdate,
  });

  return (
    <>
      <button
        ref={refs.setReference}
        aria-describedby={open ? id : undefined}
        onPointerEnter={() => setOpen(true)}
        onPointerLeave={() => setOpen(false)}
        onFocus={() => setOpen(true)}
        onBlur={() => setOpen(false)}
      >
        {children}
      </button>
      {open && createPortal(
        <div ref={refs.setFloating} id={id} role="tooltip" className="tooltip floating" data-side={placement} style={floatingStyles}>
          {content}
          <span ref={arrowRef} className="arrow" style={{ left: middlewareData.arrow?.x }} />
        </div>,
        document.body,
      )}
    </>
  );
}

自作のフックでしていたことが、middleware の1つずつに当たります。offset がボタンとのすき間、flip が上に入らないときに下へ回すこと、shift が左右にずらすこと、arrow が矢印の位置です。flip のページには、shift と一緒に使うときは flip を先に書くようにとあります。

矢印の扱いは自作の版と少し違います。arrow のページのとおり、middlewareData.arrow.x は矢印の要素の左端の位置なので、この版だけ CSS で translateX(-50%) を外しました(.tooltip.floating .arrow { transform: rotate(45deg); })。

スクロールや画面の幅の変化に合わせて計算し直すのは、whileElementsMounted に渡した autoUpdate です。ドキュメントによると、スクロールする箱のスクロールに加えて、ボタンやツールチップの大きさが変わったときも計算し直します。自作のフックは scroll と resize しか待っていないので、開いている途中でツールチップの中身が変わって大きさが変わっても、計算し直しません。そうした使い方があるなら、こちらが向いています。

同じ6つのボタンとスクロールの操作で測った結果は、自作の版と同じ位置になりました。ページを 680px スクロールしてツールチップが下に回ったときだけ、すき間が 9px と出ています。丸める前の値は 8.5px で、自作の版(8px)より 0.5px 広くなっていました。

CSS のアンカー位置指定だけで置く場合

CSS のアンカー位置指定(anchor positioning)を使うと、位置の計算をブラウザに任せられます。React で書くのは、開け閉めと、ボタンとツールチップを結ぶ名前だけです。

tsx
import { useId, useState, type ReactNode } from 'react';

// CSS のアンカー位置指定で置く版(React は開け閉めだけ)
export function CssAnchorTooltip({ content, children }: { content: ReactNode; children: ReactNode }) {
  const [open, setOpen] = useState(false);
  const id = useId();
  // useId の値には CSS の名前に使えない文字が入りうるので、英数字だけにする
  const anchorName = `--tip-${id.replace(/[^a-zA-Z0-9]/g, '')}`;

  return (
    <>
      <button
        style={{ anchorName }}
        aria-describedby={open ? id : undefined}
        onPointerEnter={() => setOpen(true)}
        onPointerLeave={() => setOpen(false)}
        onFocus={() => setOpen(true)}
        onBlur={() => setOpen(false)}
      >
        {children}
      </button>
      {open && (
        <div id={id} role="tooltip" className="tooltip css-anchor" style={{ positionAnchor: anchorName }}>
          {content}
        </div>
      )}
    </>
  );
}
css
.tooltip.css-anchor {
  position-area: top; margin: 8px;
  position-try-fallbacks: flip-block;
}

ボタンの anchor-name と、ツールチップの position-anchor に同じ名前を書くと、ツールチップの置き場所がボタンを基準に決まります。position-area の top はボタンの上で、top span-all と同じ意味です。position-try-fallbacks の flip-block は、上に収まらないときに、上下を入れ替えた位置を試す指定です。

この版は、ポータルを使わずボタンの隣に描いていますが、6つのボタンとスクロールの操作で、自作の版と同じ位置になりました。position: fixed なので、カードの中のボタンでも切れていません。左右の位置は、何も指定していないのに画面の内側に収まり、margin: 8px の分だけ端から離れました。margin を上下だけ(8px 0)にしていたときは、左右は画面の端にぴったり付いた位置(左端のボタンで左上が (0,201))で止まりました。

text
top-left  下に表示 左上(8,57) 右下(248,108) はみ出し:なし
right     上に表示 左上(142,201) 右下(382,252) はみ出し:なし
in-card   上に表示 左上(8,382) 右下(248,433) はみ出し:なし

(出力は、必要な行と列だけを残しています)

ただし、この版には矢印を付けていません。上と下のどちらに出たかを React の側では知らないので、自作の版のように data-side で矢印の向きを切り替えられないためです。ボタンの隣に描いているので、祖先の transform の影響も受けます。カードに transform: translateZ(0) を付けると、この版のツールチップはカードの中に収まる位置(左上が (25,409)、右下が (265,460))に出て、ボタン(上の端が 441)に重なりました。

また、MDN の position-area の対応表と position-try-fallbacks の対応表によると、この2つのプロパティが両方使えるのは Chrome 129、Firefox 147、Safari 26 からです。それより古いブラウザにも対応するなら、自作のフックか Floating UI を使います。

​Posted by 86