DEMO
CODE
<div class="demo-stage ptr-stage">
<div class="ptr-phone">
<div class="ptr-ind" id="ptr-ind" aria-hidden="true"><span class="ptr-arrow">↓</span><span id="ptr-text">引っ張って更新</span></div>
<div class="ptr-scroller" id="ptr-scroller" tabindex="0" role="region" aria-label="メッセージ一覧(いちばん上で下へ引っ張ると更新)">
<ul class="ptr-list" id="ptr-list">
<li>デザイン案を送りました<small>Kappy · 10分前</small></li>
<li>明日の打ち合わせは14時からです<small>Kappy · 32分前</small></li>
<li>画像の差し替えが終わりました<small>Kappy · 1時間前</small></li>
<li>公開前の確認をお願いします<small>Kappy · 2時間前</small></li>
<li>資料を共有フォルダに置きました<small>Kappy · 3時間前</small></li>
<li>今週の予定をまとめました<small>Kappy · 昨日</small></li>
</ul>
</div>
</div>
<div class="ptr-foot">
<button type="button" class="ptr-btn" id="ptr-btn">更新する</button>
<p class="ptr-status" id="ptr-status" role="status" aria-live="polite"></p>
</div>
</div>
.ptr-stage { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:.8rem; padding:1.5rem; background:#0B0B10; min-height:380px; }
.ptr-phone { position:relative; width:100%; max-width:300px; height:300px; border-radius:18px; overflow:hidden; background:#0F0F14; border:1px solid rgba(255,255,255,.1); }
.ptr-ind { position:absolute; top:0; left:0; right:0; height:56px; display:flex; align-items:center; justify-content:center; gap:.5rem; color:#C9C9D1; font-size:.78rem; }
.ptr-arrow { display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; box-sizing:border-box; border-radius:50%; border:2px solid currentColor; font-size:.7rem; transition: transform .2s ease; }
.ptr-ind.is-ready .ptr-arrow { transform:rotate(180deg); }
.ptr-ind.is-loading .ptr-arrow { border-color:rgba(233,27,137,.3); border-top-color:#E91B89; color:transparent; animation: ptr-spin .8s linear infinite; }
.ptr-scroller { position:relative; z-index:1; height:100%; overflow-y:auto; overscroll-behavior-y:contain; outline:none; }
.ptr-scroller:focus-visible { outline:2px solid #E91B89; outline-offset:-2px; }
.ptr-list { list-style:none; margin:0; padding:0; min-height:100%; background:#15151B; transition: transform .3s cubic-bezier(.22,1,.36,1); }
.ptr-list.is-pulling { transition:none; user-select:none; -webkit-user-select:none; }
.ptr-list li { padding:.75rem 1rem; border-bottom:1px solid rgba(255,255,255,.06); color:#EDEDED; font-size:.84rem; }
.ptr-list li small { display:block; margin-top:.15rem; color:#A1A1AA; font-size:.72rem; }
.ptr-list li.is-new { background:#2A1523; }
.ptr-foot { display:flex; align-items:center; gap:.8rem; }
.ptr-btn { min-height:44px; padding:0 1.1rem; border-radius:10px; border:1px solid rgba(255,255,255,.2); background:transparent; color:#EDEDED; font-size:.82rem; cursor:pointer; }
.ptr-btn:focus-visible { outline:2px solid #E91B89; outline-offset:2px; }
.ptr-status { margin:0; min-height:1.2em; color:#A1A1AA; font-size:.76rem; }
@keyframes ptr-spin { to { transform:rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.ptr-list, .ptr-arrow { transition-duration:.01ms; }
.ptr-ind.is-loading .ptr-arrow { animation:none; border-color:#E91B89; }
}
(function(){
var sc = document.getElementById('ptr-scroller');
var list = document.getElementById('ptr-list');
var ind = document.getElementById('ptr-ind');
var text = document.getElementById('ptr-text');
var btn = document.getElementById('ptr-btn');
var status = document.getElementById('ptr-status');
if (!sc || !list || !ind || !text || !btn || !status) return;
var TH = 64, MAX = 96, HOLD = 56, startY = null, d = 0, loading = false, n = 0;
function show(px){ d = px; list.style.transform = px ? 'translateY(' + px + 'px)' : ''; }
function begin(y){
if (loading || sc.scrollTop > 0) return false;
startY = y; list.classList.add('is-pulling');
return true;
}
function move(y, e){
if (startY === null) return;
var dy = y - startY;
if (dy <= 0) { show(0); ind.classList.remove('is-ready'); text.textContent = '引っ張って更新'; return; }
if (e && e.cancelable) e.preventDefault();
show(Math.min(MAX, dy * 0.5));
var ready = d >= TH;
ind.classList.toggle('is-ready', ready);
text.textContent = ready ? '離すと更新' : '引っ張って更新';
}
function end(){
if (startY === null) return;
startY = null;
list.classList.remove('is-pulling');
if (d >= TH) refresh();
else { show(0); ind.classList.remove('is-ready'); text.textContent = '引っ張って更新'; }
}
function refresh(){
if (loading) return;
loading = true;
sc.scrollTop = 0;
ind.classList.remove('is-ready'); ind.classList.add('is-loading');
text.textContent = '更新中…';
status.textContent = '更新しています';
show(HOLD);
setTimeout(function(){
n++;
var li = document.createElement('li');
var sm = document.createElement('small');
li.className = 'is-new';
li.textContent = '新しいメッセージ ' + n;
sm.textContent = 'Kappy · たった今';
li.appendChild(sm);
list.insertBefore(li, list.firstChild);
ind.classList.remove('is-loading');
text.textContent = '引っ張って更新';
status.textContent = '新しいメッセージが1件届きました';
show(0);
loading = false;
}, 1200);
}
sc.addEventListener('touchstart', function(e){ begin(e.touches[0].clientY); }, { passive: true });
sc.addEventListener('touchmove', function(e){ move(e.touches[0].clientY, e); }, { passive: false });
sc.addEventListener('touchend', end);
sc.addEventListener('touchcancel', end);
sc.addEventListener('pointerdown', function(e){
if (e.pointerType !== 'mouse' || e.button !== 0 || !list.contains(e.target)) return;
if (begin(e.clientY)) sc.setPointerCapture(e.pointerId);
});
sc.addEventListener('pointermove', function(e){ if (e.pointerType === 'mouse') move(e.clientY, null); });
sc.addEventListener('pointerup', function(e){ if (e.pointerType === 'mouse') end(); });
sc.addEventListener('pointercancel', function(e){ if (e.pointerType === 'mouse') end(); });
btn.addEventListener('click', refresh);
})();
AI PROMPT
一覧のいちばん上でさらに下へ引っ張ると更新される Pull to Refresh を作って。高さ300px・overflow:hidden の枠の中に、上端に高さ56pxの表示(丸い矢印「↓」と「引っ張って更新」の文字)を position:absolute で置き、その上に overflow-y:auto・背景透明のスクロール領域を重ねる。一覧(ul)には背景色を付け、引っ張った分だけ transform: translateY で下へずらして、後ろの表示を見せる。スクロール領域の scrollTop が0の時だけ引っ張りを始め、指やマウスの移動量の半分だけ動かし(引っ張る抵抗感)、最大96pxで止める。上向きに動かした時は何もせず、普通のスクロールに任せる。一覧のずれが64px以上(指の移動量では128px以上)になったら矢印を180度回して「離すと更新」に変え、その状態で離したら一覧を56pxの位置で止めて、矢印をスピナーに変えて回し、更新が終わったら新しい項目を先頭に追加して0へ戻す(デモでは1.2秒後)。ずれが64px未満で離したら何もせず戻す。タッチは touchstart(passive: true)と touchmove(passive: false)で扱い、引っ張っている間だけ preventDefault を呼ぶ(cancelable の時だけ)。マウスは pointerType が mouse で一覧の上を押した時だけ(スクロールバーのドラッグは対象外)pointerdown・pointermove・pointerup でドラッグとして扱い、setPointerCapture を使う。引っ張っている間は transition を外し、user-select:none にする。スクロール領域には overscroll-behavior-y: contain を付けて、ブラウザ標準の引っ張って更新や、ページ全体へのスクロールの連鎖を止める。引っ張れない人のために「更新する」ボタンも置き、同じ更新を実行する。状態は role="status" の段落で「更新しています」「新しいメッセージが1件届きました」と伝え、後ろの表示は aria-hidden="true" にする。@media (prefers-reduced-motion: reduce) では戻る動きとスピナーの回転を止める。 Acceptance criteria: works with prefers-reduced-motion (animation disabled), zero console errors, zero layout shift
AIエージェントでの実装ガイド → Claude Code でWebアニメーションを実装する
BOOKS · PR · AMAZON
※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。
学びを止めない · AMAZONサブスク
※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。
CREATOR TOOLS · スクール(PR)
※本サイトはアフィリエイトリンクを含みます。リンク経由のご購入・お申し込みで運営者が紹介料を受け取る場合があります。
スマホアプリでおなじみの、一覧のいちばん上でさらに下へ引っ張ると新しい内容を読み込む操作です。引っ張った距離に合わせて矢印が現れ、一定の距離を超えると「離すと更新」に変わることで、離した時に何が起きるかを先に伝えます。
当サイトの Spinner はデータ取得や画面遷移を待つ間に回るスピナー、Bottom Sheet は画面下からせり上がるボトムシート、Swipe to Dismiss は横にスワイプすると一定以上で画面の外へ飛んで消える通知カードです。本ページは指で引っ張る距離をそのまま更新の合図にする別テーマです。
引っ張る操作は便利な近道ですが、誰にでもできる操作ではありません。同じ更新をボタンでも実行できるようにし、ブラウザ標準の引っ張って更新とぶつからないよう overscroll-behavior を指定してください。
ブラウザ標準の「引っ張って更新」とぶつかりませんか?
スマホのブラウザには、ページのいちばん上で下へ引っ張るとページ全体を読み込み直す機能があります。MDN によると、overscroll-behavior に contain を指定すると、隣のスクロール領域へのスクロールの連鎖が止まり、縦方向の引っ張って更新などのブラウザの動作も起きなくなります。本実装はスクロール領域に overscroll-behavior-y: contain を付けたうえで、引っ張っている間だけ touchmove で preventDefault を呼んでいます。
引っ張る操作ができない人はどうしますか?
引っ張る操作は画面を見ながら指を動かせる人向けの近道で、キーボードや読み上げを使う人、手の動きに制約がある人には使えません。本実装は同じ更新を実行する「更新する」ボタンを常に表示し、更新の開始と結果を role="status" の文で伝えています。一覧を自動で最新にする仕組みがある場合も、手動で更新できる手段は残しておくと安心です。
コードは無料で使えますか?
はい。Motion LabのコードスニペットとAIプロンプトは、クレジット表記なしで自由に使えます。コピーしてそのままプロジェクトに利用できます。