> ## Documentation Index
> Fetch the complete documentation index at: https://hyperframes.heygen.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Pull to Refresh

> A mobile list pulls through nonlinear rubber-band resistance, arms at a threshold, commits to a bounded loading indicator, then snaps exactly back to rest.

export const InstallCommand = ({command}) => {
  const [copied, setCopied] = React.useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(command);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = command;
        scratch.setAttribute("readonly", "");
        scratch.style.position = "fixed";
        scratch.style.opacity = "0";
        document.body.appendChild(scratch);
        scratch.select();
        document.execCommand("copy");
        document.body.removeChild(scratch);
        previous?.focus?.();
      }
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="hf-install-command not-prose my-4 flex items-stretch overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <code className="flex-1 overflow-x-auto whitespace-nowrap border-r border-zinc-200 px-4 py-3 font-mono text-sm text-zinc-800 dark:border-zinc-800 dark:text-zinc-100">
        {command}
      </code>
      <button type="button" onClick={copy} data-copied={copied ? "true" : "false"} aria-label={`Copy ${command} to the clipboard`} className="hf-install-copy">
        <svg className="hf-install-copy-clipboard" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" />
          <path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" />
        </svg>
        <svg className="hf-install-copy-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.75 9.5L6.5 13.25L15.25 4.5" />
        </svg>
      </button>
      <span className="hf-install-copy-status" role="status" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>
    </div>;
};

<iframe className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800" title="pull-to-refresh preview" loading="lazy" srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/components/pull-to-refresh.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/pull-to-refresh.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add pull-to-refresh" />

That writes one file: `compositions/components/pull-to-refresh.html`.

## Paste it into your composition

Open `compositions/components/pull-to-refresh.html` and copy what is inside into your own composition.

A component has no size or duration of its own. It takes both from the composition
you paste it into.

## Variables

Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:

| Variable       | Default | Accepts                 | What it does                                               |
| -------------- | ------- | ----------------------- | ---------------------------------------------------------- |
| `pullDistance` | `120`   | 60 to 220, step 5       | Raw pull distance before nonlinear rubber-band resistance. |
| `spinnerStyle` | `ring`  | `arrow`, `dots`, `ring` | Refresh indicator geometry and loading motion.             |

Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:

```html wrap theme={null}
<div
  data-composition-id="pull-to-refresh"
  data-composition-src="compositions/components/pull-to-refresh.html"
  data-variable-values='{"pullDistance":120,"spinnerStyle":"ring"}'
></div>
```

## Source

