# Pull to Refresh

A scroll container that refreshes its content when the user pulls down from the top.

Pull to Refresh is an open-source interaction from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/interactions/pull-to-refresh

**React**

**Example**

```tsx
import * as React from "react"
import { PullToRefresh } from "@/components/interactions/pull-to-refresh"

const senders: [from: string, subject: string][] = [
  ["Acme Store", "Your order has shipped"],
  ["Jordan Lee", "Tomorrow's meeting"],
  ["Cloud Drive", "Your storage is almost full"],
  ["Taylor Kim", "Weekend plans"],
  ["Design Team", "New mockups are ready"],
  ["Sam Rivera", "Lunch on Thursday?"],
  ["Bank Alerts", "Your statement is available"],
  ["Riley Park", "Photos from the trip"],
]

const incoming: [from: string, subject: string][] = [
  ["Morgan Diaz", "Quick question about the brief"],
  ["Travel Desk", "Your flight is confirmed"],
  ["Alex Chen", "Re: Project timeline"],
  ["News Digest", "Today's top stories"],
]

type Message = { id: number; from: string; subject: string; fresh: boolean }

function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function InboxExample({ empty = false }: { empty?: boolean }) {
  const [messages, setMessages] = React.useState<Message[]>(() =>
    empty
      ? []
      : senders.map(([from, subject], index) => ({
          id: index,
          from,
          subject,
          fresh: false,
        }))
  )
  const [log, setLog] = React.useState("Pull down from the top to refresh.")
  const count = React.useRef(0)

  const refresh = async () => {
    setLog("Refreshing…")
    await wait(1200)
    const next = incoming[count.current % incoming.length]
    if (!next) return
    const [from, subject] = next
    count.current += 1
    setMessages((current) => [
      { id: 100 + count.current, from, subject, fresh: true },
      ...current.map((message) => ({ ...message, fresh: false })),
    ])
    setLog(`New message from ${from}`)
  }

  return (
    <div className="mx-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-3">
      <PullToRefresh
        onRefresh={refresh}
        className="h-[min(36rem,calc(100dvh-7rem))] rounded-2xl border"
      >
        <h2 className="px-4 pt-4 pb-2 text-3xl font-bold tracking-tight">
          Inbox
        </h2>
        {messages.length === 0 ? (
          <p className="px-4 py-16 text-center text-sm text-muted-foreground">
            No messages yet.
          </p>
        ) : (
          <ul>
            {messages.map((message) => (
              <li
                key={message.id}
                data-fresh={message.fresh ? "" : undefined}
                className="ms-4 border-border not-first:border-t starting:opacity-0 data-fresh:transition-opacity data-fresh:duration-500"
              >
                <button
                  type="button"
                  onClick={() => setLog(`Opened ${message.subject}`)}
                  className="flex w-full flex-col py-3 pe-4 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
                >
                  <span className="flex items-center gap-2 font-semibold">
                    {message.fresh && (
                      <span className="size-2 rounded-full bg-primary" />
                    )}
                    {message.from}
                  </span>
                  <span className="truncate text-sm text-muted-foreground">
                    {message.subject}
                  </span>
                </button>
              </li>
            ))}
          </ul>
        )}
      </PullToRefresh>
      <p role="status" className="text-sm text-muted-foreground">
        {log}
      </p>
    </div>
  )
}

export default function PullToRefreshDemo() {
  return <InboxExample />
}
```

`PullToRefresh` is a vertically scrolling container. When it is scrolled to the top, dragging down with touch, or scrolling up with a trackpad or wheel, pulls the content down with increasing resistance. An indicator above the content fills in spoke by spoke as the pull nears the threshold. Releasing past the threshold calls `onRefresh`, holds the content open, and swaps the indicator for the suiss/ui `Spinner` until the refresh finishes; a shorter pull springs back without refreshing.

The spinner stays visible for at least 600 ms so fast refreshes still read clearly. Use it for lists of fetched content, such as feeds and inboxes.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/pull-to-refresh
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add interactions pull-to-refresh
```

### Manual

1. Add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper).

2. Add the pieces it builds on: [Spinner](https://ui.suiss.app/docs/components/spinner).

3. Copy the source into `components/interactions/pull-to-refresh.tsx`:

```tsx title="components/interactions/pull-to-refresh.tsx"
"use client"

import * as React from "react"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "@/lib/utils"

type PullToRefreshPhase = "idle" | "pulling" | "refreshing" | "settling"

const THRESHOLD = 64
const HOLD = 52
const MAX = 128
const MIN_REFRESH = 600
const SETTLE = 400
const WHEEL_GAP = 250
const WHEEL_IDLE = 200
const SPOKES = 8

const spokes = Array.from({ length: SPOKES }, (_, index) => ({
  angle: index * (360 / SPOKES),
  index,
}))

function resist(distance: number) {
  return MAX * (1 - Math.exp(-distance / (MAX * 1.6)))
}

function wheelDelta(event: WheelEvent) {
  return event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
}

