👀

追随ボタンをメニューの表示に使う

に公開

対象はreactを使い、useContextなどの状態管理周りを理解していることが前提になります。

よくあるwebサイトなどで使われているカーソルの追随をメニューのトリガーにしました。
作成した理由はシンプルにカーソルまでの操作を短縮したいと考えていたからです。

対象はtauriですが、webでも使うことができると考えています。
今回は追随させることでメニューの表示非表示のトリガーに使っています。

業務やuxとしてのカテゴリはhover領域の範囲と対象操作を邪魔をしないことなどが含まれます。

対象はreactでソースは以下です。

import type { CSSProperties, MouseEvent, PointerEvent, PropsWithChildren, ReactNode } from "react";
import { useState } from "react";

import { useUIContext } from "../../store/ui";

type Props = PropsWithChildren<{
  className?: string;
  glowClassName?: string;
  glowContent?: ReactNode;
  onGlowClick?: () => void;
}>;

const orbSize = 40;

export default function HoverFollow(props: Props) {
  const { state, dispatch } = useUIContext();
  const { viewtype: activePath, isSidebarOpen: switcher } = state;

  const {
    children,
    className = "",
    glowClassName = "",
    glowContent = "open",
    onGlowClick,
  } = props;
  const [isActive, setIsActive] = useState(false);
  const [pointer, setPointer] = useState({
    x: 0,
    y: 0,
  });

  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const rect = event.currentTarget.getBoundingClientRect();
    const nextPointer = {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    };

    const insideOrbX = Math.abs(nextPointer.x - (pointer.x + orbSize)) <= orbSize;
    const insideOrbY = Math.abs(nextPointer.y - (pointer.y + orbSize)) <= orbSize;

    if (insideOrbX && insideOrbY) {
      return;
    }

    setPointer(nextPointer);
  };

  const onButtonClick = (event: MouseEvent<HTMLButtonElement>) => {
    event.stopPropagation();
    onGlowClick?.();
    dispatch({
      type: "TOGGLE_SIDEBAR",
    });
  };

  return (
    <div
      className={`hover-follow ${isActive ? "is-active" : ""} ${className}`.trim()}
      onPointerEnter={() => setIsActive(true)}
      onPointerLeave={() => setIsActive(false)}
      onPointerMove={onPointerMove}
      style={
        {
          "--hover-x": `${pointer.x + orbSize}px`,
          "--hover-y": `${pointer.y + orbSize}px`,
        } as CSSProperties
      }
    >
      <div
        aria-hidden="true"
        className={`hover-follow__glow ${glowClassName}`.trim()}
      >
        <button
          type="button"
          className="hover-follow__button"
          onClick={onButtonClick}
        >
          {switcher ? "close" : glowContent}
        </button>
      </div>
      <div className="hover-follow__content">{children}</div>
    </div>
  );
}

[state]

import type { Dispatch, ReactNode } from "react";
import { createContext, useContext, useReducer } from "react";

type UIState = {
  viewtype: string;
  isSidebarOpen: boolean;
};

type UIAction =
  | { type: "SET_VIEWTYPE"; payload: string }
  | { type: "TOGGLE_SIDEBAR" }
  | { type: "SET_SIDEBAR_OPEN"; payload: boolean };

const initialState: UIState = {
  viewtype: "home",
  isSidebarOpen: false,
};

function reducer(state: UIState, action: UIAction): UIState {
  switch (action.type) {
    case "SET_VIEWTYPE":
      return {
        ...state,
        viewtype: action.payload,
      };
    case "TOGGLE_SIDEBAR":
      return {
        ...state,
        isSidebarOpen: !state.isSidebarOpen,
      };
    case "SET_SIDEBAR_OPEN":
      return {
        ...state,
        isSidebarOpen: action.payload,
      };
    default:
      return state;
  }
}

const UIContext = createContext<{
  state: UIState;
  dispatch: Dispatch<UIAction>;
}>({
  state: initialState,
  dispatch: () => undefined,
});

export function UIProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return <UIContext.Provider value={{ state, dispatch }}>{children}</UIContext.Provider>;
}

export function useUIContext() {
  return useContext(UIContext);
}

ui次第でクリックを変化させることや過去に使ったことにあるページをキャッシュで表示することもできると思います。
判定する範囲などを広げることやマウスの移動が続く場合のみメニューをクリック可能にさせるケースなども考慮してみて下さい。

このuiが面倒であればどこかで固定メニューと[ctrl+r]などでメニューをショートカットキーで出せるなどへ誘導させていくようにすることもできると思うので、ユーザーが段階的に使うためのプロセスで後に消えるフローにもできるかもしれません。

[備考]
環境は以下を対象に作成しました。
macmini m2
node v24.12.0

"@tailwindcss/vite": "^4.0.17",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-shell": "^2.0.0",
"github-markdown-css": "^5.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.14.1",

Discussion