💭

Floating UIでツールチップを簡単に作成する

2023/09/05に公開

Floating UIはツールチップやポップオーバーなどのフローティング要素を簡単に作成できるライブラリです。Popperというライブラリから分岐し、より小さいサイズで直感的に扱えるように改善されたとドキュメントで紹介されています。

現職では元々Popperを使っておりFloating UIに移行したのですが、紹介の通り手軽にフローティング要素を作成でき、複雑な挙動にも対応できる優れたライブラリでした!

今回はツールチップの作成を通して、Floating UIの使い方をご紹介します。

https://floating-ui.com/

本記事のGoal

本記事では以下の要件を満たすツールチップを作成します。

  • 受け取ったchildrenを起点に、ホバー時にツールチップを表示する
  • childrenに向けて矢印を表示し、吹き出しのUIにできる
  • childrenの上下左右の位置を指定してツールチップを表示できる

今回作成したツールチップのコードはこちらに公開しておりますので、最終的なコードのみを見たい方はこちらからご確認ください!
https://github.com/masayaO/react-floating-ui/tree/main

ツールチップを表示する

まずは常に表示される状態でツールチップを実装してみます。useStateで状態を定義し、useFloatingの引数に渡します。返り値として、refsとfloatingStylesを受け取り、各要素に定義します。

  • refs.setReference: 起点となる要素の参照
  • refs.setFloating: フローティング要素の参照
  • floatingStyles: フローティング要素の配置スタイル
const [isOpen, setIsOpen] = useState(true); //常に表示するため、一時的にtrue
const { refs, floatingStyles } = useFloating({
  open: isOpen,
  onOpenChange: setIsOpen,
});

return (
  <>
    <div ref={refs.setReference}>{children}</div>
    {isOpen && (
      <div ref={refs.setFloating} style={floatingStyles}>
        {label}
      </div>
    )}
  </>
);

コード全文
import { offset, useFloating } from "@floating-ui/react";
import React, { useState } from "react";

const Tooltip = ({
  label,
  children,
}: {
  label: string;
  children: React.ReactNode;
}) => {
  const [isOpen, setIsOpen] = useState(true);
  const { refs, floatingStyles } = useFloating({
    open: isOpen,
    onOpenChange: setIsOpen,
    middleware: [offset(12)],
  });

  return (
    <>
      <div ref={refs.setReference}>{children}</div>
      {isOpen && (
        <div
          ref={refs.setFloating}
          style={{
            ...floatingStyles,
            padding: "4px 8px",
            backgroundColor: "black",
            color: "white",
            borderRadius: "8px",
            fontSize: "12px",
          }}
        >
          {label}
        </div>
      )}
    </>
  );
};

export default Tooltip;

ホバー時に表示する

useFloatingからcontextを受け取り、インタラクションフックを定義します。

  • useHover: ホバー時にツールチップの開閉を切り替える
  • useFocus: 参照要素がフォーカスされている時にツールチップの開閉を切り替える
  • useDismiss: escキーの入力時にツールチップを閉じる
  • useRole: ARIA属性を指定する

useInteractionsの引数に各インタラクションフックの返り値を渡します。最後にgetReferencePropsgetFloatingPropsを各要素に渡すことで、ホバーを始めたとしたインタラクションが利用できます。

const { refs, floatingStyles, context } = useFloating();
const hover = useHover(context);
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: "tooltip" });
const { getReferenceProps, getFloatingProps } = useInteractions([
  hover,
  focus,
  dismiss,
  role,
]);

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

コード全文
import {
  offset,
  useDismiss,
  useFloating,
  useFocus,
  useHover,
  useInteractions,
  useRole,
} from "@floating-ui/react";
import React, { useState } from "react";

