Pull to Refresh
Pull a list down past a threshold: the arrow rotates to say “release”, a spinner holds while loading, new rows slide in.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/pull-to-refresh.json'use client'
import { useRef, useState } from 'react'
import './pull-to-refresh.css'
export interface FeedItem { id: string; title: string; meta: string; isNew?: boolean }
export interface PullToRefreshProps {
items: FeedItem[]
onRefresh: () => Promise<FeedItem[]>
threshold?: number
title?: string
fill?: 'dark' | 'light'
className?: string
}
/** Pull-down-to-refresh list. */
export function PullToRefresh({ items: init, onRefresh, threshold = 64, title = 'Activity', fill = 'dark', className }: PullToRefreshProps) {
const [items, setItems] = useState(init)
const [y, setY] = useState(0)
const [drag, setDrag] = useState(false)
const [loading, setLoading] = useState(false)
const [msg, setMsg] = useState('')
const start = useRef<number | null>(null)
const refresh = async () => {
setLoading(true); setY(threshold); setMsg('Refreshing')
const fresh = await onRefresh()
setItems((it) => [...fresh.map((f) => ({ ...f, isNew: true })), ...it.map((i) => ({ ...i, isNew: false }))])
setLoading(false); setY(0); setMsg(fresh.length ? `${fresh.length} new` : 'Up to date')
}
const ready = y >= threshold
return (
<div className={['sd-ptr', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-ptr__top">{title}<button type="button" className="sd-ptr__rb" aria-label="Refresh" disabled={loading} onClick={refresh}><svg viewBox="0 0 16 16" aria-hidden><path d="M13 8a5 5 0 1 1-1.5-3.6M13 2.5v2.5h-2.5" /></svg></button></div>
<div
className="sd-ptr__vp"
data-drag={drag || undefined}
onPointerDown={(e) => { if (loading) return; e.currentTarget.setPointerCapture(e.pointerId); start.current = e.clientY; setDrag(true) }}
onPointerMove={(e) => { if (start.current === null) return; const d = e.clientY - start.current; setY(d > 0 ? Math.min(140, d * 0.5) : 0) }}
onPointerUp={() => { start.current = null; setDrag(false); if (ready && !loading) refresh(); else if (!loading) setY(0) }}
>
<div className="sd-ptr__ind" aria-hidden style={{ opacity: Math.min(1, y / threshold) }}>
{loading ? <span className="sd-ptr__spin" /> : <svg className="sd-ptr__arrow" data-flip={ready || undefined} viewBox="0 0 16 16"><path d="M8 3v10M4 9l4 4 4-4" /></svg>}
{loading ? 'Refreshing…' : ready ? 'Release to refresh' : 'Pull to refresh'}
</div>
<ul className="sd-ptr__list" style={{ transform: `translateY(${y}px)` }}>
{items.map((it, i) => (
<li key={it.id} className="sd-ptr__row" data-new={it.isNew || undefined} style={{ animationDelay: `${i * 60}ms` }}>
<span className="sd-ptr__av" aria-hidden>{it.title[0]}</span>
<span><span className="sd-ptr__t" style={{ display: 'block' }}>{it.title}</span><span className="sd-ptr__m">{it.meta}</span></span>
</li>
))}
</ul>
</div>
<span className="sr" aria-live="polite">{msg}</span>
</div>
)
}.sd-ptr { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-ptr[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-ptr button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-ptr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-ptr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-ptr { position: relative; width: min(100%, 320px); height: 380px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; display: flex; flex-direction: column; }
.sd-ptr__top { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid var(--line); font-weight: 650; font-size: 14px; z-index: 1; background: var(--bg); }
.sd-ptr__rb { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: var(--mute); }
.sd-ptr__rb:hover { color: var(--fg); background: var(--soft) !important; }
.sd-ptr__rb svg, .sd-ptr__ind svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.sd-ptr__vp { position: relative; flex: 1; overflow: hidden; touch-action: pan-x; cursor: grab; }
.sd-ptr__ind { position: absolute; top: 0; left: 0; right: 0; height: 64px; display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 12.5px; color: var(--mute); }
.sd-ptr__arrow { transition: transform 200ms; }
.sd-ptr__arrow[data-flip] { transform: rotate(180deg); }
.sd-ptr__spin { width: 16px; height: 16px; border-radius: 99px; border: 2px solid var(--fg); border-right-color: transparent; animation: sd-ptr-rot 700ms linear infinite; }
@keyframes sd-ptr-rot { to { transform: rotate(360deg); } }
.sd-ptr__list { position: relative; background: var(--bg); list-style: none; margin: 0; padding: 0; }
.sd-ptr__vp:not([data-drag]) .sd-ptr__list { transition: transform 360ms cubic-bezier(0.3, 1.2, 0.4, 1); }
.sd-ptr__row { display: flex; gap: 10px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.sd-ptr__row[data-new] { animation: sd-ptr-in 420ms cubic-bezier(0.2, 1, 0.3, 1) both; }
@keyframes sd-ptr-in { from { opacity: 0; transform: translateY(-12px); } }
.sd-ptr__av { width: 32px; height: 32px; flex: none; border-radius: 99px; display: grid; place-items: center; background: var(--soft); font-weight: 650; font-size: 12px; }
.sd-ptr__t { font-size: 13.5px; font-weight: 550; }
.sd-ptr__m { font-size: 12px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-ptr *, .sd-ptr *::before, .sd-ptr *::after { animation: none !important; transition: none !important; } }Overview
Drag down from the top of the list with resistance. Past the threshold the arrow flips and the hint changes to Release; letting go holds the indicator while your loader runs.
New rows animate in at the top. A Refresh button provides the same action without the gesture.
When to use
- Feeds, inboxes and lists on touch devices.
When not to use
- Desktop-only lists: a refresh button is enough.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/pull-to-refresh.json$
npx shadcn@latest add https://sundaeui.dev/r/pull-to-refresh.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/pull-to-refresh.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/pull-to-refresh.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy pull-to-refresh.tsx and pull-to-refresh.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { PullToRefresh } from '@/components/sundae/pull-to-refresh'
<PullToRefresh />Props
| Prop | Type | Default |
|---|---|---|
onRefresh*Loads new items. | () => Promise<T[]> | — |
items*Current items. | T[] | — |
thresholdPull distance in px. | number | 64 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A visible Refresh button offers the same action; status changes are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-ptr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Pull to Refresh free?
- Yes. Pull to Refresh is free for personal and commercial projects, with full source on this page.
- How do I install Pull to Refresh?
- Run npx shadcn@latest add https://sundaeui.dev/r/pull-to-refresh.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Pull to Refresh support keyboard and screen readers?
- A visible Refresh button offers the same action; status changes are announced. Reduced motion removes the movement and keeps every state change.
- Does Pull to Refresh respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Sheet Drawer
Components · Refined - Pro
Swipe Actions
Components · Refined Load More List
Components · RefinedDownload Button
Components · Refined- Pro
Sortable List
Components · Refined Submit Button
Components · Refined