function PullToRefresh({
  onRefresh,
  className,
  children,
  ref,
  ...props
}: React.ComponentProps<"div"> & {
  onRefresh: () => Promise<unknown> | unknown
}) {
  const rootRef = React.useRef<HTMLDivElement>(null)
  const onRefreshRef = React.useRef(onRefresh)
  const [phase, setPhase] = React.useState<PullToRefreshPhase>("idle")

  React.useEffect(() => {
    onRefreshRef.current = onRefresh
  }, [onRefresh])

  React.useEffect(() => {
    const root = rootRef.current
    if (!root) return
    let current: PullToRefreshPhase = "idle"
    let pull = 0
    let touchStart: number | null = null
    let wheelPull = 0
    let lastWheel = 0
    let wheelAtTop = false
    let wheelTimer = 0
    let settleTimer = 0
    let disposed = false

    const enter = (next: PullToRefreshPhase) => {
      if (disposed) return
      current = next
      root.dataset.phase = next
      setPhase(next)
    }

    const setPull = (value: number) => {
      pull = value
      root.style.setProperty("--pull", `${value}px`)
      root.style.setProperty(
        "--pull-progress",
        String(Math.min(value / THRESHOLD, 1))
      )
    }

    const release = async () => {
      if (pull < THRESHOLD) {
        enter("idle")
        setPull(0)
        return
      }
      enter("refreshing")
      setPull(HOLD)
      const started = performance.now()
      await Promise.allSettled([
        new Promise((resolve) => resolve(onRefreshRef.current())),
      ])
      const elapsed = performance.now() - started
      await new Promise((resolve) =>
        setTimeout(resolve, Math.max(0, MIN_REFRESH - elapsed))
      )
      if (disposed) return
      enter("settling")
      setPull(0)
      settleTimer = window.setTimeout(() => enter("idle"), SETTLE)
    }

    const pullTo = (distance: number) => {
      if (current !== "pulling") enter("pulling")
      setPull(resist(Math.max(distance, 0)))
    }

    const onTouchStart = (event: TouchEvent) => {
      const touch = event.touches[0]
      touchStart =
        touch &&
        current === "idle" &&
        root.scrollTop <= 0 &&
        event.touches.length === 1
          ? touch.clientY
          : null
    }

    const onTouchMove = (event: TouchEvent) => {
      const touch = event.touches[0]
      if (touchStart === null || !touch) return
      const distance = touch.clientY - touchStart
      if (distance <= 0 && current !== "pulling") {
        touchStart = null
        return
      }
      if (event.cancelable) event.preventDefault()
      pullTo(distance)
    }

    const onTouchEnd = () => {
      if (touchStart === null) return
      touchStart = null
      if (current === "pulling") release()
    }

    const onWheel = (event: WheelEvent) => {
      if (event.ctrlKey || (current !== "idle" && current !== "pulling")) return
      const delta = wheelDelta(event)
      const now = performance.now()
      if (now - lastWheel > WHEEL_GAP)
        wheelAtTop = root.scrollTop <= 0 && delta < 0
      lastWheel = now
      if (!wheelAtTop || root.scrollTop > 0) return
      if (delta > 0 && current !== "pulling") return
      event.preventDefault()
      wheelPull = Math.max(0, wheelPull - delta)
      pullTo(wheelPull)
      window.clearTimeout(wheelTimer)
      wheelTimer = window.setTimeout(() => {
        wheelPull = 0
        release()
      }, WHEEL_IDLE)
    }

    root.addEventListener("touchstart", onTouchStart, { passive: true })
    root.addEventListener("touchmove", onTouchMove, { passive: false })
    root.addEventListener("touchend", onTouchEnd)
    root.addEventListener("touchcancel", onTouchEnd)
    root.addEventListener("wheel", onWheel, { passive: false })
    return () => {
      disposed = true
      window.clearTimeout(wheelTimer)
      window.clearTimeout(settleTimer)
      root.removeEventListener("touchstart", onTouchStart)
      root.removeEventListener("touchmove", onTouchMove)
      root.removeEventListener("touchend", onTouchEnd)
      root.removeEventListener("touchcancel", onTouchEnd)
      root.removeEventListener("wheel", onWheel)
    }
  }, [])

  const spinning = phase === "refreshing" || phase === "settling"

  return (
    <div
      ref={(node) => {
        rootRef.current = node
        if (typeof ref === "function") return ref(node)
        if (ref) ref.current = node
      }}
      data-slot="pull-to-refresh"
      data-phase={phase}
      aria-busy={phase === "refreshing"}
      className={cn(
        "group/pull-to-refresh relative overflow-y-auto overscroll-y-contain [--pull-progress:0] [--pull:0px]",
        className
      )}
      {...props}
    >
      <div
        data-slot="pull-to-refresh-indicator"
        className="pointer-events-none absolute inset-x-0 top-0 flex h-(--pull) items-center justify-center overflow-hidden text-muted-foreground transition-[height] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[phase=pulling]/pull-to-refresh:transition-none motion-reduce:transition-none"
      >
        {spinning ? (
          <Spinner
            aria-label="Refreshing"
            className="size-6 transition-[scale,opacity] duration-300 group-data-[phase=settling]/pull-to-refresh:scale-50 group-data-[phase=settling]/pull-to-refresh:opacity-0"
          />
        ) : (
          <svg
            aria-hidden
            viewBox="0 0 24 24"
            fill="currentColor"
            className="size-6 shrink-0"
          >
            {spokes.map(({ angle, index }) => (
              <rect
                key={angle}
                x="10.44"
                y="0.5"
                width="3.12"
                height="7.2"
                rx="1.56"
                transform={`rotate(${angle} 12 12)`}
                style={{
                  opacity: `clamp(0, calc(var(--pull-progress) * ${SPOKES} - ${index}), 1)`,
                }}
              />
            ))}
          </svg>
        )}
      </div>
      <div
        data-slot="pull-to-refresh-content"
        className="translate-y-(--pull) transition-transform duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[phase=pulling]/pull-to-refresh:transition-none motion-reduce:transition-none"
      >
        {children}
      </div>
    </div>
  )
}

