✈️
Tooltipはposition: absolute or fixed ?
前書き
今では当たり前に存在するTooltipと呼ばれるUIパーツ、フロントエンジニアであればライブラリを使用せず1から実装をしている方もいらっしゃるかと思います。
Tooltipを作る際、Css propertyのposition: absolute;
もしくは position: fixed;
値を利用して、配置ロジックを実装するのがよくあるパターンかと思いますが、それぞれのメリット、デメリットについて、考察していきます。
前提
そもそもTooltipとは
ここでいうTooltipとは以下のようなUIを指します。
UX定義
- あるパーツをクリックしたら開く
- 開く位置は、トリガーとしたパーツのすぐ側となる
- 開いた状態で、Tooltip領域以外をクリックしたら閉じる
position: absolute;
メリット
配置ロジックの構築が容易
- 開閉トリガーパーツをラップする親要素を用意
- 親要素に
position: relative
を指定 - その子要素としてTooltipを定義する形で構成
- 相対位置で指定可能
<div class="tooltip-container">
<button onClick={open} />
<div class="tooltip">
</div>
.tooltip-container {
position: relative;
.tooltip {
position: absolute;
top: 16px;
left: 16px;
}
}
デメリット
overflow: scroll or auto;
下の要素配下で定義されていると、Tooltipが見切れるケースが起きる
- 以下のような状態
position: fixed;
メリット
あらゆるケースにおいて、違和感ない配置が可能
- absolute時に問題の起きたケースにも対応が可能となる。
デメリット
配置ロジックの構築が難
- fixedでは画面全体を基準とした絶対位置での指定を行う必要がある。
- ここでの要件を例にすると、以下のようなロジックが必要となる。
- トリガーパーツの側に表示させる
- そのためにまず、トリガーパーツの位置情報を取得する
- その値から配置場所を計算する
- 求めた配置に表示するロジック
トリガーパーツの位置に追従しない
- 例えば、Tooltipが開いた状態でトリガーパーツがスクロールによって移動された場合に、Tooltipは位置が固定されているため、その場所に留まってしまう。
- 頑張ればスクロール量を計算して、Tooltipの位置を追従して更新させることも可能ではあるが、、
見解
- 基本的には、Tooltipを構成する際は、
position: fixed;
で対応するのが良いと考えます。 - 理由としては以下。
- 場所を選ばず配置できるのが汎用コンポーネントの理想系である
- トリガーパーツの位置に追従しない問題はUX要件次第で対処可能
- 配置ロジックの構築はそこまで難しいものでもない(主観です)
2. トリガーパーツの位置に追従しない問題はUX要件次第で対処可能
- 開いている時にスクロールされることが問題
- → 閉じるまでスクロールをさせなければ解決
例えば、開いている時は背後にoverlayを貼ると、スクロールやその他別パーツへの余計な操作を抑制できます。
3. 配置ロジックの構築はそこまで難しいものでもない
- Vanilla jsなら
document.querySelector
で少し込みいった実装がいるが、reactやvueならref
を使用すれば比較的容易に実装が可能です。 - reactになりますが、記事の最後にコード例を載せてみます。
後書き
今ではよく見るTooltipの実装について考察してみました。
ご意見やツッコミ等大大歓迎です。
おまけ
自分なりのposition: fixed;
なTooltipのコードを載せてみます。(React + TypeScript)
- ここでの配置ロジックは
-
is〇〇
: trueならTooltipの対応する方向の端が、トリガーパーツに沿うような位置になる -
〇〇Offset
: 対応する方向のoffset
-
Tooltip.tsx
import { useEffect, useState } from 'react';
import type { FC } from 'react';
import styles from './index.module.scss';
type Props = {
/** 表示位置の基準となる要素 */
basisRef: HTMLElement | null;
/** 開閉状態 */
isOpen: boolean;
/** クローズ関数 */
onClose: () => void;
/** 表示位置の基準要素に対する、位置調整オプション */
isTop?: boolean;
isBottom?: boolean;
isLeft?: boolean;
isRight?: boolean;
topOffset?: number;
bottomOffset?: number;
leftOffset?: number;
rightOffset?: number;
};
export const Tooltip: FC<Props> = ({
basisRef,
isOpen,
onClose,
isTop,
isBottom,
isLeft,
isRight,
topOffset,
bottomOffset,
leftOffset,
rightOffset,
}) => {
const [basisRect, setBasisRect] = useState<DOMRect>();
useEffect(() => {
if (!isOpen) {
return;
}
if (basisRef === null) {
return;
}
setBasisRect(basisRef.getBoundingClientRect());
}, [isOpen, basisRef]);
if (!isOpen) {
return null;
}
return (
<>
<div className={styles['tooltip-overlay']} onClick={onClose} />
<div
className={styles['tooltip-container']}
role='tooltip'
style={{
top: isTop ? `${(basisRect?.top || 0) + (basisRect?.height || 0) + (topOffset || 0)}px` : '',
bottom: isBottom
? `${
document.body.clientHeight - (basisRect?.bottom || 0) - (basisRect?.height || 0) + (bottomOffset || 0)
}px`
: '',
left: isLeft ? `${(basisRect?.left || 0) + (basisRect?.width || 0) + (leftOffset || 0)}px` : '',
right: isRight
? `${document.body.clientWidth - (basisRect?.right || 0) + (basisRect?.width || 0) + (rightOffset || 0)}px`
: '',
}}
>
<div>Tooltip Test</div>
<ul>
<li>option1</li>
<li>option2</li>
</ul>
</div>
</>
);
};
style.module.scss
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.tooltip-overlay {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
width: 100vw;
height: 100vh;
}
.tooltip-container {
position: fixed;
z-index: 1010;
min-width: 138px;
padding: 8px;
background-color: #fff;
filter: drop-shadow(0 0 20px rgb(0 0 0 / 15%));
border-radius: 8px;
animation: fade-in 0.25s cubic-bezier(0.17, 0.67, 0.79, 0.76);
}
Discussion