UI Components Medium
Pull to Refresh
Pull-down-to-refresh indicator for mobile lists. Shows a spinner when the user pulls past the threshold, then resets the list. No libraries.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML React Native
Expo Snack
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f5f7;
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
.page {
width: 100%;
max-width: 480px;
height: 100vh;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page-header {
padding: 24px 20px 12px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
}
.page-header h1 {
font-size: 22px;
font-weight: 700;
color: #111;
}
.hint {
font-size: 12px;
color: #aaa;
}
/* Scroll container */
.scroll-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
}
/* Pull indicator */
.pull-indicator {
position: absolute;
top: -64px;
left: 0;
right: 0;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
pointer-events: none;
transition: opacity 0.2s;
}
.pull-icon {
width: 28px;
height: 28px;
position: relative;
}
.arrow-icon,
.spinner-icon {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
color: #6366f1;
transition: opacity 0.2s;
}
.spinner-icon {
opacity: 0;
animation: spin 0.8s linear infinite;
}
.pull-indicator.spinning .arrow-icon {
opacity: 0;
}
.pull-indicator.spinning .spinner-icon {
opacity: 1;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.pull-label {
font-size: 13px;
color: #6366f1;
font-weight: 600;
}
/* Feed list */
.feed-list {
list-style: none;
padding: 8px 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.feed-list.dragging {
transition: none;
}
.feed-item {
display: flex;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid #f3f4f6;
align-items: flex-start;
opacity: 1;
transform: translateY(0);
transition: opacity 0.4s, transform 0.4s;
}
.feed-item.new-item {
opacity: 0;
transform: translateY(-16px);
}
.feed-item.new-item.visible {
opacity: 1;
transform: translateY(0);
}
.item-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
}
.av1 {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
.av2 {
background: linear-gradient(135deg, #f59e0b, #ef4444);
}
.av3 {
background: linear-gradient(135deg, #10b981, #3b82f6);
}
.av4 {
background: linear-gradient(135deg, #ec4899, #f43f5e);
}
.item-body {
flex: 1;
}
.item-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.item-meta strong {
font-size: 14px;
color: #111;
}
.item-meta span {
font-size: 12px;
color: #aaa;
}
.item-body p {
font-size: 13px;
color: #555;
line-height: 1.5;
}const container = document.getElementById("scrollContainer");
const list = document.getElementById("feedList");
const indicator = document.querySelector(".pull-indicator");
const pullLabel = document.getElementById("pullLabel");
const THRESHOLD = 80;
let startY = 0;
let pulling = false;
let triggered = false;
let refreshing = false;
const newItems = [
{
name: "Alex Morgan",
time: "just now",
text: "Just published a new article on mobile-first design patterns. Check it out!",
},
{
name: "Sam Rivera",
time: "just now",
text: "New sprint just started. Excited to build out the gesture library this week.",
},
];
let newItemIndex = 0;
function setTranslation(y) {
list.style.transform = `translateY(${y}px)`;
indicator.style.transform = `translateY(${y}px)`;
// Rotate arrow based on progress
const progress = Math.min(y / THRESHOLD, 1);
const arrow = indicator.querySelector(".arrow-icon");
if (arrow) arrow.style.transform = `rotate(${progress * 180}deg)`;
pullLabel.textContent = progress >= 1 ? "Release to refresh" : "Pull to refresh";
}
function reset() {
list.classList.remove("dragging");
list.style.transform = "";
indicator.style.transform = "";
indicator.classList.remove("spinning");
pulling = false;
triggered = false;
refreshing = false;
pullLabel.textContent = "Pull to refresh";
}
async function doRefresh() {
refreshing = true;
indicator.classList.add("spinning");
pullLabel.textContent = "Refreshing…";
list.style.transform = `translateY(${THRESHOLD}px)`;
indicator.style.transform = `translateY(${THRESHOLD}px)`;
await new Promise((r) => setTimeout(r, 1200));
// Prepend a new item
const data = newItems[newItemIndex % newItems.length];
newItemIndex++;
const avatars = ["av1", "av2", "av3", "av4"];
const avClass = avatars[Math.floor(Math.random() * avatars.length)];
const li = document.createElement("li");
li.className = `feed-item new-item`;
li.innerHTML = `
<div class="item-avatar ${avClass}"></div>
<div class="item-body">
<div class="item-meta"><strong>${data.name}</strong><span>${data.time}</span></div>
<p>${data.text}</p>
</div>
`;
list.prepend(li);
// Animate in
requestAnimationFrame(() => {
requestAnimationFrame(() => li.classList.add("visible"));
});
// Retract
list.style.transition = "transform 0.35s cubic-bezier(0.4,0,0.2,1)";
indicator.style.transition = "transform 0.35s cubic-bezier(0.4,0,0.2,1)";
reset();
setTimeout(() => {
list.style.transition = "";
indicator.style.transition = "";
}, 350);
}
container.addEventListener(
"touchstart",
(e) => {
if (container.scrollTop > 0 || refreshing) return;
startY = e.touches[0].clientY;
pulling = true;
list.classList.add("dragging");
},
{ passive: true }
);
container.addEventListener(
"touchmove",
(e) => {
if (!pulling || refreshing) return;
if (container.scrollTop > 0) {
pulling = false;
return;
}
const delta = e.touches[0].clientY - startY;
if (delta <= 0) return;
const dampened = Math.min(delta * 0.5, THRESHOLD * 1.4);
setTranslation(dampened);
triggered = dampened >= THRESHOLD;
},
{ passive: true }
);
container.addEventListener("touchend", () => {
if (!pulling) return;
list.classList.remove("dragging");
if (triggered) {
doRefresh();
} else {
reset();
}
});<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="style.css" />
<title>Pull to Refresh</title>
</head>
<body>
<div class="page">
<header class="page-header">
<h1>Feed</h1>
<span class="hint">Pull down to refresh</span>
</header>
<div class="scroll-container" id="scrollContainer">
<!-- Pull indicator -->
<div class="pull-indicator" id="pullIndicator">
<div class="pull-icon" id="pullIcon">
<svg class="arrow-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/>
<polyline points="19 12 12 19 5 12"/>
</svg>
<svg class="spinner-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
</div>
<span class="pull-label" id="pullLabel">Pull to refresh</span>
</div>
<!-- Content list -->
<ul class="feed-list" id="feedList">
<li class="feed-item">
<div class="item-avatar av1"></div>
<div class="item-body">
<div class="item-meta"><strong>Alex Morgan</strong><span>2 min ago</span></div>
<p>Just shipped a new component library update with mobile-first patterns.</p>
</div>
</li>
<li class="feed-item">
<div class="item-avatar av2"></div>
<div class="item-body">
<div class="item-meta"><strong>Sam Rivera</strong><span>14 min ago</span></div>
<p>The new iOS gestures API is really powerful for building native-feeling web apps.</p>
</div>
</li>
<li class="feed-item">
<div class="item-avatar av3"></div>
<div class="item-body">
<div class="item-meta"><strong>Jordan Lee</strong><span>1 hr ago</span></div>
<p>Finished the swipe-to-reveal component. The momentum snapping feels really good.</p>
</div>
</li>
<li class="feed-item">
<div class="item-avatar av1"></div>
<div class="item-body">
<div class="item-meta"><strong>Alex Morgan</strong><span>3 hr ago</span></div>
<p>Pull-to-refresh is one of those patterns that just has to feel right. No cutting corners.</p>
</div>
</li>
<li class="feed-item">
<div class="item-avatar av4"></div>
<div class="item-body">
<div class="item-meta"><strong>Taylor Kim</strong><span>Yesterday</span></div>
<p>Reading about CSS scroll-snap. It pairs beautifully with touch carousels.</p>
</div>
</li>
<li class="feed-item">
<div class="item-avatar av2"></div>
<div class="item-body">
<div class="item-meta"><strong>Sam Rivera</strong><span>Yesterday</span></div>
<p>Pushed some performance improvements to the image lazy-load module. LCP is way down.</p>
</div>
</li>
</ul>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import React, { useCallback, useState } from "react";
import { FlatList, RefreshControl, StyleSheet, Text, View } from "react-native";
function generateItems(count: number): { id: string; title: string; subtitle: string }[] {
const topics = [
"React Native performance tips",
"Building offline-first apps",
"State management patterns",
"Animation best practices",
"Navigation deep linking",
"TypeScript generics guide",
"Custom hook patterns",
"Accessibility on mobile",
"Testing strategies",
"CI/CD for mobile apps",
"Dark mode implementation",
"Gesture handling techniques",
];
const shuffled = [...topics].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count).map((title, i) => ({
id: `${Date.now()}-${i}`,
title,
subtitle: `Updated ${Math.floor(Math.random() * 59) + 1}m ago`,
}));
}
export default function App() {
const [items, setItems] = useState(() => generateItems(10));
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setItems(generateItems(10));
setRefreshing(false);
}, 1000);
}, []);
return (
<View style={styles.container}>
<View style={styles.headerBar}>
<Text style={styles.headerTitle}>Feed</Text>
<Text style={styles.headerHint}>Pull down to refresh</Text>
</View>
<FlatList
data={items}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#818cf8"
colors={["#818cf8", "#34d399", "#f472b6"]}
progressBackgroundColor="#1e293b"
title="Refreshing..."
titleColor="#94a3b8"
/>
}
renderItem={({ item, index }) => (
<View style={styles.card}>
<View style={styles.cardIndex}>
<Text style={styles.cardIndexText}>{index + 1}</Text>
</View>
<View style={styles.cardBody}>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardSubtitle}>{item.subtitle}</Text>
</View>
</View>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
},
headerBar: {
paddingTop: 60,
paddingBottom: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: "#1e293b",
},
headerTitle: {
color: "#f8fafc",
fontSize: 28,
fontWeight: "700",
},
headerHint: {
color: "#64748b",
fontSize: 13,
marginTop: 4,
},
list: {
padding: 16,
},
card: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#1e293b",
borderRadius: 12,
padding: 16,
},
cardIndex: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#334155",
alignItems: "center",
justifyContent: "center",
marginRight: 14,
},
cardIndexText: {
color: "#818cf8",
fontSize: 14,
fontWeight: "700",
},
cardBody: {
flex: 1,
},
cardTitle: {
color: "#f8fafc",
fontSize: 15,
fontWeight: "600",
marginBottom: 2,
},
cardSubtitle: {
color: "#64748b",
fontSize: 13,
},
separator: {
height: 8,
},
});Pull to Refresh
A native-feeling pull-to-refresh interaction for scrollable lists. Pulling down past a threshold reveals a spinner and triggers a simulated refresh with new content.
How it works
touchstartrecords the initial Y position only when the list is scrolled to the toptouchmovetranslates the list downward and rotates the pull arrow as a progress indicator- Past the 80px threshold the arrow becomes a spinner and
.triggeredstate is set - On
touchenda short async “refresh” runs, new items prepend to the list, and the indicator retracts
Performance
- Uses
transform: translateY(nottop) to keep the pull animation on the compositor thread - The pull indicator is absolutely positioned above the scroll container so no layout reflow occurs
When to use it
- Mobile feeds and social timelines
- Notification or message lists
- Any scrollable list that benefits from manual refresh