Skip to content
38

Pull to Refresh

A scroll area that refreshes when pulled down from the top, with a rubber-band feel, an arrow that flips when letting go will refresh, and your shadcn Spinner while it loads. Keeps the page from bouncing underneath.

Example

Installation

npx shadcn@latest add https://hirael.com/r/pull-to-refresh.json

Usage

import { PullToRefresh } from "@/components/pull-to-refresh"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<PullToRefresh />

+ native element props
PropTypeDefault
onRefresh*

Runs when the reader pulls past the threshold. The spinner shows until it settles.

() => Promise<unknown> | voidnone
threshold

Pixels to pull before letting go refreshes.

number72
mouse

Also respond to mouse drags, which helps on desktop and in demos.

booleanfalse
disabledbooleanfalse
labelsPartial<PullToRefreshLabels>none

Component source

'use client';

import * as React from 'react';
import { ArrowDown } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Spinner } from '@/registry/hirael/bases/radix/ui/spinner';

export interface PullToRefreshLabels {
  pull: string;
  release: string;
  refreshing: string;
}

const DEFAULT_LABELS: PullToRefreshLabels = {
  pull: 'Pull to refresh',
  release: 'Release to refresh',
  refreshing: 'Refreshing',
};

export interface PullToRefreshProps extends React.ComponentProps<'div'> {
  /** Runs when the reader pulls past the threshold. The spinner shows until it settles. */
  onRefresh: () => Promise<unknown> | void;
  /** Pixels to pull before letting go refreshes. */
  threshold?: number;
  /** Also respond to mouse drags, which helps on desktop and in demos. */
  mouse?: boolean;
  disabled?: boolean;
  labels?: Partial<PullToRefreshLabels>;
}

/**
 * A scroll area that refreshes when pulled down from the top, like a mobile feed.
 * It is the scroll container itself, so give it a height.
 */
const PullToRefresh = ({
  onRefresh,
  threshold = 72,
  mouse = false,
  disabled = false,
  labels,
  className,
  children,
  ...props
}: PullToRefreshProps) => {
  const text = { ...DEFAULT_LABELS, ...labels };
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const [pull, setPull] = React.useState(0);
  const [refreshing, setRefreshing] = React.useState(false);
  const onRefreshRef = React.useRef(onRefresh);

  React.useLayoutEffect(() => {
    onRefreshRef.current = onRefresh;
  });

  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el || disabled || refreshing) return;
    let startY: number | null = null;
    let current = 0;

    // Rubber-band: the further the pull, the less each pixel moves the content.
    const resist = (dy: number) => Math.min(threshold * 1.6, dy * 0.5);

    const begin = (y: number) => {
      startY = el.scrollTop <= 0 ? y : null;
      current = 0;
    };
    const move = (y: number, event: Event) => {
      if (startY === null) return;
      const dy = y - startY;
      if (dy <= 0) {
        current = 0;
        setPull(0);

        return;
      }
      // While pulling, the page must not scroll or bounce underneath.
      if (event.cancelable) event.preventDefault();
      current = resist(dy);
      setPull(current);
    };
    const end = () => {
      if (startY === null) return;
      startY = null;
      if (current < threshold) {
        setPull(0);

        return;
      }
      setPull(threshold * 0.75);
      setRefreshing(true);
      // Called inside the chain so a handler that throws before returning a promise still ends the refresh.
      void Promise.resolve()
        .then(() => onRefreshRef.current())
        .finally(() => {
          setRefreshing(false);
          setPull(0);
        });
    };

    const onTouchStart = (event: TouchEvent) => begin(event.touches[0].clientY);
    const onTouchMove = (event: TouchEvent) => move(event.touches[0].clientY, event);
    const onMouseDown = (event: MouseEvent) => {
      if (event.button !== 0) return;
      begin(event.clientY);
      const onMouseMove = (moveEvent: MouseEvent) => move(moveEvent.clientY, moveEvent);
      const onMouseUp = () => {
        window.removeEventListener('mousemove', onMouseMove);
        window.removeEventListener('mouseup', onMouseUp);
        end();
      };
      window.addEventListener('mousemove', onMouseMove);
      window.addEventListener('mouseup', onMouseUp);
    };

    el.addEventListener('touchstart', onTouchStart, { passive: true });
    // Not passive, so a pull can stop the native scroll.
    el.addEventListener('touchmove', onTouchMove, { passive: false });
    el.addEventListener('touchend', end);
    el.addEventListener('touchcancel', end);
    if (mouse) el.addEventListener('mousedown', onMouseDown);

    return () => {
      el.removeEventListener('touchstart', onTouchStart);
      el.removeEventListener('touchmove', onTouchMove);
      el.removeEventListener('touchend', end);
      el.removeEventListener('touchcancel', end);
      el.removeEventListener('mousedown', onMouseDown);
    };
  }, [disabled, refreshing, threshold, mouse]);

  const progress = Math.min(1, pull / threshold);
  const ready = progress >= 1;
  const settling = pull === 0 || refreshing;

  return (
    <div
      ref={scrollRef}
      data-slot="pull-to-refresh"
      data-refreshing={refreshing || undefined}
      aria-busy={refreshing || undefined}
      className={cn('relative overflow-y-auto overscroll-y-contain', mouse && 'select-none', className)}
      {...props}
    >
      <div
        aria-hidden={!refreshing}
        data-slot="pull-to-refresh-indicator"
        className="pointer-events-none absolute inset-x-0 top-0 flex items-end justify-center overflow-hidden text-xs text-muted-foreground"
        style={{ height: pull }}
      >
        <span className="flex items-center gap-2 pb-3" style={{ opacity: refreshing ? 1 : progress }}>
          {refreshing ? (
            <Spinner />
          ) : (
            <ArrowDown aria-hidden className={cn('size-4 transition-transform duration-200', ready && 'rotate-180')} />
          )}
          <span role={refreshing ? 'status' : undefined}>
            {refreshing ? text.refreshing : ready ? text.release : text.pull}
          </span>
        </span>
      </div>
      <div
        data-slot="pull-to-refresh-content"
        className={cn(settling && 'transition-transform duration-300 ease-out motion-reduce:transition-none')}
        style={{ transform: `translateY(${pull}px)` }}
      >
        {children}
      </div>
    </div>
  );
};

export { PullToRefresh };

Dependencies

shadcn registry

spinner

npm

lucide-react

Shipped