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.
Walkthrough
1. 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> 2. 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> 3. 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> 4. 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> 5. 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>