Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

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';

Last updated on July 24, 2026

Was this page helpful?