Skip to content
omaris

System

Pull to Refresh

The gesture every phone app has: drag the list down to refetch, against a rubber band, with a morphing MD3 shape for an indicator.

import { PullToRefresh } from 'omaris'
Learn

Examples

Basic

Drag the list down and let go. Past the threshold the shape arms; releasing runs onrefresh and holds the indicator until the promise settles.

Zainab Hassan Out for delivery 2m
Omar Ali Out for delivery 9m
Dilan Rashid Out for delivery 14m
Sara Mahmoud Out for delivery 21m
Yusuf Karim Out for delivery 33m
Lana Abdullah Out for delivery 48m
<script lang="ts">	import { Badge, List, ListItem, PullToRefresh } from 'omaris';​	let rows = $state([		{ name: 'Zainab Hassan', ago: 2 },		{ name: 'Omar Ali', ago: 9 },		{ name: 'Dilan Rashid', ago: 14 },		{ name: 'Sara Mahmoud', ago: 21 },		{ name: 'Yusuf Karim', ago: 33 },		{ name: 'Lana Abdullah', ago: 48 }	]);​	async function reload() {		await new Promise((done) => setTimeout(done, 1200));		rows = [{ name: 'New order', ago: 0 }, ...rows].slice(0, 6);	}</script>​<div class="w-full max-w-sm overflow-hidden rounded-shape-lg border border-border">	<PullToRefresh onrefresh={reload} height="16rem">		<List variant="plain" dividers>			{#each rows as row, i (row.name + i)}				<ListItem headline={row.name} supportingText="Out for delivery">					{#snippet trailing()}<Badge>{row.ago}m</Badge>{/snippet}				</ListItem>			{/each}		</List>	</PullToRefresh></div>

Overlay

overlay leaves the content where it is and floats the indicator over it — the Android behaviour, and the right one when the first row is a header you would rather not push off the top.

Refreshed 0 times
Kitchen Nothing needs attention
Couriers Nothing needs attention
Payments Nothing needs attention
Stock Nothing needs attention
Reviews Nothing needs attention
<script lang="ts">	import { List, ListItem, PullToRefresh } from 'omaris';​	let count = $state(0);	const reload = () => new Promise((done) => setTimeout(() => (count++, done(null)), 900));</script>​<div class="w-full max-w-sm overflow-hidden rounded-shape-lg border border-border">	<PullToRefresh overlay onrefresh={reload} height="14rem">		<div class="bg-surface-container px-4 py-2 text-label-md">			Refreshed {count} time{count === 1 ? '' : 's'}		</div>		<List variant="plain" dividers>			{#each ['Kitchen', 'Couriers', 'Payments', 'Stock', 'Reviews'] as row (row)}				<ListItem headline={row} supportingText="Nothing needs attention" />			{/each}		</List>	</PullToRefresh></div>

Your own indicator

The indicator is a snippet, and it is handed the live gesture: distance, progress, whether a release would refresh, and whether one is running. Put anything in it — here a pill with a bar and a word, and no shapes at all.

Pull
Today 12 orders
Yesterday 12 orders
This week 12 orders
Last week 12 orders
Older 12 orders
<script lang="ts">	import { List, ListItem, PullToRefresh } from 'omaris';​	const reload = () => new Promise((done) => setTimeout(done, 1000));</script>​<div class="w-full max-w-sm overflow-hidden rounded-shape-lg border border-border">	<PullToRefresh onrefresh={reload} height="14rem" threshold={64}>		{#snippet indicator({ progress, armed, refreshing })}			<div				class="mt-2 flex items-center gap-2 rounded-full bg-inverse-surface px-3 py-1.5 text-label-sm text-inverse-surface-foreground shadow-3"				style="opacity: {Math.min(1, progress * 1.5)}"			>				<span					class="h-1 w-10 max-w-full overflow-hidden rounded-full bg-inverse-surface-foreground/25"				>					<span						class="block h-full rounded-full bg-inverse-surface-foreground"						style="width: {progress * 100}%"					></span>				</span>				{refreshing ? 'Refreshing…' : armed ? 'Release' : 'Pull'}			</div>		{/snippet}​		<List variant="plain" dividers>			{#each ['Today', 'Yesterday', 'This week', 'Last week', 'Older'] as row (row)}				<ListItem headline={row} supportingText="12 orders" />			{/each}		</List>	</PullToRefresh></div>

Controlled

With no onrefresh the component is controlled: the gesture opens refreshing and you close it. That is the shape to use when the refetch belongs to a store, a query client or a parent that already knows about it.

Inbox
Assigned
Escalated
Closed
<script lang="ts">	import { Button, List, ListItem, PullToRefresh, Switch } from 'omaris';​	let refreshing = $state(false);</script>​<div class="flex w-full max-w-sm flex-col gap-3">	<div class="flex items-center gap-3">		<Switch bind:checked={refreshing} label="Held open" />		<Button size="xs" variant="outlined" onclick={() => (refreshing = false)}>Done</Button>	</div>​	<div class="overflow-hidden rounded-shape-lg border border-border">		<PullToRefresh bind:refreshing height="12rem">			<List variant="plain" dividers>				{#each ['Inbox', 'Assigned', 'Escalated', 'Closed'] as row (row)}					<ListItem headline={row} />				{/each}			</List>		</PullToRefresh>	</div></div>

Overridden

The overridden case: a different set of outlines, a disc restyled through classes, a longer pull, and scroller={false} so the page around it does the scrolling instead of a viewport of its own.

Erbil 3 couriers on shift
Baghdad 3 couriers on shift
Basra 3 couriers on shift
Duhok 3 couriers on shift
<script lang="ts">	import { List, ListItem, PullToRefresh } from 'omaris';​	const reload = () => new Promise((done) => setTimeout(done, 900));</script>​<div class="w-full max-w-sm">	<PullToRefresh		scroller={false}		onrefresh={reload}		threshold={96}		max={190}		shapes={['pill', 'gem', 'flower', 'sunny', 'boom']}		classes={{			disc: 'size-12 bg-primary-container shadow-4 ring-0',			glyph: 'bg-primary-container-foreground'		}}	>		<List variant="outlined" dividers>			{#each ['Erbil', 'Baghdad', 'Basra', 'Duhok'] as city (city)}				<ListItem headline={city} supportingText="3 couriers on shift" />			{/each}		</List>	</PullToRefresh></div>

When to use it

Use it for

  • A list on a phone that people expect to refetch by pulling down. With it, the list needs no refresh button.
  • A feed whose freshness matters: orders, messages, a courier's jobs, a dashboard someone is watching.
  • A list inside its own scroller, like a sheet or a panel. height sets the viewport; scroller={false} hands scrolling back to the page.
  • A refetch that already lives elsewhere. With no onrefresh the component is controlled, so bind:refreshing wires it to a store or query client.
  • A branded indicator. The indicator is a snippet and gets the live gesture, so a logo or a line of text can move with the finger.

Not for

  • A desktop screen → a refresh Button or Icon Button.
  • Showing a fetch the person did not ask for → Progress, or Skeleton for a first load.
  • Loading the next page → infinite scroll or a "Load more" Button. This gesture means "start again".
  • A horizontal strip → Carousel. The two gestures fight.
  • A page that is one form or a map. Pulling a form down is how typed input gets lost.

Do

  • Return a promise from onrefresh. It tells the indicator when to stop. minimum keeps a fast refetch from flashing.
  • Keep threshold near the default. Too short refreshes by accident; too long feels broken.
  • Wrap the scrolling element, not the page frame.
  • Use overlay when the first row is a header or search field you do not want pushed down.
  • Disable it while a dialog or sheet is open over the list, and while the list is empty.

Don't

  • Refetch without changing anything visible. Show a timestamp, a count or a new row.
  • Leave refreshing true with no onrefresh. Controlled means nothing else will close it.
  • Nest one inside another, or inside a Sheet that drags on the same axis. The inner one takes the gesture.
  • Make the indicator taller than max.
  • Make it the only way to refresh. Keep a menu item or keyboard path too.

API

PullToRefresh

Pull down to refetch — the gesture every phone app has, on the web.

Wrap the thing that scrolls. A finger that starts at the top and travels down drags the content with it against a rubber band; past threshold the indicator arms, and letting go runs onrefresh and holds the indicator until the promise settles.

The indicator is a ring around a morphing MD3 shape. The ring is the read-out — it closes as the pull reaches the threshold, then becomes a turning segment while the refetch runs, so one element says both "this far to go" and "something is happening". The outline inside travels through shapes as you pull and cycles through them while it refreshes, which is what makes the pull itself the animation rather than a bar that fills. shapes picks a different set, and the indicator snippet replaces the whole thing; it is handed the live distance, progress and state, so a custom one can be just as physical.

The gesture is only taken from a scroll origin and only when it is clearly downward, so a horizontal swipe, a carousel or a list already scrolled down keeps it. Travel is damped asymptotically toward max, and a flick past half the threshold refreshes without reaching it.

With onrefresh, refreshing is managed for you and held for at least minimum ms so a fast refetch doesn't flash. Without it the component is controlled: bind:refreshing and clear it yourself.

Reduced motion keeps the gesture and drops the flourish — nothing spins, and the settle is a cut rather than a spring.

import { PullToRefresh } from 'omaris'
<PullToRefresh onrefresh={() => orders.reload()} height="26rem">  <List>…</List></PullToRefresh>

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
The frame. Not the scroller — it clips the travel while a pull is on, and stops clipping the moment it is over, so a sticky header inside is only ever clipped mid-gesture.
indicator
The indicator's rail: centred, above the content, never hit-tested.
disc
The default indicator's disc — the thing the shape sits in.
arc
The arc around the disc, tracing how much of the pull is done.
glyph
The shape inside the disc.
viewport
What actually scrolls, and what the pull moves.

Props

onrefresh
() => unknown

Run the refetch. Return a promise and the indicator is held until it settles; return nothing and it is held for minimum. Leave it out to drive the component yourself with bind:refreshing.

refreshing bindable

Defaults to false

boolean

Whether the refresh is running. Bindable, and the whole API without onrefresh.

disabled

Defaults to false

boolean

Turn the gesture off — while a dialog is up, or the list is empty.

threshold

Defaults to 72

number

Travel at which a release refreshes, in px.

max

Defaults to 140

number

Furthest the content can be pulled, in px. The rubber band's asymptote.

minimum

Defaults to 450

number

Shortest time the indicator stays up, in ms, so a fast refetch doesn't flash.

overlay

Defaults to false

boolean

Let the indicator float over the content rather than pushing it down.

scroller

Defaults to true

boolean

Whether this component owns the scrolling. The default gives the viewport its own overflow-y-auto and height; false leaves the scrolling to the page (or to an ancestor) and only reads the gesture.

height

Defaults to '24rem'

number | string

How tall the scrolling viewport is — a number is px, a string is any CSS length. Ignored when scroller is false.

It lives in a custom property rather than a utility class so a height in classes.viewport genuinely replaces it.

shapes

Defaults to ['circle', 'cookie-4', 'cookie-9', 'clover-4', 'burst']

ShapeName[]

The outlines the indicator travels through as you pull, and spins through.

label

Defaults to 'Refreshing'

string

What a screen reader is told while the refresh runs.

armedLabel

Defaults to 'Release to refresh'

string

What it is told once the pull would refresh on release.

indicator
Snippet<[PullState]>

Replace the indicator entirely. It is handed the live state.

class
string
classes
PullToRefreshClasses

Per-part Tailwind overrides. class still covers the root.

children required
Snippet