Skip to content

Layout

Pull to refresh

Pull down at the top of a list to refresh it, with the expressive loading indicator.

Examples

Pull to refresh

Pull the content down with touch or a pen and release past the threshold. The Refresh button is the keyboard and mouse path that COMPONENT_SPEC asks the app for, as in every Compose sample: it calls the same refresh. The indicator spins while refreshing is set.

The live demo runs with JavaScript. Its code is below.

React HTML
import { useState } from 'react';
import { Icon, IconButton, PullToRefresh } from '@kyralabs/m3exp-web/react';

const ROWS = Array.from({ length: 20 }, (_, i) => `Item ${String(i + 1)}`);

/** Pull down with touch or a pen; the Refresh button is the keyboard and mouse path. */
export default function Pull() {
  const [refreshing, setRefreshing] = useState(false);
  const [count, setCount] = useState(0);
  const refresh = () => {
    setRefreshing(true);
    setTimeout(() => {
      setCount((c) => c + 1);
      setRefreshing(false);
    }, 1500);
  };
  return (
    <div
      style={{
        display: 'grid',
        gridTemplateRows: 'auto minmax(0, 1fr)',
        inlineSize: '100%',
        maxInlineSize: 'var(--m3e-sys-window-width-medium)',
        blockSize: 'var(--m3e-sys-window-height-medium)',
      }}
    >
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
        <p style={{ paddingInline: 'var(--m3e-sys-space-200)' }}>Refreshed {count} times.</p>
        <IconButton aria-label="Refresh" isDisabled={refreshing} onPress={refresh}>
          <Icon name="refresh" />
        </IconButton>
      </div>
      <PullToRefresh isRefreshing={refreshing} onRefresh={refresh}>
        <ul style={{ margin: 0, padding: 'var(--m3e-sys-space-200)' }}>
          {ROWS.map((row) => (
            <li key={row} style={{ paddingBlock: 'var(--m3e-sys-space-150)' }}>
              {row}
            </li>
          ))}
        </ul>
      </PullToRefresh>
    </div>
  );
}
<div
  style="
    display: grid;
    grid-template-rows: auto minmax(0, 1fr);
    inline-size: 100%;
    max-inline-size: var(--m3e-sys-window-width-medium);
    block-size: var(--m3e-sys-window-height-medium);
  "
>
  <div style="display: flex; align-items: center; justify-content: space-between">
    <p style="padding-inline: var(--m3e-sys-space-200)">
      Refreshed <output id="refresh-count">0</output> times.
    </p>
    <button
      type="button"
      id="refresh-button"
      class="m3e-icon-button m3e-icon-button--standard"
      aria-label="Refresh"
    >
      <span class="m3e-icon" aria-hidden="true">refresh</span>
    </button>
  </div>
  <m3e-pull-to-refresh id="feed">
    <ul style="margin: 0; padding: var(--m3e-sys-space-200)">
      <li style="padding-block: var(--m3e-sys-space-150)">Item 1</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 2</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 3</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 4</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 5</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 6</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 7</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 8</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 9</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 10</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 11</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 12</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 13</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 14</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 15</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 16</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 17</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 18</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 19</li>
      <li style="padding-block: var(--m3e-sys-space-150)">Item 20</li>
    </ul>
  </m3e-pull-to-refresh>
</div>
<script>
  const feed = document.getElementById('feed');
  const button = document.getElementById('refresh-button');
  const count = document.getElementById('refresh-count');
  // The keyboard and mouse path: the same refresh a pull fires.
  button.addEventListener('click', () => feed.refresh());
  feed.addEventListener('refresh', () => {
    feed.refreshing = true;
    button.disabled = true;
    setTimeout(() => {
      count.textContent = String(Number(count.textContent) + 1);
      feed.refreshing = false;
      button.disabled = false;
    }, 1500);
  });
</script>

Run the HTML version (custom elements, no framework).

Usage

Refresh a scrolling list by pulling it down at its top (Compose PullToRefreshBox; no m3.material.io page at the pin).

API

React

<PullToRefresh>

Pull to refresh (COMPONENT_SPEC §38; Compose PullToRefreshBox): children scroll in __content (overscroll contained) under the indicator; a touch or pen pull down at the top brings it down and a release past the threshold calls onRefresh. m3e-elements' PullToRefreshLayout (the code <m3e-pull-to-refresh> runs) handles the pulls, the spring and the arrow, so both render the same DOM. The default indicator is Compose's arrow and circular progress; indicator="loading-indicator" uses contained <LoadingIndicator>s. The ref is the .m3e-pull-to-refresh root; other props go to it.

