Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 148 additions & 99 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,132 +1,181 @@
# React Pull to Refresh

Pull to refresh without owning your feed.
Add a downward refresh gesture to a React feed while keeping your existing data
fetching and cache. Useful for inboxes, activity lists, and other scrolling views
where people expect to pull at the top to check for updates.

A small, accessible pull-to-refresh primitive with scroll arbitration,
resistance, and an async refresh lifecycle.
The package coordinates scroll ownership, pull resistance, release thresholds,
and the pending refresh animation. You supply the refresh function, content,
indicator, and error handling. An ordinary refresh button remains available to
everyone, including people who cannot use the gesture.

Mobile pull-to-refresh depends on browser gesture ownership. See
[Browser Behavior](docs/BROWSER_BEHAVIOR.md).
[Demos and documentation](https://react-pull-to-refresh.nipesolutions.com) ·
[npm](https://www.npmjs.com/package/@nipe-solutions/react-pull-to-refresh) ·
[API reference](docs/API.md)

## Install

```bash
npm install @nipe-solutions/react-pull-to-refresh
```

Supports React 18.3 and React 19 with the matching `react-dom` peer. There are no
additional runtime dependencies.

## Refresh a scrolling inbox

This component accepts the current messages and your async reload function. Both
the button and the gesture use the same handler, which prevents overlapping
requests and reports success or failure. Your parent component updates `messages`
when `refreshMessages` loads new data.

```tsx
import { useRef, useState } from 'react'
import { PullToRefresh } from '@nipe-solutions/react-pull-to-refresh'
import '@nipe-solutions/react-pull-to-refresh/core.css'

export function Inbox() {
interface InboxProps {
messages: readonly { id: string; subject: string }[]
refreshMessages: () => Promise<void>
}

export function Inbox({ messages, refreshMessages }: InboxProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const inFlight = useRef(false)
const [refreshing, setRefreshing] = useState(false)
const [status, setStatus] = useState('')

async function refresh() {
await refetch()
if (inFlight.current) return
inFlight.current = true
setRefreshing(true)
setStatus('Refreshing inbox…')

try {
await refreshMessages()
setStatus('Inbox updated.')
} catch {
setStatus('Could not refresh. Please try again.')
} finally {
inFlight.current = false
setRefreshing(false)
}
}

return (
<>
<button type="button" onClick={() => void refresh()}>
<section aria-label="Inbox">
<button
type="button"
disabled={refreshing}
onClick={() => void refresh()}
>
Refresh inbox
</button>
<PullToRefresh.Root onRefresh={refresh}>
<PullToRefresh.Indicator>
<Spinner />
</PullToRefresh.Indicator>
<PullToRefresh.Content>
<Messages />
</PullToRefresh.Content>
</PullToRefresh.Root>
</>
<p role="status">{status}</p>
<div
ref={scrollRef}
style={{
height: '20rem',
overflowY: 'auto',
overscrollBehaviorY: 'contain',
}}
>
<PullToRefresh.Root
onRefresh={refresh}
disabled={refreshing}
scrollContainer={scrollRef}
>
<PullToRefresh.Indicator>
<span style={{ padding: '0.5rem' }}>↻</span>
</PullToRefresh.Indicator>
<PullToRefresh.Content>
<ul>
{messages.map((message) => (
<li key={message.id}>{message.subject}</li>
))}
</ul>
</PullToRefresh.Content>
</PullToRefresh.Root>
</div>
</section>
)
}
```

## Why this exists

Pull-to-refresh coordinates downward intent, the active scroll boundary,
resistance, threshold hysteresis, exactly-once refresh commitment, and settling.
It does not fetch data, own a cache, render a feed, or manage application errors.

## API

| Root prop | Type | Default | Purpose |
| --------------- | ---------------------------------- | -------- | ----------------------------------------------------------- |
| onRefresh | function returning void or Promise | required | Runs the application-owned refresh. |
| threshold | number | 72 | Visual pull distance required to arm. |
| disabled | boolean | false | Cancels an uncommitted gesture without disabling scrolling. |
| scrollContainer | HTMLElement, Window, or ref | auto | Overrides nearest scroll-surface detection. |

Root exposes data-state with idle, pending, pulling, armed, refreshing,
settling, or disabled. Indicator positions consumer-owned visual content and is
aria-hidden. Content provides a transform-isolated wrapper.

## CSS

Import core.css for mechanics. theme.css is an optional neutral starting point;
styles.css combines both.

```css
@import '@nipe-solutions/react-pull-to-refresh/core.css';
```

Available root variables:

- --ptr-distance: resisted visual distance in pixels
- --ptr-progress: threshold progress clamped from 0 to 1
- --ptr-overshoot: resisted distance beyond the threshold
- --ptr-threshold: configured threshold

## Scroll ownership

The root resolves an explicit owner or the nearest actually scrollable ancestor,
then Window. It checks nested scrollers from the gesture origin and rechecks the
boundary before claim. An explicit null ref stays unresolved until a subsequent
gesture. Gestures starting below the top stay browser-owned for that stream.

Core CSS does not set root overflow or overscroll policy. Apply optional
`overscroll-behavior-y: contain` to your actual scroller. Chromium page-level
containment may help suppress native refresh; it is not a universal guarantee.
Page-level custom Pull to Refresh on iOS Safari is not a guaranteed configuration.
Safari may retain native browser Pull-to-Refresh ownership.

## Accessibility

The gesture must not be the only refresh path. Pass the same application
function to a normal button. Add a polite status announcement only when it
benefits the product. The primitive adds no progress role, live region,
keyboard gesture, or focus movement.

## Browser notes

Directional-capable browsers use Pointer Events with `pan-x pan-down pinch-zoom`.
Firefox/Safari use a session-scoped Touch Events compatibility adapter. Automated
Chromium, Firefox, and WebKit tests cover mechanics, keyboard use, and axe rules.
They do not verify physical iOS or Android behavior. No physical-device or human
screen-reader session was performed for 1.0.0. Always provide an application
refresh button as the accessible alternative.

See [browser behavior](docs/BROWSER_BEHAVIOR.md),
[integrations](docs/INTEGRATIONS.md), and
[real-device QA](docs/REAL_DEVICE_QA.md).

## When not to use it

Prefer a refresh button when the app is desktop-only, content does not scroll,
browser-native refresh is enough, or refresh requires explicit confirmation.

## Development
`core.css` supplies positioning, translation, and touch policy. It does not
create a scroll container: the outer `div` above provides the scrolling layout.
The explicit ref keeps it the owner even when a short inbox does not overflow.
Its optional overscroll containment limits scroll chaining at that surface.
You can add `theme.css` after core for neutral indicator styling, or use
`styles.css` to import both together.

## Refresh lifecycle and presentation

`onRefresh` runs once when an armed pull is released. Return a promise to keep
the gesture in its refreshing state until the work finishes. Resolution,
rejection, and synchronous throws all settle the gesture; the library does not
display errors, retry requests, or cancel your promise. Handle those concerns in
the application, as the example does.

| Root prop | Default | Use |
| ----------------- | --------- | ---------------------------------------------------------------------- |
| `onRefresh` | Required | Function returning `void` or `Promise<void>`. |
| `threshold` | `72` | Positive visual pull distance in pixels required to arm. |
| `disabled` | `false` | Cancels an uncommitted gesture while preserving ordinary scrolling. |
| `scrollContainer` | Automatic | Explicit `HTMLElement`, `Window`, or element ref for scroll ownership. |

Root exposes `data-state`: `idle`, `pending`, `pulling`, `armed`, `refreshing`,
`settling`, or `disabled`. Use it and the `--ptr-distance`, `--ptr-progress`,
`--ptr-overshoot`, and `--ptr-threshold` CSS variables to style your indicator.
Indicator is `aria-hidden`; Content supplies the moving content wrapper. The
primitive adds no live region, keyboard gesture, or focus movement. The example
provides its own status announcement outside the indicator.

## Choose the scroll surface deliberately

Automatic detection selects Root or the nearest ancestor with vertical overflow,
then falls back to Window. Relevant nested scrollers must also be at the top.
A gesture that begins below the top stays with scrolling for that gesture,
even if it later reaches the boundary. Pass `scrollContainer` when your layout
needs an explicit owner; a ref whose `current` is null leaves the gesture
ineligible until a later gesture resolves it.

Page-level custom pull-to-refresh on iOS Safari is not guaranteed: Safari may
retain native browser refresh ownership. Overscroll CSS is application policy,
not a universal way to suppress native refresh. Element scrollers are a more
controllable starting point, but still need testing on the devices you support.
See [browser behavior](docs/BROWSER_BEHAVIOR.md) before choosing page-level use.

Inputs and editable content do not initiate the gesture. Mark custom surfaces
with `data-pull-to-refresh-ignore` when they should keep their own interaction.
For swipeable rows and bottom-sheet feeds, see [integrations](docs/INTEGRATIONS.md).

## Support and limits

Prefer a refresh button alone for desktop-only views, non-scrolling content,
or refresh actions that need confirmation. This package does not fetch data,
manage a cache, virtualize a list, or render a feed.

Directional-capable browsers use Pointer Events; browsers without directional
touch-action support use a session-scoped Touch Events adapter. Automated
Chromium, Firefox, and WebKit checks cover mechanics, keyboard use of fallback
controls, and axe rules. No physical iOS/Android or human screen-reader session
was performed for 1.0.0. Desktop WebKit does not establish physical iOS support.
Keep the refresh button available and consult the [real-device QA checklist](docs/REAL_DEVICE_QA.md).

## Working on the package

Requires Node 24 and npm 11.

```bash
npm install
npm run check
npm run test:e2e
```

Requires Node 24. React 18.3 and React 19 are peer-supported. Runtime
dependencies are limited to React peers.

Current status: 1.0.0 stable. Automated checks do not replace the documented
manual device and assistive-technology matrix.

Part of [NIPE Open Source](https://opensource.nipesolutions.com).
See [contributing](CONTRIBUTING.md), [security reporting](SECURITY.md), and
[architecture](docs/ARCHITECTURE.md). Part of
[NIPE Open Source](https://opensource.nipesolutions.com).

## License

Expand Down