export { PullToRefresh }
```

4. Update the import paths to match your project.

## Usage

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

```tsx
<InboxExample />
```

- Give `PullToRefresh` a bounded height so its content scrolls inside it.
- `onRefresh` can return a promise; the container stays in the refreshing state until it settles, whether it resolves or rejects.
- Pulls only start at the top of the scroll position and are ignored while a refresh is in progress.

## Examples

### Empty list

An empty inbox refreshes from a trackpad pull and fills in with the first message.

**Example**

```tsx
import * as React from "react"
import { PullToRefresh } from "@/components/interactions/pull-to-refresh"

const senders: [from: string, subject: string][] = [
  ["Acme Store", "Your order has shipped"],
  ["Jordan Lee", "Tomorrow's meeting"],
  ["Cloud Drive", "Your storage is almost full"],
  ["Taylor Kim", "Weekend plans"],
  ["Design Team", "New mockups are ready"],
  ["Sam Rivera", "Lunch on Thursday?"],
  ["Bank Alerts", "Your statement is available"],
  ["Riley Park", "Photos from the trip"],
]

const incoming: [from: string, subject: string][] = [
  ["Morgan Diaz", "Quick question about the brief"],
  ["Travel Desk", "Your flight is confirmed"],
  ["Alex Chen", "Re: Project timeline"],
  ["News Digest", "Today's top stories"],
]

type Message = { id: number; from: string; subject: string; fresh: boolean }

function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function InboxExample({ empty = false }: { empty?: boolean }) {
  const [messages, setMessages] = React.useState<Message[]>(() =>
    empty
      ? []
      : senders.map(([from, subject], index) => ({
          id: index,
          from,
          subject,
          fresh: false,
        }))
  )
  const [log, setLog] = React.useState("Pull down from the top to refresh.")
  const count = React.useRef(0)

  const refresh = async () => {
    setLog("Refreshing…")
    await wait(1200)
    const next = incoming[count.current % incoming.length]
    if (!next) return
    const [from, subject] = next
    count.current += 1
    setMessages((current) => [
      { id: 100 + count.current, from, subject, fresh: true },
      ...current.map((message) => ({ ...message, fresh: false })),
    ])
    setLog(`New message from ${from}`)
  }

  return (
    <div className="mx-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-3">
      <PullToRefresh
        onRefresh={refresh}
        className="h-[min(36rem,calc(100dvh-7rem))] rounded-2xl border"
      >
        <h2 className="px-4 pt-4 pb-2 text-3xl font-bold tracking-tight">
          Inbox
        </h2>
        {messages.length === 0 ? (
          <p className="px-4 py-16 text-center text-sm text-muted-foreground">
            No messages yet.
          </p>
        ) : (
          <ul>
            {messages.map((message) => (
              <li
                key={message.id}
                data-fresh={message.fresh ? "" : undefined}
                className="ms-4 border-border not-first:border-t starting:opacity-0 data-fresh:transition-opacity data-fresh:duration-500"
              >
                <button
                  type="button"
                  onClick={() => setLog(`Opened ${message.subject}`)}
                  className="flex w-full flex-col py-3 pe-4 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
                >
                  <span className="flex items-center gap-2 font-semibold">
                    {message.fresh && (
                      <span className="size-2 rounded-full bg-primary" />
                    )}
                    {message.from}
                  </span>
                  <span className="truncate text-sm text-muted-foreground">
                    {message.subject}
                  </span>
                </button>
              </li>
            ))}
          </ul>
        )}
      </PullToRefresh>
      <p role="status" className="text-sm text-muted-foreground">
        {log}
      </p>
    </div>
  )
}

export default function PullToRefreshEmptyList() {
  return <InboxExample empty />
}
```

## API Reference

### PullToRefresh

The scroll container that tracks the pull and shows the refresh indicator.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onRefresh` | `() => Promise<unknown> \| unknown` | Required | Called when the user releases past the threshold; may return a promise. |

Also accepts the props of `React.ComponentProps<"div">`.

## Accessibility

