Pull to Refresh
Pull down from the top of a page to refresh its content.
Wrap a page’s scroll content and let users pull down from the top to refresh it. Past a threshold the gesture arms and fires; the platform indicator holds while the work runs and retracts when it settles. iOS scales a pull-spinner in and ticks it round; Material descends an elevated disc whose arc fills with the pull and spins on trigger.
The gesture is touch-only — preview it with device emulation (Chrome DevTools) or a real device.
Installation
npx touchcn add pull-to-refresh
Usage
<tcn-pull-to-refresh #ptr="tcnPullToRefresh" (refresh)="onRefresh(ptr)">
<!-- scrollable content -->
</tcn-pull-to-refresh>import { TcnPullToRefresh } from '@/components/ui/pull-to-refresh';
<TcnPullToRefresh onRefresh={onRefresh}>
{/* scrollable content */}
</TcnPullToRefresh><script setup lang="ts">
import { TcnPullToRefresh } from '@/components/ui/pull-to-refresh';
</script>
<template>
<TcnPullToRefresh :on-refresh="onRefresh">
<!-- scrollable content -->
</TcnPullToRefresh>
</template>On Angular, run the refresh work when refresh fires and call complete() (via the template ref) to retract. On React, return a Promise from onRefresh — the indicator holds until it settles.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
refresh / onRefresh |
() => void | Promise<void> |
— | Fires when pulled past the threshold. |
threshold |
number |
70 |
Finger distance (px) that arms the refresh. |
disabled |
boolean |
false |
Disables arming (an in-flight refresh still resolves). |
complete() |
method (Angular) | — | Retracts the indicator; React resolves via the Promise. |
Source · Angular
export * from './tcn-pull-to-refresh';import { Component, inject } from '@angular/core';
import { TcnPullToRefreshDirective } from '@touchcn/angular/pull-to-refresh';
/**
* Pull-to-refresh container. Wrap the page's scroll content in it; pulling down
* from the top reveals a platform indicator and, past the threshold, emits
* `refresh`. Run your refresh work in that handler, then call `complete()` (via
* a template ref) to retract.
*
* All gesture behaviour — touch tracking, resistance, the `translateY` +
* `--tcn-ptr-progress` writes and the `data-state` — lives in the engine
* `TcnPullToRefreshDirective`; this component owns only markup and classes. The
* indicator reuses the spinner spoke/arc visuals: iOS scales the spokes in and
* ticks them round; MD descends an elevated disc whose arc arm fills with the
* pull and spins on trigger.
*/
@Component({
selector: 'tcn-pull-to-refresh',
exportAs: 'tcnPullToRefresh',
hostDirectives: [
{
directive: TcnPullToRefreshDirective,
inputs: ['disabled', 'threshold'],
outputs: ['refresh'],
},
],
template: `
<div class="tcn-ptr-indicator" aria-hidden="true">
<span class="tcn-ptr-spinner if-ios">
@for (spoke of spokes; track $index) {
<span class="tcn-spinner-spoke"></span>
}
</span>
<span class="tcn-ptr-disc if-md">
<svg class="tcn-ptr-arc" viewBox="0 0 48 48" aria-hidden="true">
<circle class="tcn-ptr-arc-track" cx="24" cy="24" r="20" />
<circle class="tcn-ptr-arc-arm" cx="24" cy="24" r="20" />
</svg>
</span>
</div>
<ng-content />
`,
host: { class: 'tcn-pull-to-refresh block' },
})
export class TcnPullToRefresh {
private readonly ptr = inject(TcnPullToRefreshDirective);
protected readonly spokes = Array.from({ length: 8 });
/** Retracts the indicator once refresh work has settled. */
complete(): void {
this.ptr.complete();
}
}Source · React
export * from './tcn-pull-to-refresh';import type { ComponentPropsWithoutRef } from 'react';
import { cn } from '@touchcn/core';
import { usePullToRefresh } from '@touchcn/react';
export interface TcnPullToRefreshProps extends Omit<ComponentPropsWithoutRef<'div'>, 'onScroll'> {
/**
* Runs the refresh work. Return a Promise (or async) — the indicator stays
* open until it resolves, then retracts.
*/
onRefresh: () => void | Promise<void>;
/** Finger distance (px) that arms the refresh. Defaults to 70. */
threshold?: number;
/** Disables arming entirely. */
disabled?: boolean;
}
/**
* Pull-to-refresh container. Wrap the page's scroll content in it; pulling down
* from the top reveals a platform indicator and, past the threshold, runs
* `onRefresh` — the indicator holds until the returned Promise settles.
*
* All gesture behaviour — touch tracking, resistance, the `translateY` +
* `--tcn-ptr-progress` writes and the `data-state` — lives in the engine
* `usePullToRefresh` hook; this component owns only markup and classes. The
* indicator reuses the spinner spoke/arc visuals: iOS scales the spokes in and
* ticks them round; MD descends an elevated disc whose arc arm fills with the
* pull and spins on trigger.
*/
export function TcnPullToRefresh({ onRefresh, threshold, disabled, className, children, ...props }: TcnPullToRefreshProps) {
const { hostRef, state } = usePullToRefresh({ onRefresh, threshold, disabled });
return (
<div ref={hostRef} data-state={state} className={cn('tcn-pull-to-refresh block', className)} {...props}>
<div className="tcn-ptr-indicator" aria-hidden="true">
<span className="tcn-ptr-spinner if-ios">
{Array.from({ length: 8 }).map((_, index) => (
<span key={index} className="tcn-spinner-spoke" />
))}
</span>
<span className="tcn-ptr-disc if-md">
<svg className="tcn-ptr-arc" viewBox="0 0 48 48" aria-hidden="true">
<circle className="tcn-ptr-arc-track" cx="24" cy="24" r="20" />
<circle className="tcn-ptr-arc-arm" cx="24" cy="24" r="20" />
</svg>
</span>
</div>
{children}
</div>
);
}Source · Vue
<script setup lang="ts">
import { usePullToRefresh } from '@touchcn/vue';
const props = defineProps<{
/**
* Runs the refresh work. Return a Promise (or async) — the indicator stays
* open until it resolves, then retracts.
*/
onRefresh: () => void | Promise<void>;
/** Finger distance (px) that arms the refresh. Defaults to 70. */
threshold?: number;
/** Disables arming entirely. */
disabled?: boolean;
}>();
/**
* Pull-to-refresh container. Wrap the page's scroll content in it; pulling down
* from the top reveals a platform indicator and, past the threshold, runs
* `onRefresh` — the indicator holds until the returned Promise settles.
*
* All gesture behaviour — touch tracking, resistance, the `translateY` +
* `--tcn-ptr-progress` writes and the `data-state` — lives in the engine
* `usePullToRefresh` hook; this component owns only markup and classes. The
* indicator reuses the spinner spoke/arc visuals: iOS scales the spokes in and
* ticks them round; MD descends an elevated disc whose arc arm fills with the
* pull and spins on trigger.
*/
const { setHost, state } = usePullToRefresh(() => ({
onRefresh: props.onRefresh,
threshold: props.threshold,
disabled: props.disabled,
}));
</script>
<template>
<div :ref="setHost" :data-state="state" class="tcn-pull-to-refresh block">
<div class="tcn-ptr-indicator" aria-hidden="true">
<span class="tcn-ptr-spinner if-ios">
<span v-for="i in 8" :key="i" class="tcn-spinner-spoke" />
</span>
<span class="tcn-ptr-disc if-md">
<svg class="tcn-ptr-arc" viewBox="0 0 48 48" aria-hidden="true">
<circle class="tcn-ptr-arc-track" cx="24" cy="24" r="20" />
<circle class="tcn-ptr-arc-arm" cx="24" cy="24" r="20" />
</svg>
</span>
</div>
<slot />
</div>
</template>export { default as TcnPullToRefresh } from './TcnPullToRefresh.vue';