const Tooltip = ({
  label,
  children,
}: {
  label: string;
  children: React.ReactNode;
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const { refs, floatingStyles, context } = useFloating({
    open: isOpen,
    onOpenChange: setIsOpen,
    middleware: [offset(12)],
  });
  const hover = useHover(context);
  const focus = useFocus(context);
  const dismiss = useDismiss(context);
  const role = useRole(context, { role: "tooltip" });
  const { getReferenceProps, getFloatingProps } = useInteractions([
    hover,
    focus,
    dismiss,
    role,
  ]);

  return (
    <>
      <div ref={refs.setReference} {...getReferenceProps()}>
        {children}
      </div>
      {isOpen && (
        <div
          ref={refs.setFloating}
          {...getFloatingProps()}
          style={{
            ...floatingStyles,
            padding: "4px 8px",
            backgroundColor: "black",
            color: "white",
            borderRadius: "8px",
            fontSize: "12px",
          }}
        >
          {label}
        </div>
      )}
    </>
  );
};

export default Tooltip;

表示位置を指定する

ツールチップを上下左右のどこに表示するかはuseFloatingにplacementを渡すことで指定できます。この際、middlewareにflip()を定義しておくことで、画面端などで指定した位置で表示できない場合に自動で位置調整してくれるので便利です!

const { refs, floatingStyles, context } = useFloating({
  placement: "top",
  middleware: [flip()],
});

コード全文
import {
  flip,
  offset,
  Placement,
  useDismiss,
  useFloating,
  useFocus,
  useHover,
  useInteractions,
  useRole,
} from "@floating-ui/react";
import React, { useState } from "react";

const Tooltip = ({
  label,
  children,
  placement = "top",
}: {
  label: string;
  children: React.ReactNode;
  placement?: Placement;
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const { refs, floatingStyles, context } = useFloating({
    placement: placement,
    open: isOpen,
    onOpenChange: setIsOpen,
    middleware: [offset(12), flip()],
  });
  const hover = useHover(context);
  const focus = useFocus(context);
  const dismiss = useDismiss(context);
  const role = useRole(context, { role: "tooltip" });
  const { getReferenceProps, getFloatingProps } = useInteractions([
    hover,
    focus,
    dismiss,
    role,
  ]);

  return (
    <>
      <div ref={refs.setReference} {...getReferenceProps()}>
        {children}
      </div>
      {isOpen && (
        <div
          ref={refs.setFloating}
          {...getFloatingProps()}
          style={{
            ...floatingStyles,
            padding: "4px 8px",
            backgroundColor: "black",
            color: "white",
            borderRadius: "8px",
            fontSize: "12px",
          }}
        >
          {label}
        </div>
      )}
    </>
  );
};

export default Tooltip;

矢印を表示する

最後に、吹き出しのようなUIにするために矢印を表示します。useRefで矢印用のrefを定義しmiddlewareのarrow()に渡します。FloatingArrowというコンポーネントが定義されているので、refとcontextを渡すことで、placementで指定した吹き出しの位置に応じて動的に矢印が表示されます。

const arrowRef = useRef(null);
const { refs, floatingStyles, context } = useFloating({
  middleware: [
    arrow({
      element: arrowRef,
    }),
  ],
});

return (
  <>
    <div ref={refs.setReference} {...getReferenceProps()}>
      {children}
    </div>
    {isOpen && (
      <div>
        {label}
        <FloatingArrow ref={arrowRef} context={context} />
      </div>
    )}
  </>
);

コード全文
import {
  arrow,
  flip,
  FloatingArrow,
  offset,
  Placement,
  useDismiss,
  useFloating,
  useFocus,
  useHover,
  useInteractions,
  useRole,
} from "@floating-ui/react";
import React, { useRef, useState } from "react";

const Tooltip = ({
  label,
  children,
  placement = "top",
}: {
  label: string;
  children: React.ReactNode;
  placement?: Placement;
}) => {
  const arrowRef = useRef(null);
  const [isOpen, setIsOpen] = useState(true);
  const { refs, floatingStyles, context } = useFloating({
    placement: placement,
    open: isOpen,
    onOpenChange: setIsOpen,
    middleware: [
      arrow({
        element: arrowRef,
      }),
      offset(12),
      flip(),
    ],
  });
  const hover = useHover(context);
  const focus = useFocus(context);
  const dismiss = useDismiss(context);
  const role = useRole(context, { role: "tooltip" });
  const { getReferenceProps, getFloatingProps } = useInteractions([
    hover,
    focus,
    dismiss,
    role,
  ]);

  return (
    <>
      <div ref={refs.setReference} {...getReferenceProps()}>
        {children}
      </div>
      {isOpen && (
        <div
          ref={refs.setFloating}
          {...getFloatingProps()}
          style={{
            ...floatingStyles,
            padding: "4px 8px",
            backgroundColor: "black",
            color: "white",
            borderRadius: "4px",
            fontSize: "12px",
          }}
        >
          {label}
          <FloatingArrow ref={arrowRef} context={context} />
        </div>
      )}
    </>
  );
};

export default Tooltip;

おわりに

いかがでしたでしょうか?特に表示位置の指定や画面端での自動調整などは自前で実装すると複雑な実装になりがちなので、ライブラリ側に任せることでUIの実装に集中できる点が便利だと感じました。

現職ではSelectコンポーネントで利用していますが、ツールチップやダイアログでもFloating UIで実装していく予定です!

もっと便利な使い方があればコメントで教えていただければ幸いです。

Discussion