- The container sets `aria-busy` while refreshing.
- The spinner is labelled `Refreshing`; the pull indicator is `aria-hidden`.
- Pulling requires touch or a scroll gesture, so offer another way to refresh where it matters, such as a button.

## Notes

- The root exposes `data-slot="pull-to-refresh"` and `data-phase` (`idle`, `pulling`, `refreshing`, `settling`). The indicator and content use `pull-to-refresh-indicator` and `pull-to-refresh-content`.
- The `--pull` and `--pull-progress` custom properties track the pull distance and progress toward the threshold.
- Transitions are disabled under `prefers-reduced-motion`.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/interactions-pull-to-refresh--default.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { PullToRefresh } from "@/components/interactions/pull-to-refresh"
import { Item, ItemContent, ItemDescription, ItemGroup, ItemSeparator, ItemTitle } from "@/components/ui/item"
import { Text } from "@/components/ui/text"

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

function Feed() {
  const [count, setCount] = React.useState(3)
  return (
    <PullToRefresh
      onRefresh={async () => {
        await wait(1200)
        setCount((current) => current + 1)
      }}
      contentContainerClassName="gap-3"
    >
      <Text className="text-sm text-muted-foreground">
        Pull down to refresh
      </Text>
      <ItemGroup variant="inset">
        {Array.from({ length: count }, (_, index) => count - index).map(
          (update, index) => (
            <React.Fragment key={update}>
              {index > 0 ? <ItemSeparator /> : null}
              <Item>
                <ItemContent>
                  <ItemTitle>Update {update}</ItemTitle>
                  <ItemDescription>Fetched from the server</ItemDescription>
                </ItemContent>
              </Item>
            </React.Fragment>
          )
        )}
      </ItemGroup>
    </PullToRefresh>
  )
}

