✈️

Tooltipはposition: absolute or fixed ?

2023/02/07に公開

前書き

今では当たり前に存在するTooltipと呼ばれるUIパーツ、フロントエンジニアであればライブラリを使用せず1から実装をしている方もいらっしゃるかと思います。
Tooltipを作る際、Css propertyのposition: absolute; もしくは position: fixed;値を利用して、配置ロジックを実装するのがよくあるパターンかと思いますが、それぞれのメリット、デメリットについて、考察していきます。

前提

そもそもTooltipとは

ここでいうTooltipとは以下のようなUIを指します。

UX定義

  • あるパーツをクリックしたら開く
  • 開く位置は、トリガーとしたパーツのすぐ側となる
  • 開いた状態で、Tooltip領域以外をクリックしたら閉じる

position: absolute;

メリット

配置ロジックの構築が容易

  1. 開閉トリガーパーツをラップする親要素を用意
  2. 親要素にposition: relativeを指定
  3. その子要素として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;で対応するのが良いと考えます。
  • 理由としては以下。
  1. 場所を選ばず配置できるのが汎用コンポーネントの理想系である
  2. トリガーパーツの位置に追従しない問題はUX要件次第で対処可能
  3. 配置ロジックの構築はそこまで難しいものでもない(主観です)

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