UI Components Medium
Pull-to-refresh
Pull a scroll surface down to reveal a refresh affordance, with a momentum-like release.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--panel-2: #18233b;
--line: #2b3d62;
--text: #eef2ff;
--muted: #9eb1d4;
--accent: #78a9ff;
--ok: #5fe3a1;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.demo {
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
}
.ptr {
width: min(420px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
overflow: hidden;
box-shadow: 0 24px 70px #0007;
}
.ptr__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #17223a, var(--panel));
}
.ptr__bar h1 { margin: 0; font-size: 16px; letter-spacing: .2px; }
.ptr__btn {
font: inherit;
font-size: 13px;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 14px;
cursor: pointer;
}
.ptr__btn:hover { border-color: var(--accent); color: var(--accent); }
.ptr__btn:focus-visible,
.ptr__viewport:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ptr__viewport {
position: relative;
height: 320px;
overflow-y: auto;
overscroll-behavior-y: contain;
touch-action: pan-y;
}
.ptr__indicator {
position: absolute;
top: 0;
left: 50%;
width: 38px;
height: 38px;
margin-left: -19px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--panel-2);
border: 1px solid var(--line);
opacity: var(--ptr-progress, 0);
transform: translateY(calc(var(--ptr-pull, 0px) - 46px));
z-index: 2;
}
.ptr__indicator.is-armed { border-color: var(--accent); box-shadow: 0 0 0 4px #78a9ff22; }
.ptr__track { stroke: #2c3444; }
.ptr__arc {
stroke: var(--accent);
stroke-dasharray: 100;
stroke-dashoffset: calc(100 - var(--ptr-progress, 0) * 100);
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
.ptr__list {
list-style: none;
margin: 0;
padding: 0;
transform: translateY(var(--ptr-pull, 0px));
}
.ptr__viewport.is-settling .ptr__list,
.ptr__viewport.is-settling .ptr__indicator {
transition: transform .34s cubic-bezier(.22, 1, .36, 1), opacity .34s ease;
}
.ptr__item {
display: grid;
grid-template-columns: 34px 1fr auto;
gap: 12px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.ptr__item:last-child { border-bottom: 0; }
.ptr__avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
color: #0b1020;
background: var(--accent);
}
.ptr__from { font-size: 14px; font-weight: 600; }
.ptr__sub { font-size: 12.5px; color: var(--muted); }
.ptr__time { font-size: 11.5px; color: var(--muted); }
.ptr__item.is-new { animation: ptr-in .35s ease both; }
.ptr__item.is-new .ptr__avatar { background: var(--ok); }
@keyframes ptr-in { from { opacity: 0; transform: translateY(-10px); } }
.ptr__hint, .ptr__status {
margin: 0;
padding: 10px 16px;
font-size: 12.5px;
color: var(--muted);
}
.ptr__hint { border-top: 1px solid var(--line); }
.ptr__status { padding-top: 0; }
.ptr__status.is-ok { color: var(--ok); }
kbd {
font: inherit;
font-size: 11.5px;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 5px;
padding: 1px 5px;
}
.ptr__viewport.is-loading .ptr__spinner { animation: ptr-spin .9s linear infinite; }
@keyframes ptr-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.ptr__viewport.is-settling .ptr__list,
.ptr__viewport.is-settling .ptr__indicator { transition: none; }
.ptr__item.is-new,
.ptr__viewport.is-loading .ptr__spinner { animation: none; }
}/* Pull-to-refresh — vanilla Pointer Events, rubber-band pull, snap release. */
(() => {
const viewport = document.querySelector("[data-viewport]");
const list = document.querySelector("[data-list]");
const indicator = document.querySelector("[data-indicator]");
const status = document.querySelector("[data-status]");
const button = document.querySelector("[data-refresh]");
if (!viewport || !list) return;
const THRESHOLD = 64; // px of pull needed to arm a refresh
const MAX_PULL = 110; // hard cap after rubber-banding
const HOLD = 56; // resting offset while loading
const SUBJECTS = [
["Ana Ruiz", "Design review moved to 4pm"],
["Deploy bot", "Build #1841 succeeded"],
["Marc Vidal", "Re: pricing page copy"],
["Statuspage", "All systems operational"],
["Lena Ortiz", "Contract signed — next steps"],
["Sofia Marin", "Sprint retro notes attached"],
["Billing", "Invoice 2261 is ready"],
];
let seed = 0;
let pointerId = null;
let startY = 0;
let pull = 0;
let armed = false;
let loading = false;
const clockFrom = (minutesAgo) => {
const d = new Date(Date.now() - minutesAgo * 60000);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
const makeItem = ([from, sub], minutesAgo, isNew) => {
const li = document.createElement("li");
li.className = "ptr__item" + (isNew ? " is-new" : "");
li.innerHTML = `
<span class="ptr__avatar" aria-hidden="true">${from[0]}</span>
<span>
<span class="ptr__from">${from}</span><br />
<span class="ptr__sub">${sub}</span>
</span>
<span class="ptr__time">${clockFrom(minutesAgo)}</span>`;
return li;
};
// Seed the initial list.
for (let i = 0; i < 6; i += 1) {
list.append(makeItem(SUBJECTS[(seed + i) % SUBJECTS.length], 12 + i * 17, false));
}
seed += 6;
const render = () => {
const progress = Math.min(1, pull / THRESHOLD);
viewport.style.setProperty("--ptr-pull", `${pull}px`);
viewport.style.setProperty("--ptr-progress", progress.toFixed(3));
indicator.classList.toggle("is-armed", armed || loading);
};
const setStatus = (text, ok) => {
status.textContent = text;
status.classList.toggle("is-ok", Boolean(ok));
};
const settle = (to) => {
viewport.classList.add("is-settling");
pull = to;
render();
window.setTimeout(() => viewport.classList.remove("is-settling"), 360);
};
// Rubber band: resistance grows with distance so the pull feels elastic.
const damp = (raw) => MAX_PULL * (1 - Math.exp(-raw / (MAX_PULL * 0.9)));
function refresh() {
if (loading) return;
loading = true;
armed = false;
viewport.classList.add("is-loading");
settle(HOLD);
setStatus("Refreshing…");
button.disabled = true;
window.setTimeout(() => {
const count = 2 + Math.floor(Math.random() * 2);
for (let i = count - 1; i >= 0; i -= 1) {
list.prepend(makeItem(SUBJECTS[(seed + i) % SUBJECTS.length], i, true));
}
seed += count;
while (list.children.length > 12) list.lastElementChild.remove();
loading = false;
viewport.classList.remove("is-loading");
viewport.scrollTop = 0;
settle(0);
button.disabled = false;
setStatus(`Updated · ${count} new message${count > 1 ? "s" : ""}`, true);
}, 1100);
}
viewport.addEventListener("pointerdown", (e) => {
if (loading || pointerId !== null) return;
if (viewport.scrollTop > 0) return;
pointerId = e.pointerId;
startY = e.clientY;
viewport.classList.remove("is-settling");
});
viewport.addEventListener("pointermove", (e) => {
if (e.pointerId !== pointerId) return;
const raw = e.clientY - startY;
if (raw <= 0) {
// Upward drag hands control back to native scrolling.
pull = 0;
armed = false;
render();
return;
}
if (!viewport.hasPointerCapture(pointerId)) viewport.setPointerCapture(pointerId);
if (e.cancelable) e.preventDefault();
pull = damp(raw);
armed = pull >= THRESHOLD;
setStatus(armed ? "Release to refresh" : "Pull to refresh");
render();
});
const endDrag = (e) => {
if (e.pointerId !== pointerId) return;
if (viewport.hasPointerCapture(pointerId)) viewport.releasePointerCapture(pointerId);
pointerId = null;
if (armed) {
refresh();
} else {
settle(0);
if (!loading) setStatus("Idle");
}
armed = false;
};
viewport.addEventListener("pointerup", endDrag);
viewport.addEventListener("pointercancel", endDrag);
// Keyboard + button alternatives.
viewport.addEventListener("keydown", (e) => {
if (e.key === "r" || e.key === "R") {
e.preventDefault();
refresh();
}
});
button.addEventListener("click", refresh);
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pull-to-refresh</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Pull-to-refresh">
<section class="ptr" aria-labelledby="ptr-title">
<header class="ptr__bar">
<h1 id="ptr-title">Inbox</h1>
<button type="button" class="ptr__btn" data-refresh>Refresh</button>
</header>
<div class="ptr__viewport" data-viewport tabindex="0" aria-describedby="ptr-hint">
<div class="ptr__indicator" data-indicator aria-hidden="true">
<svg class="ptr__spinner" viewBox="0 0 24 24" width="22" height="22">
<circle class="ptr__track" cx="12" cy="12" r="9" fill="none" stroke-width="2.5" />
<circle class="ptr__arc" cx="12" cy="12" r="9" fill="none" stroke-width="2.5"
stroke-linecap="round" pathLength="100" />
</svg>
</div>
<ul class="ptr__list" data-list></ul>
</div>
<p class="ptr__hint" id="ptr-hint">
Drag the list downward to refresh. Keyboard: focus the list and press <kbd>R</kbd>, or use the Refresh button.
</p>
<p class="ptr__status" role="status" aria-live="polite" data-status>Idle</p>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function GesturePullToRefresh() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Pull-to-refresh</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Pull-to-refresh
Pull a scroll surface down to reveal a refresh affordance, with a momentum-like release.
Support notes
The examples use Pointer Events, capture, bounded transforms, and small snap thresholds. Production code should also wire keyboard alternatives and reduced-motion preferences.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.