export function PullToRefreshExample() {
  return (
    <View className="flex-1">
      <Feed />
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PullToRefreshFeed: View {
  @State private var count = 3

  var body: some View {
    PullToRefresh {
      try? await Task.sleep(for: .milliseconds(1200))
      count += 1
    } content: {
      VStack(alignment: .leading, spacing: 12) {
        Text("Pull down to refresh")
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
        ItemGroup(.inset) {
          ForEach(Array((1...count).reversed()), id: \.self) { update in
            if update != count {
              ItemSeparator()
            }
            Item {
              ItemContent {
                ItemTitle(verbatim: "Update \(update)")
                ItemDescription("Fetched from the server")
              }
            }
          }
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemDescription
import com.esuiss.ui.components.ItemGroup
import com.esuiss.ui.components.ItemGroupVariant
import com.esuiss.ui.components.ItemSeparator
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.PullToRefresh
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay

val pullToRefreshViewport = Modifier.fillMaxWidth().height(600.dp)

@Composable
fun PullToRefreshFeed() {
    var count by rememberSaveable { mutableIntStateOf(3) }
    PullToRefresh(
        onRefresh = {
            delay(1200)
            count += 1
        },
        modifier = pullToRefreshViewport,
    ) {
        Column(
            modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Text("Pull down to refresh", style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground))
            ItemGroup(variant = ItemGroupVariant.Inset) {
                (count downTo 1).forEachIndexed { index, update ->
                    key(update) {
                        if (index > 0) ItemSeparator()
                        Item {
                            ItemContent {
                                ItemTitle("Update $update")
                                ItemDescription("Fetched from the server")
                            }
                        }
                    }
                }
            }
        }
    }
}
```

`PullToRefresh` is a `ScrollView` with a native `RefreshControl`. Pulling down past the top starts a refresh: a light impact haptic plays, the spinner shows, and `onRefresh` runs.

The spinner stays for at least 600 ms, even when `onRefresh` resolves sooner, so it does not flicker. It also hides if `onRefresh` throws or rejects. Use `PullToRefreshControl` to add the same behavior to a `FlatList` or `SectionList`.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/pull-to-refresh
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add pull-to-refresh
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add pull-to-refresh --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add pull-to-refresh --platform kotlin
```

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn uniwind
```

2. Add the pieces it builds on: the primitives `components/primitives/haptics.tsx`.

3. Copy the source into `components/interactions/pull-to-refresh.tsx`:

```tsx title="uniwind/components/interactions/pull-to-refresh.tsx"
import { cn } from "cn"
import * as React from "react"
import {
  type ColorValue,
  RefreshControl,
  type RefreshControlProps,
  ScrollView,
} from "react-native"
import { withUniwind } from "uniwind"
import { useHaptics } from "@/components/primitives/haptics"

const MIN_REFRESH = 600

type RefreshHandler = () => Promise<unknown> | unknown

function useRefresh(onRefresh: RefreshHandler) {
  const [refreshing, setRefreshing] = React.useState(false)
  const onRefreshRef = React.useRef(onRefresh)
  const mountedRef = React.useRef(true)
  const haptics = useHaptics()

  React.useEffect(() => {
    onRefreshRef.current = onRefresh
  }, [onRefresh])

  React.useEffect(() => {
    mountedRef.current = true
    return () => {
      mountedRef.current = false
    }
  }, [])

  const refresh = React.useCallback(async () => {
    haptics.impact("light")
    setRefreshing(true)
    const started = Date.now()
    await Promise.allSettled([
      new Promise((resolve) => resolve(onRefreshRef.current())),
    ])
    const elapsed = Date.now() - started
    await new Promise((resolve) =>
      setTimeout(resolve, Math.max(0, MIN_REFRESH - elapsed))
    )
    if (mountedRef.current) setRefreshing(false)
  }, [haptics])

  return { refreshing, refresh }
}

function RefreshIndicator({
  color,
  ...props
}: RefreshControlProps & { color?: ColorValue }) {
  return (
    <RefreshControl
      tintColor={color}
      colors={color ? [color] : undefined}
      {...props}
    />
  )
}

const StyledRefreshIndicator = withUniwind(RefreshIndicator)

function PullToRefreshControl({
  onRefresh,
  refreshing: refreshingProp,
  ...props
}: Omit<RefreshControlProps, "onRefresh" | "refreshing"> & {
  onRefresh: RefreshHandler
  refreshing?: boolean
}) {
  const { refreshing, refresh } = useRefresh(onRefresh)
  return (
    <StyledRefreshIndicator
      colorClassName="accent-muted-foreground"
      progressBackgroundColorClassName="accent-card"
      accessibilityLabel={refreshing ? "Refreshing" : undefined}
      refreshing={refreshingProp ?? refreshing}
      onRefresh={refresh}
      {...props}
    />
  )
}

function PullToRefresh({
  onRefresh,
  className,
  children,
  ...props
}: React.ComponentProps<typeof ScrollView> & {
  onRefresh: RefreshHandler
}) {
  const { refreshing, refresh } = useRefresh(onRefresh)
  return (
    <ScrollView
      accessibilityState={{ busy: refreshing }}
      className={cn("flex-1", className)}
      refreshControl={
        <StyledRefreshIndicator
          colorClassName="accent-muted-foreground"
          progressBackgroundColorClassName="accent-card"
          refreshing={refreshing}
          onRefresh={refresh}
        />
      }
      {...props}
    >
      {children}
    </ScrollView>
  )
}

export { PullToRefresh, PullToRefreshControl }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn nativewind
```

2. Add the pieces it builds on: the primitives `components/primitives/haptics.tsx`.

3. Copy the source into `components/interactions/pull-to-refresh.tsx`:

```tsx title="nativewind/components/interactions/pull-to-refresh.tsx"
import { cn } from "cn"
import { cssInterop } from "nativewind"
import * as React from "react"
import {
  type ColorValue,
  RefreshControl,
  type RefreshControlProps,
  ScrollView,
} from "react-native"
import { useHaptics } from "@/components/primitives/haptics"

const MIN_REFRESH = 600

type RefreshHandler = () => Promise<unknown> | unknown

function useRefresh(onRefresh: RefreshHandler) {
  const [refreshing, setRefreshing] = React.useState(false)
  const onRefreshRef = React.useRef(onRefresh)
  const mountedRef = React.useRef(true)
  const haptics = useHaptics()

  React.useEffect(() => {
    onRefreshRef.current = onRefresh
  }, [onRefresh])

  React.useEffect(() => {
    mountedRef.current = true
    return () => {
      mountedRef.current = false
    }
  }, [])

  const refresh = React.useCallback(async () => {
    haptics.impact("light")
    setRefreshing(true)
    const started = Date.now()
    await Promise.allSettled([
      new Promise((resolve) => resolve(onRefreshRef.current())),
    ])
    const elapsed = Date.now() - started
    await new Promise((resolve) =>
      setTimeout(resolve, Math.max(0, MIN_REFRESH - elapsed))
    )
    if (mountedRef.current) setRefreshing(false)
  }, [haptics])

  return { refreshing, refresh }
}

function RefreshIndicator({
  color,
  ...props
}: RefreshControlProps & { color?: ColorValue }) {
  return (
    <RefreshControl
      tintColor={color}
      colors={color ? [color] : undefined}
      {...props}
    />
  )
}

const StyledRefreshIndicator = cssInterop(RefreshIndicator, {
  colorClassName: { target: false, nativeStyleToProp: { color: true } },
  progressBackgroundColorClassName: {
    target: false,
    nativeStyleToProp: { color: "progressBackgroundColor" },
  },
})

function PullToRefreshControl({
  onRefresh,
  refreshing: refreshingProp,
  ...props
}: Omit<RefreshControlProps, "onRefresh" | "refreshing"> & {
  onRefresh: RefreshHandler
  refreshing?: boolean
}) {
  const { refreshing, refresh } = useRefresh(onRefresh)
  return (
    <StyledRefreshIndicator
      colorClassName="text-muted-foreground"
      progressBackgroundColorClassName="text-card"
      accessibilityLabel={refreshing ? "Refreshing" : undefined}
      refreshing={refreshingProp ?? refreshing}
      onRefresh={refresh}
      {...props}
    />
  )
}

function PullToRefresh({
  onRefresh,
  className,
  children,
  ...props
}: React.ComponentProps<typeof ScrollView> & {
  onRefresh: RefreshHandler
}) {
  const { refreshing, refresh } = useRefresh(onRefresh)
  return (
    <ScrollView
      accessibilityState={{ busy: refreshing }}
      className={cn("flex-1", className)}
      refreshControl={
        <StyledRefreshIndicator
          colorClassName="text-muted-foreground"
          progressBackgroundColorClassName="text-card"
          refreshing={refreshing}
          onRefresh={refresh}
        />
      }
      {...props}
    >
      {children}
    </ScrollView>
  )
}

export { PullToRefresh, PullToRefreshControl }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Add the pieces it builds on: the primitives `components/primitives/haptics.tsx`.

2. Copy the source into `components/interactions/pull-to-refresh.tsx`:

```tsx title="stylesheet/components/interactions/pull-to-refresh.tsx"
import * as React from "react"
import {
  RefreshControl,
  type RefreshControlProps,
  ScrollView,
} from "react-native"
import { useHaptics } from "@/components/primitives/haptics"
import { useTheme } from "@/components/ui/theme"

const MIN_REFRESH = 600

type RefreshHandler = () => Promise<unknown> | unknown

function useRefresh(onRefresh: RefreshHandler) {
  const [refreshing, setRefreshing] = React.useState(false)
  const onRefreshRef = React.useRef(onRefresh)
  const mountedRef = React.useRef(true)
  const haptics = useHaptics()

  React.useEffect(() => {
    onRefreshRef.current = onRefresh
  }, [onRefresh])

  React.useEffect(() => {
    mountedRef.current = true
    return () => {
      mountedRef.current = false
    }
  }, [])

  const refresh = React.useCallback(async () => {
    haptics.impact("light")
    setRefreshing(true)
    const started = Date.now()
    await Promise.allSettled([
      new Promise((resolve) => resolve(onRefreshRef.current())),
    ])
    const elapsed = Date.now() - started
    await new Promise((resolve) =>
      setTimeout(resolve, Math.max(0, MIN_REFRESH - elapsed))
    )
    if (mountedRef.current) setRefreshing(false)
  }, [haptics])

  return { refreshing, refresh }
}

function RefreshIndicator(props: RefreshControlProps) {
  const { colors } = useTheme()
  return (
    <RefreshControl
      tintColor={colors.mutedForeground}
      colors={[colors.mutedForeground]}
      progressBackgroundColor={colors.card}
      {...props}
    />
  )
}

function PullToRefreshControl({
  onRefresh,
  refreshing: refreshingProp,
  ...props
}: Omit<RefreshControlProps, "onRefresh" | "refreshing"> & {
  onRefresh: RefreshHandler
  refreshing?: boolean
}) {
  const { refreshing, refresh } = useRefresh(onRefresh)
  return (
    <RefreshIndicator
      accessibilityLabel={refreshing ? "Refreshing" : undefined}
      refreshing={refreshingProp ?? refreshing}
      onRefresh={refresh}
      {...props}
    />
  )
}

function PullToRefresh({
  onRefresh,
  style,
  children,
  ...props
}: React.ComponentProps<typeof ScrollView> & {
  onRefresh: RefreshHandler
}) {
  const { refreshing, refresh } = useRefresh(onRefresh)
  return (
    <ScrollView
      accessibilityState={{ busy: refreshing }}
      style={[{ flex: 1 }, style]}
      refreshControl={
        <RefreshIndicator refreshing={refreshing} onRefresh={refresh} />
      }
      {...props}
    >
      {children}
    </ScrollView>
  )
}

export { PullToRefresh, PullToRefreshControl }
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

3. Copy the source and every file it uses:

```swift title="swift/Interactions/PullToRefresh.swift"
import SwiftUI

public struct PullToRefresh<Content: View>: View {
  let onRefresh: @MainActor @Sendable () async -> Void
  let content: Content

  @State private var refreshing = false

  public init(
    onRefresh: @escaping @MainActor @Sendable () async -> Void,
    @ViewBuilder content: () -> Content
  ) {
    self.onRefresh = onRefresh
    self.content = content()
  }

  public var body: some View {
    let onRefresh = onRefresh
    ScrollView {
      content
        .frame(maxWidth: .infinity, alignment: .top)
        .tint(EsuissColor.primary)
    }
    .pullToRefresh(onRefresh)
    .tint(EsuissColor.mutedForeground)
    .accessibilityAction(named: Text("Refresh")) {
      guard !refreshing else { return }
      refreshing = true
      Task { @MainActor in
        await PullToRefreshTiming.run(onRefresh)
        refreshing = false
      }
    }
  }
}

enum PullToRefreshTiming {
  static let minimum: Duration = .milliseconds(600)

  @MainActor
  static func run(_ action: @MainActor @Sendable () async -> Void) async {
    let clock = ContinuousClock()
    let started = clock.now
    await action()
    let elapsed = clock.now - started
    if elapsed < minimum {
      try? await Task.sleep(for: minimum - elapsed)
    }
  }
}

private struct PullToRefreshModifier: ViewModifier {
  let action: @MainActor @Sendable () async -> Void

  @State private var triggers = 0

  func body(content: Content) -> some View {
    let action = action
    let triggers = $triggers
    content
      .refreshable { @MainActor in
        triggers.wrappedValue += 1
        await PullToRefreshTiming.run(action)
      }
      .hapticFeedback(.impact(weight: .light), trigger: triggers.wrappedValue)
  }
}

extension View {
  public func pullToRefresh(_ action: @escaping @MainActor @Sendable () async -> Void) -> some View {
    modifier(PullToRefreshModifier(action: action))
  }
}
```

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

4. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/interactions/PullToRefresh.kt"
package com.esuiss.ui.interactions

import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.FormEasing
import com.esuiss.ui.components.Spinner
import com.esuiss.ui.components.SpinnerSpokes
import com.esuiss.ui.components.drawSpinnerSpokes
import com.esuiss.ui.components.formReducedMotion
import com.esuiss.ui.components.rememberHaptics
import com.esuiss.ui.theme.EsuissTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.exp
import kotlin.math.roundToInt

enum class PullToRefreshPhase { Idle, Pulling, Refreshing, Settling }

private val PullThreshold = 64.dp
private val PullHold = 52.dp
private val PullMax = 128.dp
private const val MinRefreshMillis = 600L
private const val SettleMillis = 400

@Composable
fun PullToRefresh(
    onRefresh: suspend () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    refreshLabel: String = "Refresh",
    content: @Composable () -> Unit,
) {
    val scope = rememberCoroutineScope()
    val refresh by rememberUpdatedState(onRefresh)
    var refreshing by remember { mutableStateOf(false) }
    PullToRefresh(
        refreshing = refreshing,
        onRefresh = {
            if (!refreshing) {
                refreshing = true
                scope.launch {
                    try {
                        refresh()
                    } finally {
                        refreshing = false
                    }
                }
            }
        },
        modifier = modifier,
        enabled = enabled,
        refreshLabel = refreshLabel,
        content = content,
    )
}

@Composable
fun PullToRefresh(
    refreshing: Boolean,
    onRefresh: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    refreshLabel: String = "Refresh",
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    val density = LocalDensity.current
    val haptics by rememberUpdatedState(rememberHaptics())
    val reduced = formReducedMotion()
    val scope = rememberCoroutineScope()
    val thresholdPx = with(density) { PullThreshold.toPx() }
    val holdPx = with(density) { PullHold.toPx() }
    val maxPx = with(density) { PullMax.toPx() }
    val trigger by rememberUpdatedState(onRefresh)
    var pull by remember { mutableFloatStateOf(0f) }
    var raw by remember { mutableFloatStateOf(0f) }
    var phase by remember { mutableStateOf(PullToRefreshPhase.Idle) }
    var requested by remember { mutableStateOf(false) }
    val active = refreshing || requested

    suspend fun animatePull(target: Float, millis: Int) {
        if (reduced) {
            pull = target
        } else {
            animate(pull, target, animationSpec = tween(millis, easing = FormEasing)) { value, _ -> pull = value }
        }
    }

    LaunchedEffect(active) {
        if (active) {
            phase = PullToRefreshPhase.Refreshing
            animatePull(holdPx, SettleMillis)
            if (requested) {
                delay(MinRefreshMillis)
                requested = false
            }
        } else if (phase == PullToRefreshPhase.Refreshing) {
            phase = PullToRefreshPhase.Settling
            animatePull(0f, SettleMillis)
            phase = PullToRefreshPhase.Idle
        }
    }

    fun resist(distance: Float): Float = maxPx * (1f - exp(-distance / (maxPx * 1.6f)))

    fun release() {
        raw = 0f
        if (pull >= thresholdPx) {
            haptics.perform(HapticFeedbackType.Confirm)
            requested = true
            trigger()
        } else {
            scope.launch {
                animatePull(0f, SettleMillis)
                if (phase == PullToRefreshPhase.Pulling) phase = PullToRefreshPhase.Idle
            }
        }
    }

    fun pullTo(distance: Float) {
        raw = distance.coerceAtLeast(0f)
        pull = resist(raw)
    }

    val connection = remember(enabled) {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                if (source != NestedScrollSource.UserInput || phase != PullToRefreshPhase.Pulling || available.y >= 0f) return Offset.Zero
                val before = raw
                pullTo(raw + available.y)
                return Offset(0f, raw - before)
            }

            override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
                if (!enabled || source != NestedScrollSource.UserInput || available.y <= 0f) return Offset.Zero
                if (phase != PullToRefreshPhase.Idle && phase != PullToRefreshPhase.Pulling) return Offset.Zero
                phase = PullToRefreshPhase.Pulling
                pullTo(raw + available.y)
                return Offset(0f, available.y)
            }

            override suspend fun onPreFling(available: Velocity): Velocity {
                if (phase != PullToRefreshPhase.Pulling) return Velocity.Zero
                release()
                return available
            }
        }
    }

    val spinning = phase == PullToRefreshPhase.Refreshing || phase == PullToRefreshPhase.Settling
    val settleScale by animateFloatAsState(
        targetValue = if (phase == PullToRefreshPhase.Settling) 0f else 1f,
        animationSpec = tween(if (reduced) 0 else 300),
        label = "settle",
    )
    val refreshAction = remember(refreshLabel) {
        listOf(
            CustomAccessibilityAction(refreshLabel) {
                if (!refreshing && !requested) {
                    requested = true
                    trigger()
                }
                true
            },
        )
    }
    Box(
        modifier = modifier
            .clipToBounds()
            .nestedScroll(connection)
            .semantics { if (enabled) customActions = refreshAction },
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .clipToBounds()
                .layout { measurable, constraints ->
                    val height = pull.roundToInt().coerceAtLeast(0)
                    val placeable = measurable.measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
                    layout(placeable.width, height) {
                        placeable.place(0, (height - placeable.height) / 2)
                    }
                },
            contentAlignment = Alignment.Center,
        ) {
            if (spinning) {
                Spinner(
                    modifier = Modifier.graphicsLayer {
                        val scale = 0.5f + 0.5f * settleScale
                        scaleX = scale
                        scaleY = scale
                        alpha = settleScale
                    },
                    size = 24.dp,
                    color = colors.mutedForeground,
                    contentDescription = "Refreshing",
                )
            } else {
                val tint = colors.mutedForeground
                Canvas(modifier = Modifier.size(24.dp).clearAndSetSemantics {}) {
                    val progress = (pull / thresholdPx).coerceIn(0f, 1f)
                    drawSpinnerSpokes(tint) { index -> progress * SpinnerSpokes - index }
                }
            }
        }
        Box(modifier = Modifier.offset { IntOffset(0, pull.roundToInt()) }) {
            content()
        }
    }
}
```

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Spinner.kt`: see [Spinner](https://ui.suiss.app/docs/components/spinner.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { PullToRefreshControl, PullToRefresh } from "@/components/interactions/pull-to-refresh"
```

```tsx
<View className="flex-1">
  <Feed />
</View>
```

Pass an `onRefresh` that returns a promise, or nothing for synchronous work. All other `ScrollView` props pass through to `PullToRefresh`.

For lists, pass `<PullToRefreshControl onRefresh={...} />` as the list's `refreshControl`. It manages its own refreshing state; pass `refreshing` to override it. Other `RefreshControl` props pass through.

## Examples

### With flat list

Uses `PullToRefreshControl` as the `refreshControl` of a `FlatList`.

![Preview](https://ui.suiss.app/previews/interactions-pull-to-refresh--with-flat-list.light.webp)

```tsx
import { FlatList } from "react-native"
import { PullToRefreshControl } from "@/components/interactions/pull-to-refresh"
import { Text } from "@/components/ui/text"

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export function PullToRefreshWithFlatListExample() {
  return (
    <FlatList
      data={["Inbox", "Drafts", "Sent", "Archive"]}
      keyExtractor={(item) => item}
      refreshControl={<PullToRefreshControl onRefresh={() => wait(1000)} />}
      renderItem={({ item }) => <Text className="py-3 text-base">{item}</Text>}
    />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PullToRefreshList: View {
  var body: some View {
    ScrollView {
      LazyVStack(alignment: .leading, spacing: 0) {
        ForEach(["Inbox", "Drafts", "Sent", "Archive"], id: \.self) { item in
          Text(verbatim: item)
            .textStyle(.base)
            .foregroundStyle(EsuissColor.foreground)
            .padding(.vertical, 12)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
      }
    }
    .pullToRefresh {
      try? await Task.sleep(for: .milliseconds(1000))
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.PullToRefresh
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay

val pullToRefreshViewport = Modifier.fillMaxWidth().height(600.dp)

@Composable
fun PullToRefreshFlatList() {
    PullToRefresh(onRefresh = { delay(1000) }, modifier = pullToRefreshViewport) {
        LazyColumn(Modifier.fillMaxSize()) {
            items(listOf("Inbox", "Drafts", "Sent", "Archive"), key = { it }) { item ->
                Text(item, modifier = Modifier.padding(vertical = 12.dp), style = Typography.base)
            }
        }
    }
}
```

## API Reference

### PullToRefreshControl

A `RefreshControl` for any list or scroll view.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onRefresh` | `RefreshHandler` | Required | Called when the user pulls to refresh; the spinner waits for the returned promise. |
| `refreshing` | `boolean` | - | Overrides the internal refreshing state. |

Also accepts the props of `RefreshControlProps`.

### PullToRefresh

A `ScrollView` with built-in pull to refresh.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onRefresh` | `RefreshHandler` | Required | Called when the user pulls to refresh; the spinner waits for the returned promise. |

Also accepts the props of `React.ComponentProps<typeof ScrollView>`.

## Accessibility

- `PullToRefresh` exposes a `busy` state while refreshing.
- `PullToRefreshControl` is labelled `Refreshing` while active.

## Notes

- The haptic plays only if the app provides an adapter through `HapticsProvider`.
- The spinner uses the `muted-foreground` color, and on Android its background uses `card`, so both follow light and dark mode.

