# Pull To Refresh

> A React pull to refresh wrapper: pull a list past its top to reload it, with rubberband resistance, iOS spokes or a Material ring, haptics.

- Type: React component (Mobile)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add pull-to-refresh`
- Docs: https://wingo-ui.com/components/pull-to-refresh

## Interaction

At the top of the list, drag down with a finger: the content follows with growing resistance and the indicator appears in the gap (an arrow, the iOS spokes drawing one by one, or a Material circle whose ring fills). Past the threshold the arrow turns, the spokes complete or the ring saturates, and the phone ticks once. Let go there and it spins while onRefresh runs (500ms at least), shows a check (or an alert when the promise rejects) and the content glides back; let go earlier and it springs back. Swiping sideways or up is left to the page, and pulls never start in inputs, [data-no-pull], a sideways scroller or a nested list that is not at its own top. On a trackpad or mouse wheel, scroll up once more at the very top to pull; stop for a moment to release. With pullWithMouse a mouse drag pulls too. There is no gesture on the keyboard: bind shortcut, or call setApi().refresh() from a refresh button. Screen readers hear Refreshing, then Updated or the error.

## Installation

```bash
npx wingo-ui@latest add pull-to-refresh
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/pull-to-refresh` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/pull-to-refresh.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
"use client"

import { useState } from "react"
import { RotateCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { PullToRefresh, type PullToRefreshApi } from "@/components/ui/pull-to-refresh"

type Message = { id: string; from: string; text: string }

export function Inbox({ load }: { load: () => Promise<Message[]> }) {
  const [messages, setMessages] = useState<Message[]>([])
  const [api, setApi] = useState<PullToRefreshApi>()

  return (
    <div className="flex h-dvh flex-col">
      <header className="flex h-14 items-center justify-between px-4">
        <h1 className="text-lg font-semibold">Inbox</h1>
        <Button variant="ghost" size="icon" radius="full" aria-label="Reload" onClick={() => api?.refresh()}>
          <RotateCw />
        </Button>
      </header>
      <PullToRefresh
        scroll="self"
        className="min-h-0 flex-1"
        setApi={setApi}
        onRefresh={async () => setMessages(await load())}
        labels={{ pull: "Pull to check for mail", release: "Let go to check", refreshing: "Checking for mail…", done: "Up to date", error: "Could not check for mail" }}
      >
        <ul className="divide-y divide-foreground/10">
          {messages.map((m) => (
            <li key={m.id} className="px-4 py-3">
              <p className="font-medium">{m.from}</p>
              <p className="text-sm text-muted-foreground">{m.text}</p>
            </li>
          ))}
        </ul>
      </PullToRefresh>
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children?` | `ReactNode` |  | The list or feed that gets pulled. |
| `onRefresh?` | `(() => void \| Promise<unknown>)` |  | The reload. The indicator spins until the returned promise settles, and for 500ms at least so it never flashes. A rejection shows the error state. |
| `variant?` | `"arrow" \| "ios" \| "material"` | `"arrow"` | arrow: an arrow that turns at the threshold and becomes a spinner; the content moves down. ios: 12 spokes that draw one by one as you pull, then spin like UIRefreshControl; the content moves down. material: a floating circle whose ring fills while you pull, then spins; the content stays put. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Indicator 20 / 24 / 28px; the material circle 36 / 40 / 44px. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The indicator color. neutral is the quiet muted gray (the material ring is black / white); the other tones use their tone text color and fill. |
| `color?` | `string` | `""` | Any CSS color for the indicator. Replaces the tone. Leave empty to use the tone. |
| `showLabel?` | `boolean` | `false` | Text under the indicator that follows the state: pull, release, refreshing, done or error. |
| `labels?` | `Partial<PullToRefreshLabels>` |  | The strings shown with showLabel and announced to screen readers: pull "Pull to refresh", release "Release to refresh", refreshing "Refreshing…", done "Updated", error "Couldn't refresh". Pass only the keys you translate. |
| `refreshing?` | `boolean` | `false` | Shows the refreshing state for a reload the app started itself (the content parks at the threshold with a spinner). OR-ed with the internal state, so false never blocks a pull. |
| `disabled?` | `boolean` | `false` | No pulling (offline, editing, a filter is active). Turning it on mid-pull springs back without refreshing. |
| `doneDuration?` | `number` | `600` | Milliseconds the check (or the alert) stays before the content glides back. 0 skips the done state. |
| `threshold?` | `number` | `72` | Pixels of resisted pull that arm the refresh; also where the content rests while it refreshes. |
| `maxPull?` | `number` | `128` | The furthest the content moves, however far the finger goes. |
| `indicatorOffset?` | `number` | `0` | Pixels from the top of the root to the indicator's resting line, for a sticky or translucent bar over the list. |
| `scroll?` | `"parent" \| "self"` | `"parent"` | parent: watches scrollTarget, else the provided scroll container (ScrollContainerProvider), else the nearest scrolling ancestor, else the window. self: the root is the scroll container (h-full overflow-y-auto overscroll-y-contain). |
| `scrollTarget?` | `HTMLElement \| Window \| RefObject<HTMLElement \| null> \| null` |  | The scroller to watch when scroll is parent. null forces the window. |
| `wheel?` | `boolean` | `true` | A trackpad or mouse wheel scrolling up at the very top pulls too, the desktop path. Momentum that carries a scroll up to the top never pulls. |
| `pullWithMouse?` | `boolean` | `false` | A mouse drag at the top pulls, for kiosks and touch emulation. Off by default so it never fights text selection and clicks. |
| `haptics?` | `boolean` | `true` | A "medium" tick when the pull arms and an "error" buzz when a pulled refresh fails, through lib/haptics. |
| `shortcut?` | `string` | `""` | A key that refreshes, in mod+k notation ("mod+r", "shift+r"). None by default. |
| `setApi?` | `(api: PullToRefreshApi) => void` |  | Receives { refresh(): Promise<void> }, which runs the same flow as a pull (indicator, promise, done). Calls during a running refresh return the running promise. |
| `classNames?` | `Partial<Record<"indicator" \| "icon" \| "spinner" \| "label" \| "content", string>>` |  | Extra classes for the indicator row, the arrow / check / alert icon, the spinner, the label and the content wrapper. |
| `className?` | `string` |  | Extra classes for the root, merged last. |

