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' 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.
<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.
<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.
<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.
<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.
<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.
heightsets the viewport;scroller={false}hands scrolling back to the page. - A refetch that already lives elsewhere. With no
onrefreshthe component is controlled, sobind:refreshingwires 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.minimumkeeps a fast refetch from flashing. - Keep
thresholdnear the default. Too short refreshes by accident; too long feels broken. - Wrap the scrolling element, not the page frame.
- Use
overlaywhen 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
refreshingtrue with noonrefresh. 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 bindableDefaults 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