Prop Type Default Description
isRefreshing boolean Compose isRefreshing: the indicator rests at the threshold, spinning, while true.
isDisabled boolean Compose enabled = false: pulls are ignored.
indicator "default" | "loading-indicator" loading-indicator: PullToRefreshDefaults.LoadingIndicator (default: the arrow).
threshold number px a pull must pass (× 0.5) to refresh (default PositionalThreshold, 80dp).
indicatorLabel string The indicator's accessible name (default "Refresh").
children ReactNode The scrolling content.
onRefresh () => void Compose onRefresh: a pull released past the threshold. Set isRefreshing from it (the visible Refresh button, the keyboard path, calls the same handler; owner decision M9 #28).

Also accepts HTML attributes not listed here.

Custom elements

<m3e-pull-to-refresh>

import '@kyralabs/m3e-elements/pull-to-refresh.js';

<m3e-pull-to-refresh refreshing>…scrolling content…</m3e-pull-to-refresh> (COMPONENT_SPEC §38; Compose PullToRefreshBox). Renders the m3e-css .m3e-pull-to-refresh contract inside itself (the host is display: contents; the root fills its container's height): its child nodes move into __content, the scroll container (overscroll contained), under the indicator. Runs PullToRefreshLayout (shared with m3e-react): a touch or pen pull down at the top of the content brings the indicator down; a release past the threshold fires refresh.

  • refreshing (reflected; Compose isRefreshing): the app's state. Set it from refresh and clear it when done: the indicator rests at the threshold meanwhile, spinning.
  • indicator: default (Compose's PullToRefreshDefaults.Indicator: the arrow, then a circular progress) or loading-indicator (PullToRefreshDefaults.LoadingIndicator; owner decision M9 #27): the element drives <m3e-loading-indicator contained> children, whose entry it imports on first use instead of bundling it (D17; owner decision M9 #28).
  • disabled (Compose enabled = false), threshold (px; default PositionalThreshold, 80dp), indicator-label (the indicator's accessible name, default "Refresh").
  • Event refresh (Compose onRefresh). Method refresh() fires it too: what the visible Refresh button, the keyboard path (owner decision M9 #28), calls. distanceFraction.
Attribute Property Type Notes
refreshing refreshing boolean Reflected.
disabled disabled boolean
indicator indicator string
threshold threshold number
indicator-label indicatorLabel string
Event Detail Description
refresh null The user pulled past the threshold.

CSS

pull-to-refresh.css

@import '@kyralabs/m3e-css/pull-to-refresh.css';

Pull to refresh (COMPONENT_SPEC §38; Compose PullToRefreshBox with PullToRefreshDefaults.Indicator, or PullToRefreshDefaults.LoadingIndicator: owner decisions M9 #27, #28): a scroll container (the content, overscroll contained) under an indicator that a touch or pen pull at the top of the content brings down. The pull itself is script (m3e-elements' PullToRefreshLayout, run by <m3e-pull-to-refresh> and m3e-react's <PullToRefresh>); without it the content just scrolls.

<div class="m3e-pull-to-refresh" style="block-size: 480px">
  <div class="m3e-pull-to-refresh__content">…</div>
  <div class="m3e-pull-to-refresh__indicator">
    <div class="m3e-pull-to-refresh__box">
      <div class="m3e-pull-to-refresh__pull" role="progressbar" aria-label="Pull to refresh"
        aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
        <svg class="m3e-pull-to-refresh__arrow" viewBox="0 0 16 16" aria-hidden="true"
          focusable="false">
          <path class="m3e-pull-to-refresh__arc" d="…"></path>
          <path class="m3e-pull-to-refresh__head" d="…"></path>
        </svg>
      </div>
      <div class="m3e-pull-to-refresh__refreshing" aria-hidden="true">
        <div class="m3e-circular-progress" role="progressbar" aria-label="Pull to refresh">…</div>
      </div>
    </div>
  </div>
</div>
  • The indicator (IndicatorBox): a 40dp surface-container-high circle at the top center, moved
down by distanceFraction × IndicatorMaxDistance (80dp) from just above the content
(--_m3e-ptr-fraction, written by the layout), clipped at the content's top, with the Level2
shadow while pulled or refreshing ([data-pulled], [data-refreshing]).
  • __pull: the arrow (CircularArrowProgressIndicator; the layout writes the paths from
m3e-motion's port), at MinAlpha 0.3 below the threshold and MaxAlpha 1 from it ([data-armed]),
on DefaultEffects. __refreshing: the indeterminate circular progress (16dp, 2.5dp stroke, the
indicator color), cross-faded with __pull on DefaultEffects while [data-refreshing]; it
starts spinning from its first frame when the refresh starts.
  • --loading-indicator (owner decision M9 #27): PullToRefreshDefaults.LoadingIndicator, a 48dp
primary-container circle without a shadow; __pull holds a determinate contained loading
indicator (progress = distanceFraction; rotated −(fraction − 1) × 180° past the threshold),
__refreshing an indeterminate one (<m3e-loading-indicator contained> children the element
drives, owner decision M9 #28; m3e-react's <LoadingIndicator>).
  • The content scrolls with overscroll contained (no browser pull-to-refresh or scroll chaining);
while a pull can start ([data-at-top]: scrolled to the top, enabled, not refreshing) touch and
pen gestures moving down are left to the pull (touch-action), those moving up scroll.
  • Reduced motion: the indicator jumps (its spring is spatial: the layout reads 0ms); the alpha
and cross-fade stay. Forced colors: a CanvasText outline around the indicator.
20 custom properties it reads
Custom property Default
--m3e-comp-divider-thickness 1px
--m3e-comp-pull-to-refresh-arrow-alpha-duration var(--m3e-sys-motion-spring-default-effects-duration)
--m3e-comp-pull-to-refresh-arrow-alpha-easing var(--m3e-sys-motion-spring-default-effects-easing)
--m3e-comp-pull-to-refresh-arrow-max-alpha 1
--m3e-comp-pull-to-refresh-arrow-min-alpha 0.3
--m3e-comp-pull-to-refresh-crossfade-duration var(--m3e-sys-motion-spring-default-effects-duration)
--m3e-comp-pull-to-refresh-crossfade-easing var(--m3e-sys-motion-spring-default-effects-easing)
--m3e-comp-pull-to-refresh-indicator-color var(--m3e-sys-color-on-surface-variant)
--m3e-comp-pull-to-refresh-indicator-container-color var(--m3e-sys-color-surface-container-high)
--m3e-comp-pull-to-refresh-indicator-container-size 40px
--m3e-comp-pull-to-refresh-indicator-max-distance 80px
--m3e-comp-pull-to-refresh-indicator-shadow var(--m3e-sys-elevation-level2-shadow)
--m3e-comp-pull-to-refresh-indicator-shape var(--m3e-sys-shape-corner-full)
--m3e-comp-pull-to-refresh-loading-indicator-color var(--m3e-sys-color-on-primary-container)
--m3e-comp-pull-to-refresh-loading-indicator-container-color var(--m3e-sys-color-primary-container)
--m3e-comp-pull-to-refresh-loading-indicator-container-height 48px
--m3e-comp-pull-to-refresh-loading-indicator-container-width 48px
--m3e-comp-pull-to-refresh-loading-indicator-shadow var(--m3e-sys-elevation-level0-shadow)
--m3e-comp-pull-to-refresh-spinner-size 16px
--m3e-comp-pull-to-refresh-stroke-width 2.5px

Accessibility

a pull at the top of the content brings the indicator down: the drag counts × 0.5 (DragMultiplier) up to the threshold (PositionalThreshold, 80dp), then follows the tension curve threshold × (1 + t − t²/4), t = min(overshoot, 2), so at most twice the threshold; moving back up retracts the pull before the content scrolls. A release with the adjusted distance past the threshold calls onRefresh; the indicator then springs back to hidden, or, while the app's isRefreshing is true, to the threshold, where it stays until it is false. Pulls are ignored while refreshing and while the indicator animates. The default indicator is Compose's PullToRefreshDefaults.Indicator (the arrow, then a circular progress while refreshing); PullToRefreshDefaults.LoadingIndicator (a contained loading indicator) is the loading-indicator modifier (owner decision M9 #27; the earlier wording "pull past threshold shows LoadingIndicator" was not Compose's default). Enabled only inside the component's own scroll container (__content), with overscroll-behavior: contain.

Every interactive part has a target of at least 48 × 48 px, a visible focus ring for keyboard focus only, and works under forced-colors and prefers-reduced-motion.

Differences from Compose

a pull starts only from a gesture that begins at the content's top (Compose's nested scroll also turns a scroll that reaches the top into a pull); release velocity is not consumed (no list fling to stop); a cancelled pointer goes back without refreshing; the arrow head angle uses density 1, a CSS px being a dp (D19 (23), register #86); pulls from mouse and wheel are not supported (decision #28).

Sources

Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from m3.material.io, as cited below from COMPONENT_SPEC §38 and the m3e-css sources.

pulltorefresh/PullToRefresh.kt, PullToRefreshSamples.kt, LoadingIndicator.kt, ProgressIndicator.kt, animation-core Animatable.kt, AnimationSpec.kt, VectorizedAnimationSpec.kt (vendored).

pull-to-refresh.css sources

(values: pull-to-refresh-defaults.css, lines in PARITY.md): compose/material3 @ 19991f2c pulltorefresh/PullToRefresh.kt (PullToRefreshBox, Modifier.pullToRefresh, PullToRefreshDefaults), PullToRefreshSamples.kt; no m3.material.io page.