Skip to content

Pull to Refresh ​

Pull a scroll area down with rubber-band resistance; a spinning indicator holds while your refresh runs.

Inbox
  • Lena Fischer9:41Q3 roadmap draft
  • GitHub9:12[vuelix] New pull request #128
  • Sam Okafor8:55Photos from the offsite
  • StripeYesterdayYour weekly summary
  • Priya NatarajanYesterdayRe: onboarding flow
  • FigmaMonKai commented on “Mobile v2”
  • Jonas BergMonInvoice #2041

Drag the list down to refresh

Drag the list down (touch, or press and drag with a mouse). The indicator's ring fills and its arrow turns as you pull; past the threshold it pops, and on release it spins until the promise returned by @refresh settles, then everything springs back.

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/pull-to-refresh.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/pull-to-refresh/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform } from 'motion-v'
import { computed, ref } from 'vue'
import { cn } from '@/lib/utils'

export type PullToRefreshState = 'idle' | 'pulling' | 'ready' | 'refreshing' | 'done'

const props = withDefaults(defineProps<{
  /** Called on release past the threshold; the spinner shows until it settles. Also bindable as `@refresh`. */
  onRefresh?: () => unknown
  /** Pull distance (px, after resistance) that triggers a refresh. */
  threshold?: number
  /** Asymptotic maximum pull distance (px). */
  maxPull?: number
  disabled?: boolean
  /** Screen-reader messages. */
  labels?: { ready?: string, refreshing?: string, done?: string }
  class?: HTMLAttributes['class']
}>(), {
  threshold: 72,
  maxPull: 140,
})

const springBack = { type: 'spring', stiffness: 380, damping: 34 } as const
const HOLD = 0.8 // fraction of the threshold the content rests at while refreshing
const SIZE = 36 // indicator diameter

const reduced = useReducedMotion()
const state = ref<PullToRefreshState>('idle')
const scroller = ref<HTMLElement>()

const pull = useMotionValue(0)
const progress = useTransform(pull, p => Math.min(1, p / props.threshold))
const indicatorY = useTransform(pull, p => Math.max(-SIZE - 8, p / 2 - SIZE / 2))
const indicatorOpacity = useTransform(pull, [0, 28], [0, 1])
const arrowRotate = useTransform(progress, p => p * 180)

const announcement = computed(() => ({
  idle: '',
  pulling: '',
  ready: props.labels?.ready ?? 'Release to refresh',
  refreshing: props.labels?.refreshing ?? 'Refreshing…',
  done: props.labels?.done ?? 'Updated',
}[state.value]))

/** Rubber band: the further you pull, the less it moves. */
function resist(dy: number) {
  return props.maxPull * (1 - Math.exp(-dy / props.maxPull))
}

function settle(to: number) {
  return reduced.value
    ? (pull.set(to), Promise.resolve())
    : animate(pull, to, springBack).then(() => {})
}

async function refresh() {
  if (state.value === 'refreshing')
    return
  state.value = 'refreshing'
  settle(props.threshold * HOLD)
  try {
    await props.onRefresh?.()
  }
  finally {
    state.value = 'done'
    await new Promise(r => setTimeout(r, 450))
    await settle(0)
    state.value = 'idle'
  }
}

// ── gesture (touch + mouse share the same logic) ────────────────────────────
let start: { y: number, pulling: boolean } | null = null

function begin(y: number) {
  if (props.disabled || state.value === 'refreshing' || state.value === 'done')
    return
  if ((scroller.value?.scrollTop ?? 0) > 0)
    return
  start = { y, pulling: false }
}

/** Returns true while the gesture is a pull (so the caller can block native scrolling). */
function move(y: number) {
  if (!start)
    return false
  const dy = y - start.y
  if (!start.pulling) {
    if (dy < -4 || (scroller.value?.scrollTop ?? 0) > 0) {
      start = null // scrolling up into the content: not ours
      return false
    }
    if (dy < 6)
      return false
    start.pulling = true
    start.y += 6 // don't jump by the slop distance
  }
  const p = resist(Math.max(0, y - start.y))
  pull.set(p)
  state.value = p >= props.threshold ? 'ready' : 'pulling'
  return true
}

function end() {
  if (!start)
    return
  const wasPulling = start.pulling
  start = null
  if (!wasPulling)
    return
  if (state.value === 'ready')
    refresh()
  else {
    state.value = 'idle'
    settle(0)
  }
}

function onTouchStart(e: TouchEvent) {
  begin(e.touches[0]!.clientY)
}
function onTouchMove(e: TouchEvent) {
  if (move(e.touches[0]!.clientY) && e.cancelable)
    e.preventDefault()
}

