Reactで、長押しで実行するボタンを作る方法

​​React​Playwright,Pointer Events,TypeScript

長押しで実行するボタンは、押し始めに setTimeout でタイマーを始め、決めた時間より前に指やマウスが離れたらタイマーを止める、という仕組みで作れます。止めるきっかけを mouseup だけにすると、ボタンの外で離したときに止まりません。ここでは Pointer Events の pointerup・pointerleave・pointercancel でタイマーを止めます。そのうえで、キーボードでも長押しできる useLongPress フックと、押している間に背景が塗られていく LongPressButton コンポーネントを作りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。tsc で型エラーがないことを確かめ、操作を確認するページを StrictMode で包んで esbuild で開発用にビルドし、Playwright でマウスとキーボードを操作しました(GIF 用のページだけは本番用にビルドしています)。タッチは、Chrome DevTools Protocol(CDP)の Input.dispatchTouchEvent でタッチの操作を送って試しています。実際のスマートフォン、iOS の Safari、Firefox では試していません。

「残りのメモ:3件」と「長押しで1件削除」のボタンがある画面。1回目はボタンを押してすぐ離し、背景の塗りが途中で消えて「0.30 秒で離した」と出る。2回目は押し続け、ボタンの背景が左からピンクに塗られ、0.80 秒で「残りのメモ」が2件に変わってピンクの文字になる。3回目は押したままボタンの外へずらし、塗りが消えて「ボタンの外へずらしたので取り消し」と出る。件数は2件のまま。下の帯に何回目の操作かが出ている
押してすぐ離す、押し続ける、押したまま外へずらすの3つの操作。0.8 秒押し続けたときだけ件数が減る

mousedown と mouseup だけでは、ボタンの外で離したときに止まらない

最初に、マウスのイベントだけで作った例を見ます。押したらタイマーを始め、離したらタイマーを止めます。

tsx
// mousedown と mouseup だけで作った、うまくいかない例
import { useRef } from 'react';

export function NaiveButton({ onLongPress }: { onLongPress: () => void }) {
  const timer = useRef<number | undefined>(undefined);
  return (
    <button
      type="button"
      onMouseDown={() => {
        timer.current = window.setTimeout(onLongPress, 800);
      }}
      onMouseUp={() => clearTimeout(timer.current)}
    >
      長押しで削除
    </button>
  );
}

このコンポーネントをボタンの上で押し、400ms 後にマウスをボタンの外へ動かしてから離しました。離したのは 800ms より前なのに、1回実行されました。mouseup は、マウスのボタンを離したときに、ポインターの下にある要素で起きるイベントです。ボタンの外で離すと、ボタンに付けた onMouseUp は呼ばれず、タイマーが残ります。

そこで、押したままボタンの外へずらして、長押しを途中でやめる操作にも対応させます。ボタンの上で離したときのほかに、ポインターがボタンの外へ出たときにもタイマーを止めます。

長押しを検出する useLongPress フック

フックは useLongPress.ts に書きます。

ts
// 長押しを検出するフック
import { useCallback, useEffect, useRef, useState } from 'react';
import type { KeyboardEvent, PointerEvent } from 'react';

type Options = {
  onLongPress: () => void;
  delay?: number; // 何ミリ秒押し続けたら実行するか
};

export function useLongPress({ onLongPress, delay = 800 }: Options) {
  const [pressing, setPressing] = useState(false);
  const timer = useRef<number | null>(null);
  // タイマーが切れたときに、最新の onLongPress を呼ぶ
  const callback = useRef(onLongPress);
  useEffect(() => {
    callback.current = onLongPress;
  });

  const cancel = useCallback(() => {
    if (timer.current !== null) {
      clearTimeout(timer.current);
      timer.current = null;
    }
    setPressing(false);
  }, []);

  const start = useCallback(() => {
    if (timer.current !== null) return; // 押している途中なら何もしない
    setPressing(true);
    timer.current = window.setTimeout(() => {
      timer.current = null;
      setPressing(false);
      callback.current();
    }, delay);
  }, [delay]);

  // 押している途中でコンポーネントが消えたら、タイマーを止める
  useEffect(() => cancel, [cancel]);

  const isActivationKey = (e: KeyboardEvent) => e.key === ' ' || e.key === 'Enter';

  return {
    pressing,
    handlers: {
      onPointerDown: (e: PointerEvent<HTMLElement>) => {
        if (e.button !== 0) return; // 右クリックなどは無視する
        start();
      },
      onPointerUp: cancel,
      onPointerLeave: cancel,
      onPointerCancel: cancel,
      onKeyDown: (e: KeyboardEvent) => {
        if (!isActivationKey(e) || e.repeat) return; // キーの自動リピートは無視する
        start();
      },
      onKeyUp: (e: KeyboardEvent) => {
        if (isActivationKey(e)) cancel();
      },
      onBlur: cancel,
    },
  };
}

タイマーの ID は useRef に入れています。ref を変更しても再レンダーは起きず、インターバルの ID のように画面に出さない値を入れておくのに向いているからです。start は、タイマーがすでに動いているときは何もしません。押している途中に同じイベントがもう一度届いても、タイマーが2つにならないようにするためです。

タイマーを止めるきっかけは3つあります。pointerup はポインターがアクティブでなくなったときで、ボタンの上でマウスのボタンや指を離すと起きます。pointerleave は、ポインターがボタンの範囲から出たときに起きます。pointercancel は、pointerdown のあとでブラウザーがその操作をスクロールやズームに使うと判断したときなどに起きます。タッチ操作でボタンに触れたまま画面をスクロールした場合に、これで取り消されます。

