Installation
npm install react-driftkitimport { PullToRefresh } from 'react-driftkit';Interactive Demo
Threshold
64px
Resistance
0.70
Wheel / trackpad
on — scroll up at the top of the list
State
idle · via — · 0 refreshes
On a phone, drag the list down. On a desktop, either drag with the mouse or just scroll up with a wheel or trackpad while the list is at the top — the overscroll drives the same gesture and releases when the input goes quiet. Scroll down first and the gesture stays out of the way, because it only arms when the inner scroller is already at its top. The button runs the identical code path: this demo is in controlled mode, where the
refreshing prop owns the state and the gesture is just one way to set it.API Reference
| Prop | Type | Default |
|---|---|---|
children The scrollable content. It is translated down as the pull opens. | ReactNode | — |
indicator Renders the reveal area above the content, called on every pull frame with the live state. Omit it and the pull still works — there is just nothing to see. | (state: PullToRefreshState) => ReactNode | — |
indicatorHeight Height in px of the indicator strip. The strip is parked exactly this far above the fold at rest. | number | 56 |
refreshing Controlled refreshing flag. When provided, the component holds the refreshing state until this flips back to false — use it to drive a refresh from a button or any other non-gesture affordance. Omit for uncontrolled. | boolean | — |
disabled Turns the gesture off without unmounting. An in-flight pull settles back immediately. | boolean | false |
behavior Gesture tuning — thresholds, damping, and which input paths are live. | PullToRefreshBehavior | — |
behavior.threshold Pull distance in px that arms a refresh. Releasing at or past it fires on.refresh. | number | 64 |
behavior.maxPull Ceiling the damped pull asymptotically approaches. The content never travels further than this no matter how hard the user pulls. | number | 160 |
behavior.resistance How much of the raw input travel becomes pull distance before damping. Lower values feel heavier. | number | 0.7 |
behavior.refreshOffset Distance the content holds at while refreshing, so the indicator stays visible. | number | threshold |
behavior.pointer Enables the pointer-drag path — one code path for mouse, touch, and pen. | boolean | true |
behavior.wheel Enables the wheel & trackpad overscroll path. This is what makes the gesture usable on desktop, where nobody drags a list with the mouse. | boolean | true |
behavior.wheelReleaseMs Quiet period in ms after the last wheel event that counts as a release. A wheel has no pointerup, so the gesture ends when the input stops. | number | 140 |
behavior.minRefreshMs Minimum time the refreshing state is held, so a fast response does not flash the indicator. Uncontrolled mode only. | number | 400 |
behavior.scrollSelector CSS selector for the scroll container to gate the gesture on. By default the nearest scrollable ancestor of the touched element is used, falling back to the page scroller. | string | — |
animation Transition used for the refresh hold and the settle animation — duration (ms) and easing (CSS easing). Skipped entirely under prefers-reduced-motion. | PullToRefreshAnimation | — |
animation.duration Transition duration in milliseconds. | number | 280 |
animation.easing CSS easing function applied to the transform transitions. | string | 'cubic-bezier(0.22, 1, 0.36, 1)' |
on Event handlers: refresh, stateChange. Both optional. | PullToRefreshEvents | — |
on.refresh Fires when a pull is released past the threshold. Return a promise and the component holds the refreshing state until it settles. Ignored in controlled mode, where the refreshing prop decides. | () => void | Promise<unknown> | — |
on.stateChange Fires on every phase transition — idle, pulling, armed, refreshing, settling. Useful for haptics on arm. | (state: PullToRefreshState) => void | — |
className CSS class added to the outer container. | string | '' |
style Inline styles merged onto the outer container. | CSSProperties | — |
contentClassName CSS class added to the translated content wrapper — use it when the wrapper needs a height for a nested scroller. | string | '' |
contentStyle Inline styles merged onto the translated content wrapper. | CSSProperties | — |
indicatorClassName CSS class added to the indicator strip. | string | '' |
indicatorStyle Inline styles merged onto the indicator strip. | CSSProperties | — |
The container exposes data-ptr-phase, data-ptr-armed, and data-ptr-source (pointer or wheel) so you can drive styles from CSS without a re-render, plus aria-busy while refreshing. The indicator strip is a polite live region, so whatever you render there is announced. The gesture only starts when the nearest scrollable ancestor is already at the top, so inner scrolling is never hijacked. Note that the container sets overflow: clip to hide the parked indicator — unlike overflow: hidden this does not create a scroll container, so position: sticky children keep working.
Code Examples
tsx
import { useState } from 'react';
import { PullToRefresh } from 'react-driftkit';
function Feed() {
const [items, setItems] = useState(initialItems);
return (
<PullToRefresh
on={{ refresh: async () => setItems(await fetchItems()) }}
indicator={({ progress, armed, refreshing }) => (
<span>
{refreshing ? 'Refreshing…' : armed ? 'Release to refresh' : 'Pull to refresh'}
{' '}({Math.round(progress * 100)}%)
</span>
)}
>
{items.map((item) => <Row key={item.id} {...item} />)}
</PullToRefresh>
);
}Types
typescript
type PullToRefreshPhase =
| 'idle' | 'pulling' | 'armed' | 'refreshing' | 'settling';
type PullSource = 'pointer' | 'wheel';
interface PullToRefreshState {
phase: PullToRefreshPhase;
distance: number; // px, after resistance damping
progress: number; // distance / threshold, clamped to [0, 1]
armed: boolean; // release now and it refreshes
refreshing: boolean;
source: PullSource | null;
}
interface PullToRefreshProps {
children?: ReactNode;
// Renders the reveal area above the content, on every pull frame.
indicator?: (state: PullToRefreshState) => ReactNode;
indicatorHeight?: number; // default 56 (px)
// Controlled refreshing flag. Omit and the promise returned by
// `on.refresh` decides when the refresh is over.
refreshing?: boolean;
disabled?: boolean; // default false
behavior?: {
threshold?: number; // default 64 — distance that arms a refresh
maxPull?: number; // default 160 — damping ceiling
resistance?: number; // default 0.7 — lower feels heavier
refreshOffset?: number; // defaults to threshold
pointer?: boolean; // default true — mouse / touch / pen
wheel?: boolean; // default true — wheel & trackpad
wheelReleaseMs?: number; // default 140 — quiet period that ends a wheel pull
minRefreshMs?: number; // default 400 — anti-flicker floor
scrollSelector?: string; // override scroller auto-detection
};
animation?: {
duration?: number; // default 280 (ms)
easing?: string; // default 'cubic-bezier(0.22, 1, 0.36, 1)'
};
on?: {
refresh?: () => void | Promise<unknown>;
stateChange?: (state: PullToRefreshState) => void;
};
className?: string;
style?: CSSProperties;
contentClassName?: string;
contentStyle?: CSSProperties;
indicatorClassName?: string;
indicatorStyle?: CSSProperties;
}Enjoying react-driftkit?
Star the repo on GitHub to help more devs discover it.