## Parts

### DEFAULT_PULL_TO_REFRESH_LABELS (constant)

The English copy: pull "Pull to refresh", release "Release to refresh", refreshing, done "Updated" and error "Couldn't refresh".

### PullToRefreshApi (type)

What setApi hands you: { refresh(): Promise<void> }, to run the same refresh (indicator included) from a button or a keyboard shortcut.

## Examples

- **Three variants**: arrow turns and becomes a spinner, ios draws its spokes, material floats a ring over a list that stays put.
- **Custom labels**: showLabel with custom labels; the header button runs the same flow through setApi().refresh().
- **Failure**: While Offline is on the request rejects: an alert, Could not check for mail, and an error haptic.
- **Under a large title**: scroll="parent" watches the frame's ScrollContainerProvider; the large title stays, the list moves.
- **Around a List**: The list's own refreshing bar mirrors the promise; material keeps the rows in place.
- **Brand on a feed**: tone="brand" material over a marketplace grid.
- **Disabled while filtering**: Typing a search turns disabled on, so a pull on filtered results does nothing.

## See also

- [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) ([Markdown](https://wingo-ui.com/components/swipeable-tabs.md))
- [List](https://wingo-ui.com/components/list) ([Markdown](https://wingo-ui.com/components/list.md))
- [Virtual List](https://wingo-ui.com/components/virtual-list) ([Markdown](https://wingo-ui.com/components/virtual-list.md))
- [Large Title Header](https://wingo-ui.com/components/large-title-header) ([Markdown](https://wingo-ui.com/components/large-title-header.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))

---

Canonical: https://wingo-ui.com/components/pull-to-refresh
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