<Accordion title={`pull-to-refresh.html`}>
  ```html theme={null}
  <!doctype html>
  <!--
    pull-to-refresh: HyperFrames video primitive (pointers / interaction / demonstrate)

    Concept: a mobile list yields under a scripted pull, arms at a visible
    threshold, commits into a bounded loading state, then snaps exactly back
    to rest. One mechanic, one job: demonstrate a native pull-to-refresh.

    Compiled-from evidence: GAP, agent B1 hard miss (video-primitives
    candidates card); mobile gesture-physics-recipes.md, section 9 rubber-band
    curve and quiet/default/forceful register table. The constants below use
    the default register as inspiration, while the self-contained resistance
    curve replaces the shared toolkit that is not available in this catalog.

    Use when: a mobile product scene needs a recognizable refresh gesture and
    loading handoff. Skip it for generic scrolling or a device silhouette.

    Variables (declared in data-composition-variables below):
      - pullDistance (number, default 120, range 60–220): raw pull distance fed
        into the nonlinear resistance curve before host-relative translation.
      - spinnerStyle ("arrow" | "dots" | "ring", default "ring"): the refresh
        indicator. Each option has different geometry and active motion.

    Envelope (fixed IN/OUT, elastic loading HOLD only, never gsap.timeScale()):
      IN_BASE  = 1.80s  contact, resistant drag, threshold arm, commit,
                        handoff, and the first bounded loading cycle
      HOLD     = elastic = max(0, D - (IN_BASE + OUT_BASE)); additional finite
                 loading cycles while the content stays at its held offset
      OUT_BASE = 0.65s  indicator exit and content snap-back
      If D < 2.45s, IN and OUT scale down together so IN + OUT == D and HOLD
      is zero. The drag remains heavier and slower than the release response.

    Sync points (fixed offsets, never inside elastic HOLD):
      - impact: 0.82s into unscaled IN, threshold commit and spinner handoff
      - settle: 0.65s into unscaled OUT, content reseated exactly at y = 0
      Both offsets scale proportionally only when D compresses the envelope.

    Sound cues: declarative only. `hf:sfx` events fire for "refresh-commit"
    at impact and "refresh-settle" at settle. Scene-level mixing owns audio.

    Mount contract: this file is a mountable sub-composition. A host loads it
    via data-composition-src, and the runtime clones only <template> contents.
    #root fills the host box with position:absolute, inset:0, and
    container-type:size. It has no data-width or data-height. All internal
    geometry uses cqw/cqh, and the hardcoded composition id is required because
    FLATTENED_INNER_ROOT_STRIP_ATTRS removes the mounted root id attribute.
    Variables come from window.__hyperframes.getVariables(), which owns merged
    declared defaults and per-instance overrides after mounting.
  -->
  <html
    lang="en"
    data-composition-variables='[
      { "id": "pullDistance", "type": "number", "role": "layout", "label": "Pull distance", "description": "Raw pull distance before nonlinear rubber-band resistance.", "default": 120, "min": 60, "max": 220, "step": 5 },
      { "id": "spinnerStyle", "type": "enum", "role": "content", "label": "Spinner style", "description": "Refresh indicator geometry and loading motion.", "default": "ring", "options": [{ "value": "arrow", "label": "Arrow" }, { "value": "dots", "label": "Dots" }, { "value": "ring", "label": "Ring" }] }
    ]'
  >
    <head>
      <meta charset="UTF-8" />
      <title>Pull to Refresh</title>
      <!-- Metadata only. The mount runtime discards everything outside the
           template, while the loader still reads variable declarations from
           this html element before cloning the template. -->
    </head>
    <body>
      <template>
        <div id="root" data-composition-id="pull-to-refresh" data-duration="4" data-fps="30">
          <style>
            @property --ptr-progress {
              syntax: "<number>";
              inherits: true;
              initial-value: 0;
            }

            *,
            *::before,
            *::after {
              box-sizing: border-box;
            }

            /* INVARIANT: #root is the mount-box owner. The host supplies every
               dimension, and all descendants size against this container. */
            #root {
              position: absolute;
              inset: 0;
              container-type: size;
              isolation: isolate;
              overflow: hidden;
              color: var(--fg, #f4f7fb);
              font-family: var(--font-body, Inter, system-ui, sans-serif);
            }

            .ptr-clip {
              position: relative;
              width: 100%;
              height: 100%;
              overflow: hidden;
              background: var(--bg, #0b1120);
            }

            .ptr-indicator {
              --ptr-progress: 0;
              position: absolute;
              z-index: 0;
              inset: 0 0 auto;
              height: 15cqh;
              display: flex;
              flex-direction: column;
              align-items: center;
              padding-top: 1.5cqh;
              opacity: 0;
              color: var(--brand, #35d6a0);
            }

            .ptr-indicator-visual {
              width: 6.2cqh;
              height: 6.2cqh;
              display: none;
              place-items: center;
            }

            #root[data-spinner-style="ring"] .ptr-ring,
            #root[data-spinner-style="arrow"] .ptr-arrow,
            #root[data-spinner-style="dots"] .ptr-dots {
              display: grid;
            }

            /* EDIT ZONE: all three styles read the same --ptr-progress scalar.
               Ring maps it to arc fill, arrow maps it to a threshold flip, and
               dots map it to a three-step reveal. Active motion stays separate
               so the selected style has a distinctive loading silhouette. */
            .ptr-ring-core {
              width: 100%;
              height: 100%;
              border-radius: 50%;
              background: conic-gradient(
                from -90deg,
                var(--brand, #35d6a0) 0 calc(var(--ptr-progress) * 1%),
                color-mix(in srgb, var(--surface, #1e293b) 86%, transparent)
                  calc(var(--ptr-progress) * 1%) 100%
              );
              -webkit-mask: radial-gradient(farthest-side, transparent 59%, var(--fg, #f4f7fb) 61%);
              mask: radial-gradient(farthest-side, transparent 59%, var(--fg, #f4f7fb) 61%);
            }

            .ptr-arrow-progress {
              width: 100%;
              height: 100%;
              display: grid;
              place-items: center;
              border-radius: 50%;
              border: 0.35cqh solid
                color-mix(in srgb, var(--brand, #35d6a0) 58%, var(--border, #334155));
              background: color-mix(in srgb, var(--surface, #1e293b) 88%, transparent);
              transform: rotate(calc(var(--ptr-progress) * 1.8deg));
            }

            .ptr-arrow-glyph {
              position: relative;
              width: 1.5cqh;
              height: 2.7cqh;
              border-radius: 0.3cqh;
              background: var(--brand, #35d6a0);
            }

            .ptr-arrow-glyph::after {
              content: "";
              position: absolute;
              left: 50%;
              bottom: -0.8cqh;
              width: 2.3cqh;
              height: 2.3cqh;
              border-right: 0.55cqh solid var(--brand, #35d6a0);
              border-bottom: 0.55cqh solid var(--brand, #35d6a0);
              transform: translateX(-50%) rotate(45deg);
            }

            .ptr-dots {
              grid-template-columns: repeat(3, 1.7cqh);
              gap: 0.8cqh;
              align-content: center;
              width: auto;
            }

            .ptr-dot {
              width: 1.7cqh;
              height: 1.7cqh;
              border-radius: 50%;
              background: var(--brand, #35d6a0);
              box-shadow: 0 0 1.2cqh color-mix(in srgb, var(--brand, #35d6a0) 50%, transparent);
            }

            .ptr-dot:nth-child(1) {
              opacity: clamp(0.2, calc(var(--ptr-progress) / 30), 1);
            }

            .ptr-dot:nth-child(2) {
              opacity: clamp(0.2, calc((var(--ptr-progress) - 30) / 30), 1);
            }

            .ptr-dot:nth-child(3) {
              opacity: clamp(0.2, calc((var(--ptr-progress) - 60) / 30), 1);
            }

            .ptr-state {
              position: relative;
              width: 100%;
              height: 3.4cqh;
              margin-top: 0.9cqh;
              color: var(--muted, #94a3b8);
              font-family: var(--font-display, Inter, system-ui, sans-serif);
              font-size: clamp(1.35cqh, 1.8cqw, 2.05cqh);
              font-weight: 700;
              letter-spacing: 0.04em;
              text-align: center;
              text-transform: uppercase;
            }

            .ptr-state span {
              position: absolute;
              inset: 0;
              opacity: 0;
            }

            .ptr-content {
              position: absolute;
              z-index: 1;
              inset: 0;
              overflow: hidden;
              background: var(--bg, #0b1120);
              border-radius: inherit;
              box-shadow: 0 -1cqh 3cqh color-mix(in srgb, var(--bg, #0b1120) 65%, transparent);
            }

            .ptr-topbar {
              display: flex;
              align-items: center;
              justify-content: space-between;
              height: 10cqh;
              padding: 0 6cqw;
              border-bottom: 0.12cqh solid var(--border, #334155);
              background: color-mix(in srgb, var(--surface, #1e293b) 52%, var(--bg, #0b1120));
            }

            .ptr-title {
              font-family: var(--font-display, Inter, system-ui, sans-serif);
              font-size: clamp(2.8cqh, 5.2cqw, 4.3cqh);
              font-weight: 760;
              letter-spacing: -0.035em;
            }

            .ptr-updated {
              display: flex;
              align-items: center;
              gap: 1.4cqw;
              color: var(--brand, #35d6a0);
              font-size: clamp(1.4cqh, 2.2cqw, 2cqh);
              font-weight: 700;
              opacity: 0;
            }

            .ptr-updated::before {
              content: "";
              width: 1.4cqh;
              height: 1.4cqh;
              border-radius: 50%;
              background: var(--brand, #35d6a0);
              box-shadow: 0 0 1.2cqh color-mix(in srgb, var(--brand, #35d6a0) 50%, transparent);
            }

            .ptr-search {
              display: flex;
              align-items: center;
              height: 7cqh;
              margin: 3cqh 5cqw 2cqh;
              padding: 0 4cqw;
              border: 0.12cqh solid var(--border, #334155);
              border-radius: 3.5cqh;
              background: var(--surface, #1e293b);
              color: var(--muted, #94a3b8);
              font-size: clamp(1.7cqh, 2.8cqw, 2.5cqh);
            }

            .ptr-list {
              display: grid;
              gap: 1.7cqh;
              padding: 0 5cqw 5cqh;
            }

            .ptr-card {
              display: grid;
              grid-template-columns: 8cqh 1fr auto;
              align-items: center;
              min-height: 11.5cqh;
              gap: 3cqw;
              padding: 2cqh 4cqw;
              border: 0.12cqh solid var(--border, #334155);
              border-radius: 2.4cqh;
              background: var(--surface, #1e293b);
              box-shadow: 0 1cqh 2.4cqh color-mix(in srgb, var(--bg, #0b1120) 55%, transparent);
            }

            .ptr-avatar {
              display: grid;
              place-items: center;
              width: 6.3cqh;
              height: 6.3cqh;
              border-radius: 2cqh;
              background: color-mix(in srgb, var(--brand, #35d6a0) 20%, var(--surface, #1e293b));
              color: var(--brand, #35d6a0);
              font-family: var(--font-display, Inter, system-ui, sans-serif);
              font-size: 2.3cqh;
              font-weight: 800;
            }

            .ptr-copy {
              min-width: 0;
            }

            .ptr-card-title {
              color: var(--fg, #f4f7fb);
              font-family: var(--font-display, Inter, system-ui, sans-serif);
              font-size: clamp(1.9cqh, 3.1cqw, 2.7cqh);
              font-weight: 720;
            }

            .ptr-card-subtitle {
              margin-top: 0.5cqh;
              color: var(--muted, #94a3b8);
              font-size: clamp(1.45cqh, 2.3cqw, 2.05cqh);
              line-height: 1.25;
            }

            .ptr-time {
              align-self: start;
              color: var(--muted, #94a3b8);
              font-size: clamp(1.25cqh, 2cqw, 1.8cqh);
              font-weight: 650;
            }
          </style>

          <div
            id="pull-to-refresh-clip"
            class="ptr-clip clip"
            data-start="0"
            data-duration="4"
            data-track-index="0"
          >
            <div class="ptr-indicator" aria-hidden="true">
              <div class="ptr-indicator-visual ptr-ring">
                <div class="ptr-ring-core"></div>
              </div>
              <div class="ptr-indicator-visual ptr-arrow">
                <div class="ptr-arrow-progress">
                  <div class="ptr-arrow-glyph"></div>
                </div>
              </div>
              <div class="ptr-indicator-visual ptr-dots">
                <span class="ptr-dot"></span>
                <span class="ptr-dot"></span>
                <span class="ptr-dot"></span>
              </div>
              <div class="ptr-state">
                <span class="ptr-pull-label">Pull to refresh</span>
                <span class="ptr-release-label">Release to refresh</span>
                <span class="ptr-loading-label">Refreshing</span>
              </div>
            </div>

            <section class="ptr-content" aria-label="Activity list">
              <header class="ptr-topbar">
                <div class="ptr-title">Activity</div>
                <div class="ptr-updated">Updated now</div>
              </header>
              <div class="ptr-search">Search activity</div>
              <div class="ptr-list">
                <article class="ptr-card">
                  <div class="ptr-avatar">AL</div>
                  <div class="ptr-copy">
                    <div class="ptr-card-title">Alex shared a draft</div>
                    <div class="ptr-card-subtitle">Launch sequence, ready for review</div>
                  </div>
                  <div class="ptr-time">Now</div>
                </article>
                <article class="ptr-card">
                  <div class="ptr-avatar">MK</div>
                  <div class="ptr-copy">
                    <div class="ptr-card-title">Mika left feedback</div>
                    <div class="ptr-card-subtitle">Three comments on the new flow</div>
                  </div>
                  <div class="ptr-time">4m</div>
                </article>
                <article class="ptr-card">
                  <div class="ptr-avatar">NO</div>
                  <div class="ptr-copy">
                    <div class="ptr-card-title">Noah published an update</div>
                    <div class="ptr-card-subtitle">Mobile preview is available</div>
                  </div>
                  <div class="ptr-time">18m</div>
                </article>
                <article class="ptr-card">
                  <div class="ptr-avatar">SR</div>
                  <div class="ptr-copy">
                    <div class="ptr-card-title">Sara joined the project</div>
                    <div class="ptr-card-subtitle">Workspace access granted</div>
                  </div>
                  <div class="ptr-time">1h</div>
                </article>
              </div>
            </section>
          </div>

          <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
          <script>
            (function () {
              "use strict";

              var root = document.getElementById("root");
              // Literal by contract. Mounted roots lose data-composition-id
              // during flattening, so reading the id back from the DOM can
              // register the timeline under "null".
              var compositionId = "pull-to-refresh";
              var clip = root.querySelector(".ptr-clip");
              var indicator = root.querySelector(".ptr-indicator");
              var content = root.querySelector(".ptr-content");
              var pullLabel = root.querySelector(".ptr-pull-label");
              var releaseLabel = root.querySelector(".ptr-release-label");
              var loadingLabel = root.querySelector(".ptr-loading-label");
              var ring = root.querySelector(".ptr-ring-core");
              var arrow = root.querySelector(".ptr-arrow-glyph");
              var dots = Array.prototype.slice.call(root.querySelectorAll(".ptr-dot"));
              var updated = root.querySelector(".ptr-updated");
              var vars =
                window.__hyperframes && window.__hyperframes.getVariables
                  ? window.__hyperframes.getVariables()
                  : {};

              // INVARIANT: only finite 60–220 input reaches the physics owner.
              // Zero, NaN, and strings outside the range fall back explicitly.
              var rawPull = Number(vars.pullDistance);
              var pullDistance = Number.isFinite(rawPull)
                ? Math.max(60, Math.min(220, rawPull))
                : 120;
              // INVARIANT: only one of the declared indicator variants reaches
              // the renderer. Invalid overrides resolve to the declared default.
              var spinnerStyle =
                vars.spinnerStyle === "arrow" || vars.spinnerStyle === "dots"
                  ? vars.spinnerStyle
                  : "ring";
              root.setAttribute("data-spinner-style", spinnerStyle);

              // Self-contained rubber-band mapping from the physics recipe:
              // displayed = x*c / (x*c/d + 1). REFERENCE is a normalized
              // preview dimension, then converted to cqh for host-relative
              // motion. This function alone owns resistance and displacement.
              var RESISTANCE = 0.55;
              var REFERENCE = 640;
              function rubberBand(distance) {
                return (distance * RESISTANCE) / ((distance * RESISTANCE) / REFERENCE + 1);
              }
              var shownPeak = (rubberBand(pullDistance) / REFERENCE) * 100;
              var heldOffset = Math.min(shownPeak, Math.max(5.8, shownPeak * 0.76));
              var dragState = { raw: 0 };

              // RETIME RANGE: lifecycle ownership lives only in these constants.
              // IN and OUT scale together only when the composition duration is short.
              var IN_BASE = 1.8;
              var OUT_BASE = 0.65;
              var CONTACT_BASE = 0.12;
              var DRAG_BASE = 0.7;
              var ARM_AT_BASE = 0.61;
              var IMPACT_AT_BASE = 0.82;
              var HANDOFF_BASE = 0.22;
              var durationValue = Number(clip.dataset.duration);
              var duration = Number.isFinite(durationValue) && durationValue > 0 ? durationValue : 4;
              var totalBase = IN_BASE + OUT_BASE;
              var scale = duration < totalBase ? duration / totalBase : 1;
              var IN = IN_BASE * scale;
              var OUT = OUT_BASE * scale;
              var CONTACT = CONTACT_BASE * scale;
              var DRAG = DRAG_BASE * scale;
              var ARM_AT = ARM_AT_BASE * scale;
              var IMPACT_AT = IMPACT_AT_BASE * scale;
              var HANDOFF = HANDOFF_BASE * scale;
              var HOLD = Math.max(0, duration - IN - OUT);
              var OUT_START = IN + HOLD;
              var ACTIVE_DURATION = Math.max(0.001, OUT_START - IMPACT_AT);

              function fireSfx(id, atTime) {
                root.dispatchEvent(
                  new CustomEvent("hf:sfx", {
                    detail: { id: id, t: atTime },
                    bubbles: true,
                  }),
                );
              }

              gsap.set(content, { y: "0cqh" });
              gsap.set(indicator, { "--ptr-progress": 0, opacity: 0 });
              gsap.set(pullLabel, { opacity: 1 });
              gsap.set(releaseLabel, { opacity: 0 });
              gsap.set(loadingLabel, { opacity: 0 });
              gsap.set(ring, { rotation: 0 });
              gsap.set(arrow, { rotation: 0 });
              gsap.set(dots, { y: "0cqh", scale: 1 });
              gsap.set(updated, { opacity: 0 });

              var tl = gsap.timeline({ paused: true });

              // IN, contact and nonlinear pull. The distance is already
              // resistance-mapped, while power2.out controls drag timing.
              tl.fromTo(
                indicator,
                { "--ptr-progress": 0, opacity: 0 },
                {
                  "--ptr-progress": 100,
                  opacity: 1,
                  duration: DRAG,
                  ease: "power2.out",
                  immediateRender: false,
                },
                CONTACT,
              );
              tl.fromTo(
                dragState,
                { raw: 0 },
                {
                  raw: pullDistance,
                  duration: DRAG,
                  ease: "power2.out",
                  onUpdate: function () {
                    var displayed = (rubberBand(dragState.raw) / REFERENCE) * 100;
                    gsap.set(content, { y: displayed + "cqh" });
                  },
                  immediateRender: false,
                },
                CONTACT,
              );

              // Threshold arm, a fixed point in IN. The indicator geometry is
              // full and the label flips before release commits the refresh.
              tl.to(pullLabel, { opacity: 0, duration: 0.12 * scale, ease: "power2.in" }, ARM_AT);
              tl.to(releaseLabel, { opacity: 1, duration: 0.16 * scale, ease: "power2.out" }, ARM_AT);

              tl.addLabel("impact", IMPACT_AT);
              tl.fromTo(
                content,
                { y: shownPeak + "cqh" },
                {
                  y: heldOffset + "cqh",
                  duration: HANDOFF,
                  ease: "power3.out",
                  immediateRender: false,
                },
                IMPACT_AT,
              );
              tl.to(
                releaseLabel,
                { opacity: 0, duration: 0.1 * scale, ease: "power2.in" },
                IMPACT_AT,
              );
              tl.to(
                loadingLabel,
                { opacity: 1, duration: 0.16 * scale, ease: "power2.out" },
                IMPACT_AT,
              );
              tl.call(
                function () {
                  fireSfx("refresh-commit", IMPACT_AT);
                },
                [],
                IMPACT_AT,
              );

              // Loading HOLD: every cycle count is finite and determined once.
              // Each style owns a distinct active motion, but only the resolved
              // style is authored into the timeline.
              if (spinnerStyle === "ring") {
                var spinCount = Math.max(1, Math.round(ACTIVE_DURATION / 0.58));
                tl.fromTo(
                  ring,
                  { rotation: 0 },
                  {
                    rotation: 360 * spinCount,
                    duration: ACTIVE_DURATION,
                    ease: "linear",
                    immediateRender: false,
                  },
                  IMPACT_AT,
                );
              } else if (spinnerStyle === "arrow") {
                var arrowSpinCount = Math.max(1, Math.round(ACTIVE_DURATION / 0.64));
                tl.fromTo(
                  arrow,
                  { rotation: 0 },
                  {
                    rotation: 360 * arrowSpinCount,
                    duration: ACTIVE_DURATION,
                    ease: "linear",
                    immediateRender: false,
                  },
                  IMPACT_AT,
                );
              } else {
                var PULSE_HALF = 0.22 * scale;
                dots.forEach(function (dot, index) {
                  var dotStart = IMPACT_AT + index * 0.1 * scale;
                  var available = Math.max(PULSE_HALF, OUT_START - dotStart);
                  var pulseRepeat = Math.max(1, Math.floor(available / PULSE_HALF) - 1);
                  tl.fromTo(
                    dot,
                    { y: "0cqh", scale: 1 },
                    {
                      y: "-1.15cqh",
                      scale: 1.28,
                      duration: PULSE_HALF,
                      ease: "sine.inOut",
                      yoyo: true,
                      repeat: pulseRepeat,
                      immediateRender: false,
                    },
                    dotStart,
                  );
                });
              }

              // OUT: opacity exits without overshoot. Content alone earns the
              // rare tactile back.out snap and lands exactly at zero.
              tl.fromTo(
                indicator,
                { opacity: 1 },
                {
                  opacity: 0,
                  duration: Math.min(OUT, 0.2 * scale),
                  ease: "power2.in",
                  immediateRender: false,
                },
                OUT_START,
              );
              tl.fromTo(
                content,
                { y: heldOffset + "cqh" },
                {
                  y: "0cqh",
                  duration: OUT,
                  ease: "back.out(1.7)",
                  immediateRender: false,
                },
                OUT_START,
              );
              tl.fromTo(
                updated,
                { opacity: 0 },
                {
                  opacity: 1,
                  duration: Math.min(OUT, 0.28 * scale),
                  ease: "power2.out",
                  immediateRender: false,
                },
                OUT_START + OUT * 0.32,
              );
              tl.addLabel("settle", OUT_START + OUT);
              tl.call(
                function () {
                  fireSfx("refresh-settle", OUT_START + OUT);
                },
                [],
                OUT_START + OUT,
              );

              tl.seek(0);

              window.__timelines = window.__timelines || {};
              window.__timelines[compositionId] = tl;
            })();
          </script>
        </div>
      </template>
    </body>
  </html>
  ```
</Accordion>

Tagged `pointer` `pointers` `mobile` `gesture` `interaction` `refresh` `demonstrate`.

## Related topics

* [Browse the complete Catalog](/catalog)
* [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
* [Build a richer composition](/go-further)


## Related topics

- [Figma integration](/guides/figma.md)
- [Pull Back Reveal](/catalog/components/pull-back-reveal.md)
- [Turn a pull request into a video](/guides/pr-to-video.md)