onPointerDown では、button が 0(主ボタン、ふつうは左ボタン)のときだけタイマーを始めます。右ボタンで 1200ms 押し続けても、実行されませんでした。

useEffect(() => cancel, [cancel]) は、コンポーネントが画面から消えるときにタイマーを止めるためのものです。useEffect に渡した関数が返すクリーンアップ関数は、コンポーネントが DOM から削除されたあとに、最後にもう一度実行されます。押している途中にボタンを消す操作で試したところ、1000ms 待っても実行されませんでした。

onLongPress は callback という ref に入れ直してから呼んでいます。タイマーを始めたあとで親コンポーネントが再レンダーされ、新しい関数が渡されても、タイマーが切れたときには最新の関数を呼ぶためです。押し始めてから 300ms 後に親を再レンダーしたところ、タイマーが切れたときに呼ばれたのは、再レンダーのあとに渡した関数でした。

ボタンのコンポーネントと、押している間の塗り

ボタンは LongPressButton.tsx に書き、フックを ./useLongPress から読み込みます。

tsx
// 長押しで実行するボタン
import type { CSSProperties, ReactNode } from 'react';
import { useLongPress } from './useLongPress';

type Props = {
  onLongPress: () => void;
  delay?: number;
  children: ReactNode;
};

export function LongPressButton({ onLongPress, delay = 800, children }: Props) {
  const { pressing, handlers } = useLongPress({ onLongPress, delay });
  return (
    <button
      type="button"
      className={pressing ? 'long-press pressing' : 'long-press'}
      style={{ '--delay': `${delay}ms` } as CSSProperties}
      {...handlers}
    >
      <span className="fill" aria-hidden="true" />
      <span className="label">{children}</span>
    </button>
  );
}
css
.long-press {
  position: relative;
  overflow: hidden;
  padding: 12px 24px;
  font-size: 16px;
  border: 2px solid #d85878;
  border-radius: 8px;
  background: #fff;
  color: #1f2a30;
  user-select: none; /* 長押しで文字が選ばれないようにする */
  -webkit-user-select: none;
  -webkit-touch-callout: none; /* iOS の長押しメニューを出さない */
}
.long-press .fill {
  position: absolute;
  inset: 0;
  background: #ffd6e0;
  transform: scaleX(0);
  transform-origin: left;
}
.long-press.pressing .fill {
  animation: long-press-fill var(--delay) linear forwards;
}
.long-press .label {
  position: relative; /* 塗りの上に文字を出す */
}
@keyframes long-press-fill {
  to { transform: scaleX(1); }
}

押している間は pressing が true になり、ボタンに pressing クラスが付きます。中の .fill は、transform: scaleX(0) から scaleX(1) まで、長押しの時間と同じ長さのアニメーションで横に伸びます。アニメーションの長さは、delay の値を CSS 変数 --delay に渡して決めているので、delay と同じ長さになります。離したり外へずらしたりしてクラスが外れると、アニメーションも消えて塗りが元に戻ります。

user-select: none は、ボタンの文字を範囲選択できないようにする指定です。-webkit-touch-callout: none は、iOS の Safari でリンクなどを長押ししたときに出る情報の表示(コールアウト)を出さないための、標準ではないプロパティです。

試した操作と結果

確認用のページで、delay を 800ms にした LongPressButton を操作しました。

操作 実行された回数
マウスで 300ms 押して離す 0
マウスで 1500ms 押し続ける 1(押している途中で1回だけ)
マウスで押したまま、400ms 後にボタンの外へ動かす 0
右ボタンで 1200ms 押す 0
Space キーを 300ms 押して離す 0
Enter キーを 1000ms 押す 1
Space キーを押している途中で Tab キーを押し、フォーカスを外す 0
押している途中でボタンを画面から消す 0

マウスで押した時刻から実行されるまでは 802ms でした。1500ms 押し続けたときも、実行は1回だけです。タイマーが切れたあとは、離してもう一度押すまで次のタイマーは始まりません。

キーを押し続けると、自動リピートで何度も実行される

キーボードでも長押しできるように、Space キーと Enter キーの keydown でタイマーを始め、keyup で止めています。ここで気をつけるのは、キーを押し続けると keydown がくり返し届くことです。KeyboardEvent の repeat は、キーが押しっぱなしで自動でくり返しているときに true になります。フックでは、repeat が true の keydown を無視しています。

この1行がない場合と比べました。Space キーを押したまま、自動リピートの keydown を 50ms ごとに 60 回送り、約3秒押し続けたのと同じ状態にしました。

フック keydown の数 実行された回数
repeat が true なら無視する 61(うち repeat が true のもの 60) 1
repeat を見ない 61(うち repeat が true のもの 60) 3

repeat を見ない場合は、タイマーが切れて timer.current が null に戻ったあと、次に届いた keydown で新しいタイマーが始まります。そのため、約3秒押し続けた間に3回実行されました。削除のような操作を長押しにしたいときは、1回の長押しで1回だけ実行されるように、repeat を確かめる必要があります。

タッチで押したまま指を動かすと、取り消される

タッチでも試しました。画面の幅を 390px にしたページで、CDP の Input.dispatchTouchEvent を使ってボタンに 1200ms 触れたところ、1回実行されました。このとき、ボタンでは pointerdown、pointerup、pointerleave、click の順にイベントが起き、pointerType はすべて touch でした。

次に、ボタンに触れてから 100ms 後に指を上へ 100px 動かし、そのあと 1000ms 触れたままにしました。ボタンでは pointerdown のあとに pointercancel と pointerleave が起き、ページは 62px スクロールしました。実行はされませんでした。ボタンに触れたまま画面をスクロールしても、長押しとしては扱われません。

​Posted by 86