function swallowClick(e: MouseEvent) {
  e.stopPropagation()
  e.preventDefault()
}
function onPointerDown(e: PointerEvent) {
  if (e.pointerType !== 'mouse' || e.button !== 0)
    return
  begin(e.clientY)
}
function onPointerMove(e: PointerEvent) {
  if (e.pointerType !== 'mouse' || !start)
    return
  const wasPulling = start.pulling
  if (move(e.clientY) && !wasPulling)
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onPointerUp(e: PointerEvent) {
  if (e.pointerType !== 'mouse')
    return
  const dragged = start?.pulling
  end()
  if (dragged) {
    // A drag isn't a click on whatever is under the pointer.
    window.addEventListener('click', swallowClick, { capture: true, once: true })
    setTimeout(() => window.removeEventListener('click', swallowClick, { capture: true }), 0)
  }
}

defineExpose({ refresh, state })
</script>

<template>
  <div
    :class="cn('relative overflow-hidden', props.class)"
    :aria-busy="state === 'refreshing'"
  >
    <!-- Indicator: rides down with the pull, fills its ring, then spins -->
    <motion.div
      aria-hidden="true"
      class="pointer-events-none absolute left-1/2 top-0 z-10 flex -translate-x-1/2 items-center justify-center rounded-full border bg-background text-foreground shadow-md"
      :style="{ width: `${SIZE}px`, height: `${SIZE}px`, y: indicatorY, opacity: indicatorOpacity }"
      :animate="{ scale: state === 'ready' ? 1.12 : 1 }"
      :transition="{ type: 'spring', stiffness: 520, damping: 22 }"
    >
      <svg class="absolute inset-0 size-full -rotate-90" viewBox="0 0 36 36" fill="none">
        <circle cx="18" cy="18" r="15" stroke-width="2.5" class="stroke-muted" />
      </svg>
      <motion.svg
        v-if="state === 'refreshing'"
        class="absolute inset-0 size-full"
        viewBox="0 0 36 36"
        fill="none"
        :animate="reduced ? { opacity: [1, 0.4, 1] } : { rotate: 360 }"
        :transition="reduced
          ? { duration: 1.2, repeat: Infinity }
          : { duration: 0.8, repeat: Infinity, ease: 'linear' }"
      >
        <circle cx="18" cy="18" r="15" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="24 100" class="stroke-brand" />
      </motion.svg>
      <svg v-else class="absolute inset-0 size-full -rotate-90" viewBox="0 0 36 36" fill="none">
        <motion.circle cx="18" cy="18" r="15" stroke-width="2.5" stroke-linecap="round" class="stroke-brand" :style="{ pathLength: progress }" />
      </svg>

      <AnimatePresence :initial="false" mode="wait">
        <motion.svg
          v-if="state === 'done'"
          key="done"
          class="relative size-4 text-brand"
          viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
          :initial="{ scale: 0.4, opacity: 0 }"
          :animate="{ scale: 1, opacity: 1 }"
          :exit="{ scale: 0.4, opacity: 0 }"
          :transition="{ type: 'spring', stiffness: 600, damping: 26 }"
        >
          <path d="M20 6 9 17l-5-5" />
        </motion.svg>
        <motion.svg
          v-else-if="state !== 'refreshing'"
          key="arrow"
          class="relative size-4"
          viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
          :style="{ rotate: arrowRotate }"
          :exit="{ scale: 0.4, opacity: 0, transition: { duration: 0.1 } }"
        >
          <path d="M12 5v14M19 12l-7 7-7-7" />
        </motion.svg>
      </AnimatePresence>
    </motion.div>

    <motion.div class="h-full" :style="{ y: pull }">
      <div
        ref="scroller"
        class="h-full overflow-y-auto overscroll-y-contain"
        :class="state === 'pulling' || state === 'ready' ? 'cursor-grabbing select-none' : ''"
        @touchstart.passive="onTouchStart"
        @touchmove="onTouchMove"
        @touchend="end"
        @touchcancel="end"
        @pointerdown="onPointerDown"
        @pointermove="onPointerMove"
        @pointerup="onPointerUp"
        @pointercancel="onPointerUp"
      >
        <slot :state="state" />
      </div>
    </motion.div>

    <span class="sr-only" role="status" aria-live="polite">{{ announcement }}</span>
  </div>
</template>
ts
export { default as PullToRefresh } from './PullToRefresh.vue'
export type { PullToRefreshState } from './PullToRefresh.vue'

Usage ​

ts
import { PullToRefresh } from '@/components/ui/pull-to-refresh'
vue
<PullToRefresh class="h-96" @refresh="reload">
  <ul>…</ul>
</PullToRefresh>

The component is the scroll container: give it a height. @refresh (or :on-refresh) may return a promise. A pull is a gesture only — also offer a visible refresh button for keyboard users; it can call the exposed refresh() so the same indicator plays.

API ​

PropTypeDefaultDescription
@refresh / onRefresh() => unknown | Promise<unknown>—Runs on release past the threshold.
thresholdnumber72Pull distance (px, after resistance) that triggers a refresh.
maxPullnumber140Asymptotic limit of the pull (px).
disabledbooleanfalseTurns the gesture off.
labels{ ready?, refreshing?, done? }EnglishScreen-reader announcements.
ExposedTypeDescription
refresh()() => Promise<void>Run a refresh with the indicator, e.g. from a button.
stateRef<'idle' | 'pulling' | 'ready' | 'refreshing' | 'done'>Current phase (also passed to the default slot).

Designed by Kuldeep Pisda · Released under the MIT License