Reactで、長押しで実行するボタンを作る方法
長押しで実行するボタンは、押し始めに 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 では試していません。

mousedown と mouseup だけでは、ボタンの外で離したときに止まらない
最初に、マウスのイベントだけで作った例を見ます。押したらタイマーを始め、離したらタイマーを止めます。
// 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 に書きます。
// 長押しを検出するフック
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 から読み込みます。
// 長押しで実行するボタン
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>
);
}
.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 スクロールしました。実行はされませんでした。ボタンに触れたまま画面をスクロールしても、長押しとしては扱われません。





