Reactで、ツールチップを画面の端からはみ出さずに出す方法
ツールチップを画面の端からはみ出さずに出すには、ボタンの位置とツールチップの大きさを測ってから置き場所を決めます。上に入らなければ下に回し、左右は画面の内側に収まるところまでずらします。ずらした分だけ矢印を逆に動かせば、矢印はボタンの真ん中を指したままです。
この記事では、その計算を小さなカスタムフックにまとめました。同じことをライブラリの 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 で上に出す、よくある書き方です。
.naive-wrap { position: relative; }
.tooltip.naive { position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); }
幅 240px のツールチップを、画面の左上・右上・左端・中央・右端のボタンと、overflow: hidden のカードの中のボタンで開き、ツールチップの四隅の位置を測りました。座標は画面の左上が (0,0) です。
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 に広がり、横にスクロールできるページになっていました。カードの中のボタンでは、ツールチップのうちカードより上に出た部分が、カードに切られて見えませんでした。

位置を計算するフック
ボタンとツールチップの位置は、getBoundingClientRect() で測ります。返る値は画面(ビューポート)の左上からの位置なので、ツールチップを position: fixed にすれば、計算した値をそのまま top と left に使えます。
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の違いと使い分け」で、描かれたフレームを記録して確かめました。
ツールチップの部品
フックを使う部品です。ボタンにポインターを乗せたときと、フォーカスしたときに開きます。
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 ずつ空くようにしました。
.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つのボタンで測りました。
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 を待って計算し直しているのは、このためです。
ボタンにフォーカスしてツールチップを開き、ページのスクロール位置を変えていきました。
ページを 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 のままです。

overflow: auto の箱の中にあるボタンでも試しました。箱の中のスクロールでは、ツールチップはボタンについてきました。
箱の中のボタンにフォーカス: ボタン top=261 → ツールチップは上(すき間 8px、left=8, right=248, 画面の幅 390)
箱を 50px スクロール: ボタン top=211 → ツールチップは上(すき間 8px、left=8, right=248, 画面の幅 390)
addEventListener の3つ目の引数の true は、ここで効いています。比べるため、検証用に addEventListener を差し替えて3つ目の引数を付けずに登録させ、同じ操作をすると、ページのスクロールにはついてきましたが、箱を 50px スクロールしたところで、ツールチップはボタンに重なりました。
箱を 50px スクロール: ボタン top=211 → ツールチップは重なる(すき間 NaNpx、left=8, right=248, 画面の幅 390)
箱の中のスクロールで起きた scroll のイベントは、window に普通に登録したリスナーには届かず、MDN の addEventListener のページで説明されている、キャプチャの段階で受け取るリスナーには届いた、ということです。
画面の幅を変えたときも試しました。右端のボタンのツールチップを開いたまま幅を 390px から 320px にすると、ツールチップは右端から 8px の位置に付き直しました。
幅 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 を入れれば足ります。
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 で書くのは、開け閉めと、ボタンとツールチップを結ぶ名前だけです。
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>
)}
</>
);
}
.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))で止まりました。
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 を使います。





