プルトゥリフレッシュ(Pull to Refresh)

このページでは、@use-gesture/react・Framer Motion・カスタム実装を使ったプルトゥリフレッシュの実装パターンをインタラクティブなデモとコードで確認できます。

プル検知アルゴリズム・インジケーターアニメーション・スナップバックの実装差を主要ライブラリで比較

💻 PC でご覧の方へ:デモはマウスドラッグでも操作できます。スクロール競合などタッチ特有の挙動はスマートフォンで確認できます。
@use-gesture/react

読み込み中...

tsx
'use client';
import { useState, useRef, useCallback } from 'react';
import { useDrag } from '@use-gesture/react';
import { useSpring, animated } from '@react-spring/web';

const THRESHOLD = 60; // px: リフレッシュ発動閾値
const MAX_PULL = 100; // px: 最大プル量

type Phase = 'idle' | 'pulling' | 'ready' | 'refreshing' | 'done';

export function UseGesturePullToRefreshDemo() {
  const [phase, setPhase] = useState<Phase>('idle');
  const phaseRef = useRef<Phase>('idle');
  const containerRef = useRef<HTMLDivElement>(null);

  const [spring, api] = useSpring(() => ({
    y: 0, rotate: 0,
    config: { tension: 280, friction: 26 },
  }));

  const doRefresh = useCallback(() => {
    phaseRef.current = 'refreshing';
    setPhase('refreshing');
    api.start({ y: 48, rotate: 360 });
    setTimeout(() => {
      api.start({ y: 0, rotate: 0, config: { tension: 200, friction: 30 } });
      setPhase('done');
      phaseRef.current = 'done';
      setTimeout(() => { setPhase('idle'); phaseRef.current = 'idle'; }, 1000);
    }, 1200);
  }, [api]);

  const bind = useDrag(
    ({ active, movement: [, my], last, cancel }) => {
      if (phaseRef.current === 'refreshing') { cancel?.(); return; }
      const atTop = (containerRef.current?.scrollTop ?? 0) <= 0;
      if (!atTop && my > 0) { cancel?.(); return; }
      if (my <= 0) { api.start({ y: 0, rotate: 0 }); return; }

      const pull = Math.min(my, MAX_PULL);
      if (active) {
        api.start({ y: pull, rotate: (pull / THRESHOLD) * 180, immediate: true });
        const p = pull >= THRESHOLD ? 'ready' : 'pulling';
        phaseRef.current = p; setPhase(p);
      }
      if (last) {
        if (phaseRef.current === 'ready') doRefresh();
        else { api.start({ y: 0, rotate: 0 }); phaseRef.current = 'idle'; setPhase('idle'); }
      }
    },
    { axis: 'y', pointer: { touch: true }, filterTaps: true }
  );

  return (
    <div style={{ position: 'relative', height: '320px', overflow: 'hidden', border: '2px solid #e5e7eb', borderRadius: '1rem' }}>
      {/* インジケーター(絶対配置) */}
      <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '48px', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10, pointerEvents: 'none' }}>
        <animated.div style={{ rotate: spring.rotate, opacity: spring.y.to(v => Math.min(v / 30, 1)), fontSize: '1.5rem' }}>
          {phase === 'refreshing' ? '⟳' : '↓'}
        </animated.div>
      </div>
      {/* スクロールリスト(プル量だけ下にシフト) */}
      <animated.div ref={containerRef} {...bind()} style={{ y: spring.y, overflowY: 'auto', height: '100%', touchAction: 'pan-x' }}>
        {/* リストアイテム */}
      </animated.div>
    </div>
  );
}

実装比較表

項目@use-gesture/reactFramer Motionカスタム実装
プル検知useDrag(Y軸)で EMA 平滑化済み速度を取得Pointer Events + useMotionValue でリアルタイム追跡pointerdown / pointermove / pointerup のみ
インジケーター@react-spring/web の useSpring でスプリングアニメーションuseTransform でプル量に連動して回転・フェードCSS transform + transition で translateY 制御
スナップバックスプリング物理(tension / friction)で自然な戻りanimate() で Spring スナップバックCSS transition: transform 0.3s ease
依存ライブラリ@use-gesture/react + @react-spring/webframer-motion のみなし(React + Pointer Events API のみ)
ローディング中アニメーションrotate: 360 のスプリングループanimate={{ rotate: [0, 360] }} + repeat: Infinityなし(テキスト表示のみ)
マウス対応pointer: { touch: true } でタッチ優先、マウスも動作Pointer Events はマウス・タッチ共通Pointer Events はマウス・タッチ共通

⚠️ iOS Safari での注意点

  • • iOS Safari にはネイティブのプルトゥリフレッシュがあるため、overscroll-behavior-y: contain をコンテナに設定して競合を防ぐこと
  • touchAction: "pan-x" を設定することで、縦スクロールとプル操作を Pointer Events で制御できる
  • • スクロールコンテナが overflow: hidden の親に包まれている場合、scrollTop の読み取りが正常に動作しないケースがある
  • setPointerCapture を使うことで、要素外にポインターが移動しても pointermove を継続して受け取れる

🤖 AIプロンプトテンプレート

Reactでプルトゥリフレッシュ(Pull to Refresh)を実装してください。以下の要件を満たしてください。

- @use-gesture/react、Framer Motion、またはPointer Events APIのいずれかを使用すること
- スクロールコンテナの先頭(scrollTop === 0)でのみプル検知を有効にすること
- プル量に連動してインジケーターが出現し、一定距離(60px)で「離してリフレッシュ」状態になること
- 指を離した際にローディングアニメーションを表示し、完了後にリストを更新すること
- スナップバックアニメーションを実装し、自然な操作感にすること
- マウスドラッグでも動作するように実装すること(PC での開発・確認用)

⚠️ このプロンプトはあくまでたたき台です。AIの回答はモデルやバージョン、会話の文脈によって毎回異なります。意図通りに動かない場合は、条件を追記・修正してお使いください。