Refresh Pull Flywheel

Refresh

Pull-to-refresh as a crank and flywheel: pulling down winds a spoked SVG wheel through a rack-and-pinion drivetrain, release hands it angular momentum, and the wheel freewheels through the request in flight before the response brakes it to a stop as new rows settle in.

Install
npx shadcn add https://design.helpmarq.com/r/refresh-pull-flywheel.json
Source
registry/core/refresh-pull-flywheel/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
} from "react";

// ---------------------------------------------------------------------------
// FlywheelPull — pull-to-refresh as a crank and flywheel. Dragging the wheel
// zone down winds a spoked SVG wheel through a visible rack-and-pinion
// drivetrain, pull distance mapping 1:1 to wind-up angle. Releasing hands the
// wheel an angular velocity of pullDistance * a gain constant, integrated per
// rAF frame under low friction while the (real or simulated) refresh request
// is in flight — the loading state IS the gesture's energy bleeding off, not
// a canned spinner swap. When the response lands, friction jumps and the
// wheel decays to rest over ~400ms; new rows FLIP into the list at the same
// moment the wheel settles. A visible Refresh button is always the primary
// path; the drag is enhancement, and clicking it replays the same physics
// via a synthetic pull so the two paths feel like one mechanism. DOM+SVG+CSS
// only, no canvas, colors are CSS custom properties only.
// ---------------------------------------------------------------------------

const WIND_DEG_PER_PX = 1.05; // wind-up angle per px pulled
const MAX_PULL = 130; // px — beyond this, rubber-band resistance
const RUBBER = 0.32; // beyond-MAX_PULL displacement scale
const RELEASE_GAIN = 1.65; // deg/s of release velocity per px pulled
const FRICTION_FREE = 0.55; // s^-1 — low friction while request is in flight
const FRICTION_BRAKE = 10; // s^-1 — applied once the response arrives
const SLEEP_OMEGA = 3; // deg/s — below this, and braking, the wheel is at rest
const PULL_RETURN = 16; // s^-1 — rack/pinion spring back to 0 after release
const FORCE_SETTLE_MS = 6000; // safety deadline so physics never spins forever
const BUTTON_WIND_PX = 96; // synthetic pull distance for a click-only refresh
const MIN_LATENCY_MS = 900;
const MAX_LATENCY_MS = 1500;
const MAX_ITEMS = 14;
const ENTER_STAGGER_MS = 70;
const ENTER_DURATION_MS = 380;

const CX_WHEEL = 176;
const CY_WHEEL = 80;
const R_WHEEL = 54;
const CX_PINION = 86;
const CY_PINION = 80;
const R_PINION = 15;
const SPOKES = 8;
const RACK_X = 40;
const RACK_Y = 6;
const RACK_W = 30;
const RACK_H = 148;
const TOOTH_PITCH = 16;

export interface FlywheelPullItem {
  id: string;
  title: string;
  meta?: string;
}

export interface FlywheelPullProps {
  /** Initial feed rows, newest first. */
  defaultItems?: FlywheelPullItem[];
  /**
   * Called on release/refresh; resolve with the new rows to prepend. Omit to
   * use a built-in simulated request (900-1500ms) that manufactures rows —
   * fine for a demo, but a real feed should supply this.
   */
  onRefresh?: () => Promise<FlywheelPullItem[]>;
  /** Accessible label for the row list. */
  label?: string;
  className?: string;
}

const SAMPLE_TITLES = [
  "New comment on “Q3 roadmap”",
  "3 people starred your repo",
  "Deploy finished: prod-web-42",
  "Weekly digest is ready",
  "Mentioned you in #general",
  "New follower: @octavia",
  "Build failed on staging",
  "Invoice #4021 was paid",
  "2 reviewers approved your PR",
  "New device signed in",
];

let idCounter = 0;
function generateItems(n: number): FlywheelPullItem[] {
  const out: FlywheelPullItem[] = [];
  for (let i = 0; i < n; i++) {
    idCounter += 1;
    const title =
      SAMPLE_TITLES[Math.floor(Math.random() * SAMPLE_TITLES.length)] ??
      "Update";
    out.push({ id: `fw-${idCounter}`, title, meta: "just now" });
  }
  return out;
}

function simulateRefresh(): Promise<FlywheelPullItem[]> {
  const latency =
    MIN_LATENCY_MS + Math.random() * (MAX_LATENCY_MS - MIN_LATENCY_MS);
  const count = 2 + Math.floor(Math.random() * 5); // 2..6
  return new Promise((resolve) => {
    window.setTimeout(() => resolve(generateItems(count)), latency);
  });
}

type Phase = "idle" | "busy" | "done" | "error";

export function FlywheelPull({
  defaultItems,
  onRefresh,
  label = "Activity feed",
  className = "",
}: FlywheelPullProps) {
  const [items, setItems] = useState<FlywheelPullItem[]>(
    () => defaultItems ?? generateItems(5)
  );
  const [phase, setPhase] = useState<Phase>("idle");
  const [liveText, setLiveText] = useState("");
  const [enteringOrder, setEnteringOrder] = useState<string[]>([]);

  const zoneRef = useRef<HTMLDivElement>(null);
  const wheelGroupRef = useRef<SVGGElement>(null);
  const pinionGroupRef = useRef<SVGGElement>(null);
  const rackTeethRef = useRef<SVGGElement>(null);
  const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
  const prevRectsRef = useRef<Map<string, DOMRect>>(new Map());

  const reducedRef = useRef(false);
  const onRefreshRef = useRef(onRefresh);
  onRefreshRef.current = onRefresh;

  const captureRects = useCallback(() => {
    const map = new Map<string, DOMRect>();
    rowRefs.current.forEach((el, id) => {
      map.set(id, el.getBoundingClientRect());
    });
    prevRectsRef.current = map;
  }, []);

  // single place that turns a settled request into visible feed state —
  // called from the physics loop (in flight), the reduced-motion pointer
  // path, and the reduced-motion button path alike.
  const applyResult = useCallback(
    (result: FlywheelPullItem[] | null, failed: boolean) => {
      if (failed || !result) {
        setPhase("error");
        setLiveText("Refresh failed, wheel at rest.");
        window.setTimeout(() => setPhase("idle"), 1600);
        return;
      }
      captureRects();
      setItems((prev) => [...result, ...prev].slice(0, MAX_ITEMS));
      const ids = result.map((r) => r.id);
      setEnteringOrder(ids);
      setPhase("done");
      setLiveText(
        `Updated, ${result.length} new item${result.length === 1 ? "" : "s"}`
      );
      window.setTimeout(
        () => {
          setEnteringOrder((cur) => cur.filter((id) => !ids.includes(id)));
        },
        ENTER_DURATION_MS + ids.length * ENTER_STAGGER_MS + 60
      );
    },
    [captureRects]
  );
  const applyResultRef = useRef(applyResult);
  applyResultRef.current = applyResult;
  // every refresh (drag release or button) bumps this; a resolving request
  // only commits if it's still the most recent one — clicking Refresh again
  // mid-flight restarts the wheel instead of queuing a second, stale commit.
  const genRef = useRef(0);

  useLayoutEffect(() => {
    if (reducedRef.current) return;
    const prev = prevRectsRef.current;
    rowRefs.current.forEach((el, id) => {
      const before = prev.get(id);
      if (!before) return;
      const after = el.getBoundingClientRect();
      const dy = before.top - after.top;
      if (Math.abs(dy) < 0.5) return;
      el.style.transition = "none";
      el.style.transform = `translateY(${dy}px)`;
      requestAnimationFrame(() => {
        el.style.transition = "transform 420ms cubic-bezier(.22,1,.36,1)";
        el.style.transform = "";
      });
    });
  }, [items]);

  useEffect(() => {
    const zone = zoneRef.current;
    if (!zone) return;
    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    reducedRef.current = reduced;

    // hot-path physics state — plain locals, no React state per frame
    let angle = 0;
    let pull = 0;
    let v = 0; // deg/s
    let braking = false;
    let raf = 0;
    let last = 0;
    let deadline = 0;
    let dragging = false;
    let pointerId = -1;
    let startY = 0;
    let baseAngle = 0;
    let pendingResolve: (() => void) | null = null;

    const writeTransforms = (a: number, p: number) => {
      wheelGroupRef.current?.setAttribute(
        "transform",
        `rotate(${a.toFixed(2)} ${CX_WHEEL} ${CY_WHEEL})`
      );
      pinionGroupRef.current?.setAttribute(
        "transform",
        `rotate(${a.toFixed(2)} ${CX_PINION} ${CY_PINION})`
      );
      rackTeethRef.current?.setAttribute(
        "transform",
        `translate(0 ${p.toFixed(2)})`
      );
    };

    const loop = (now: number) => {
      raf = 0;
      const dt = last ? Math.min(0.032, (now - last) / 1000) : 1 / 60;
      last = now;

      const friction = braking ? FRICTION_BRAKE : FRICTION_FREE;
      v *= Math.exp(-friction * dt);
      angle += v * dt;
      pull = pull > 0.02 ? pull * Math.exp(-PULL_RETURN * dt) : 0;
      writeTransforms(angle, pull);

      const settleReady = braking && Math.abs(v) < SLEEP_OMEGA;
      const forceSettle = now > deadline;
      if (settleReady || forceSettle) {
        v = 0;
        pull = 0;
        writeTransforms(angle, 0);
        const resolve = pendingResolve;
        pendingResolve = null;
        resolve?.();
        return;
      }
      raf = requestAnimationFrame(loop);
    };

    const startFlight = (releaseVelocity: number) => {
      v = releaseVelocity;
      braking = false;
      pendingResolve = null;
      deadline = performance.now() + FORCE_SETTLE_MS;
      setPhase("busy");
      setLiveText("Refreshing…");
      last = 0;
      raf = requestAnimationFrame(loop);

      const myGen = ++genRef.current;
      const req = onRefreshRef.current
        ? onRefreshRef.current()
        : simulateRefresh();

      const finish = (result: FlywheelPullItem[] | null, failed: boolean) => {
        if (myGen !== genRef.current) return; // superseded by a later refresh
        // response arrived — raise friction, wait for the loop to actually
        // settle, then commit rows exactly as the wheel comes to rest.
        braking = true;
        pendingResolve = () => applyResultRef.current(result, failed);
      };
      req.then(
        (result) => finish(result, false),
        () => finish(null, true)
      );
    };

    const springBackIdle = () => {
      last = 0;
      const idleLoop = (now: number) => {
        raf = 0;
        const dt = last ? Math.min(0.032, (now - last) / 1000) : 1 / 60;
        last = now;
        pull *= Math.exp(-PULL_RETURN * dt);
        writeTransforms(angle, pull);
        if (pull > 0.05) raf = requestAnimationFrame(idleLoop);
        else {
          pull = 0;
          writeTransforms(angle, 0);
        }
      };
      raf = requestAnimationFrame(idleLoop);
    };

    const onPointerDown = (e: PointerEvent) => {
      if (e.button !== 0) return;
      // grabs the wheel even mid-freewheel/mid-brake — catching it and
      // winding again always wins over whatever was already in flight; a
      // stale response is dropped by the genRef check in `finish`.
      dragging = true;
      pointerId = e.pointerId;
      startY = e.clientY;
      baseAngle = angle;
      pull = 0;
      v = 0;
      braking = false;
      pendingResolve = null;
      // a grab always supersedes whatever request was in flight — otherwise a
      // late response with no loop left to resolve it (e.g. grab then release
      // under the 4px threshold, no re-flight started) would strand `phase`
      // on "busy" forever. Reset unconditionally (React bails out a same-value
      // set) rather than reading `phase`, which is stale in this mount-once
      // closure.
      genRef.current++;
      setPhase("idle");
      zone.style.cursor = "grabbing";
      try {
        zone.setPointerCapture(e.pointerId);
      } catch {
        /* synthetic or already-gone pointer */
      }
      cancelAnimationFrame(raf);
      raf = 0;
      if (!reduced) writeTransforms(angle, 0);
    };

    const onPointerMove = (e: PointerEvent) => {
      if (!dragging || e.pointerId !== pointerId) return;
      const rawDy = e.clientY - startY;
      const dy = rawDy < 0 ? 0 : rawDy;
      pull = dy <= MAX_PULL ? dy : MAX_PULL + (dy - MAX_PULL) * RUBBER;
      if (reduced) return;
      angle = baseAngle + pull * WIND_DEG_PER_PX;
      writeTransforms(angle, pull);
    };

    const onPointerUp = (e: PointerEvent) => {
      if (e.pointerId !== pointerId) return;
      zone.style.cursor = "";
      if (!dragging) return;
      dragging = false;
      pointerId = -1;
      const finalPull = pull;

      if (reduced) {
        if (finalPull >= 4) {
          setPhase("busy");
          setLiveText("Refreshing…");
          const myGen = ++genRef.current;
          const req = onRefreshRef.current
            ? onRefreshRef.current()
            : simulateRefresh();
          req.then(
            (result) => {
              if (myGen === genRef.current) applyResultRef.current(result, false);
            },
            () => {
              if (myGen === genRef.current) applyResultRef.current(null, true);
            }
          );
        }
        pull = 0;
        return;
      }

      if (finalPull < 4) {
        springBackIdle();
        return;
      }
      startFlight(finalPull * RELEASE_GAIN);
    };

    const onPointerCancel = (e: PointerEvent) => onPointerUp(e);

    zone.addEventListener("pointerdown", onPointerDown);
    zone.addEventListener("pointermove", onPointerMove);
    zone.addEventListener("pointerup", onPointerUp);
    zone.addEventListener("pointercancel", onPointerCancel);

    return () => {
      cancelAnimationFrame(raf);
      zone.removeEventListener("pointerdown", onPointerDown);
      zone.removeEventListener("pointermove", onPointerMove);
      zone.removeEventListener("pointerup", onPointerUp);
      zone.removeEventListener("pointercancel", onPointerCancel);
    };
  }, []);

  const handleButtonRefresh = useCallback(() => {
    // always restarts the cycle, even mid-flight — a second press is a
    // fresh yank on the crank, not a no-op; see genRef in the physics effect.
    if (reducedRef.current) {
      setPhase("busy");
      setLiveText("Refreshing…");
      const myGen = ++genRef.current;
      const req = onRefreshRef.current
        ? onRefreshRef.current()
        : simulateRefresh();
      req.then(
        (result) => {
          if (myGen === genRef.current) applyResultRef.current(result, false);
        },
        () => {
          if (myGen === genRef.current) applyResultRef.current(null, true);
        }
      );
      return;
    }

    // dispatch a synthetic pull onto the drag zone so a plain click runs the
    // exact same wind -> release -> freewheel -> brake physics as a real drag.
    const zone = zoneRef.current;
    if (!zone) return;
    const rect = zone.getBoundingClientRect();
    const cx = rect.left + rect.width / 2;
    const top = rect.top + 8;
    const pid = -999;
    const opts = { pointerId: pid, clientX: cx, button: 0, bubbles: true };
    zone.dispatchEvent(new PointerEvent("pointerdown", { ...opts, clientY: top }));
    zone.dispatchEvent(
      new PointerEvent("pointermove", { ...opts, clientY: top + BUTTON_WIND_PX })
    );
    zone.dispatchEvent(
      new PointerEvent("pointerup", { ...opts, clientY: top + BUTTON_WIND_PX })
    );
  }, []);

  const busy = phase === "busy";
  const statusText =
    phase === "idle" ? "Pull the wheel down, or press Refresh." : liveText;

  return (
    <div
      className={`w-full max-w-md rounded-md border border-border bg-background ${className}`}
    >
      <div
        ref={zoneRef}
        role="presentation"
        aria-hidden="true"
        className="flex touch-none select-none items-center justify-center py-3"
        style={{ cursor: "grab" }}
      >
        <svg
          viewBox="0 0 240 160"
          width="180"
          height="120"
          className="max-w-full"
        >
          <defs>
            <clipPath id="fw-rack-clip">
              <rect x={RACK_X} y={RACK_Y} width={RACK_W} height={RACK_H} />
            </clipPath>
          </defs>

          {/* rack — vertical bar; teeth slide behind the window as you pull */}
          <rect
            x={RACK_X}
            y={RACK_Y}
            width={RACK_W}
            height={RACK_H}
            rx={3}
            fill="none"
            stroke="var(--border)"
            strokeWidth={2}
          />
          <g clipPath="url(#fw-rack-clip)">
            <g ref={rackTeethRef}>
              {Array.from({ length: 14 }, (_, i) => (
                <rect
                  key={i}
                  x={RACK_X + 2}
                  y={RACK_Y - TOOTH_PITCH * 3 + i * TOOTH_PITCH}
                  width={RACK_W - 8}
                  height={5}
                  fill="var(--border)"
                />
              ))}
            </g>
          </g>

          {/* pinion — small gear meshed with the rack, shares the drive angle */}
          <g ref={pinionGroupRef}>
            <circle
              cx={CX_PINION}
              cy={CY_PINION}
              r={R_PINION}
              fill="var(--background)"
              stroke="var(--border)"
              strokeWidth={2}
            />
            {Array.from({ length: 8 }, (_, i) => {
              const a = (i * 360) / 8;
              const rad = (a * Math.PI) / 180;
              const x1 = CX_PINION + Math.cos(rad) * (R_PINION - 1);
              const y1 = CY_PINION + Math.sin(rad) * (R_PINION - 1);
              const x2 = CX_PINION + Math.cos(rad) * (R_PINION + 4);
              const y2 = CY_PINION + Math.sin(rad) * (R_PINION + 4);
              return (
                <line
                  key={i}
                  x1={x1}
                  y1={y1}
                  x2={x2}
                  y2={y2}
                  stroke="var(--border)"
                  strokeWidth={2}
                />
              );
            })}
          </g>

          {/* shaft — couples pinion and wheel onto one drive axle */}
          <line
            x1={CX_PINION + R_PINION + 4}
            y1={CY_PINION}
            x2={CX_WHEEL - R_WHEEL + 6}
            y2={CY_WHEEL}
            stroke="var(--border)"
            strokeWidth={2}
            strokeDasharray="2 5"
          />

          {/* flywheel — spokes in --border, one index spoke in --foreground */}
          <g ref={wheelGroupRef}>
            <circle
              cx={CX_WHEEL}
              cy={CY_WHEEL}
              r={R_WHEEL}
              fill="none"
              stroke="var(--border)"
              strokeWidth={3}
            />
            {Array.from({ length: SPOKES }, (_, i) => {
              const a = (i * 360) / SPOKES;
              const rad = (a * Math.PI) / 180;
              const inner = 10;
              const outer = R_WHEEL - 5;
              const x1 = CX_WHEEL + Math.cos(rad) * inner;
              const y1 = CY_WHEEL + Math.sin(rad) * inner;
              const x2 = CX_WHEEL + Math.cos(rad) * outer;
              const y2 = CY_WHEEL + Math.sin(rad) * outer;
              const isIndex = i === 0;
              return (
                <line
                  key={i}
                  x1={x1}
                  y1={y1}
                  x2={x2}
                  y2={y2}
                  stroke={isIndex ? "var(--foreground)" : "var(--border)"}
                  strokeWidth={isIndex ? 4 : 2.5}
                  strokeLinecap="round"
                />
              );
            })}
            <circle
              cx={CX_WHEEL}
              cy={CY_WHEEL - (R_WHEEL - 5)}
              r={3}
              fill="var(--foreground)"
            />
            <circle
              cx={CX_WHEEL}
              cy={CY_WHEEL}
              r={9}
              fill="var(--background)"
              stroke="var(--border)"
              strokeWidth={2}
            />
          </g>
        </svg>
      </div>

      <div className="flex items-center gap-3 border-t border-b border-border px-4 py-3">
        <button
          type="button"
          data-flywheel-refresh
          onClick={handleButtonRefresh}
          className="inline-flex shrink-0 items-center gap-2 rounded-sm border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground outline-none transition-colors hover:border-foreground/25 focus-visible:ring-2 focus-visible:ring-accent"
        >
          <svg
            aria-hidden="true"
            viewBox="0 0 16 16"
            width="14"
            height="14"
            className={busy ? "animate-spin" : ""}
            style={{ animationDuration: "900ms" }}
          >
            <path
              d="M13.5 8a5.5 5.5 0 1 1-1.65-3.93M13.5 2v3.5H10"
              fill="none"
              stroke="currentColor"
              strokeWidth={1.4}
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
          {busy ? "Refreshing…" : "Refresh"}
        </button>
        <p
          data-flywheel-status={phase}
          className="min-w-0 flex-1 truncate font-mono text-xs text-muted"
        >
          {statusText}
        </p>
      </div>

      <div role="status" aria-live="polite" className="sr-only">
        {liveText}
      </div>

      <ul
        aria-label={label}
        aria-busy={busy}
        className="max-h-72 divide-y divide-border overflow-y-auto"
      >
        {items.map((item) => {
          const entering = !reducedRef.current && enteringOrder.includes(item.id);
          return (
            <li
              key={item.id}
              ref={(el) => {
                if (el) rowRefs.current.set(item.id, el);
                else rowRefs.current.delete(item.id);
              }}
              className={`flex items-center justify-between gap-3 px-4 py-3 ${
                entering ? "ns-fw-enter" : ""
              }`}
              style={
                entering
                  ? { animationDelay: `${enteringOrder.indexOf(item.id) * ENTER_STAGGER_MS}ms` }
                  : undefined
              }
            >
              <span className="min-w-0 flex-1 truncate text-sm text-foreground">
                {item.title}
              </span>
              {item.meta && (
                <span className="shrink-0 font-mono text-xs text-muted">
                  {item.meta}
                </span>
              )}
            </li>
          );
        })}
      </ul>

      <style>{`
        @keyframes ns-fw-enter {
          from { opacity: 0; transform: translateY(-10px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .ns-fw-enter {
          animation: ns-fw-enter ${ENTER_DURATION_MS}ms cubic-bezier(.22,1,.36,1) both;
        }
        @media (prefers-reduced-motion: reduce) {
          .ns-fw-enter { animation: none; }
        }
      `}</style>
    </div>
  );
}
Build spec

A pull-to-refresh mechanism built as a real crank and flywheel instead of a threshold-triggered spinner swap. A decorative (aria-hidden) SVG assembly sits above the feed: a vertical rack (a clipped window of teeth), a small pinion gear meshed against it, a drive shaft, and a large spoked flywheel — eight spokes in var(--border), one index spoke in var(--foreground) so a single rotation is countable frame to frame. Dragging down on the wheel zone translates the rack's teeth 1:1 with pull distance and winds the wheel and pinion by pullPx * 1.05deg, clamped to a 130px pull with rubber-band resistance (0.32x) beyond that. Releasing hands the wheel an angular velocity of pullDistance * 1.65 deg/s, integrated per requestAnimationFrame with low friction (exp(-0.55/s) decay) for as long as the refresh request is in flight — this is the loading state: on a slow connection the wheel visibly bleeds off an overcommitted yank's energy before the response ever lands, an outcome that is inspectable rather than hidden behind a canned spinner. The moment the request resolves, friction jumps to 10/s so the wheel decays to rest in roughly 400ms; committing the new rows is deferred until the wheel actually stops (or a 6s forced-settle deadline, so physics can never spin forever), so the settle and the content arriving read as one event. New rows are prepended with a lightweight FLIP: existing rows' pre-update positions are captured via getBoundingClientRect, then any that shifted animate from their old offset back to rest (420ms cubic-bezier), while the freshly prepended rows fade/slide in individually staggered 70ms apart, finishing around the same moment the flywheel's index spoke stops ticking. Grabbing the wheel mid-freewheel or mid-brake is legal — the crank always yields to a fresh yank, cancelling the coast and re-winding from wherever it was, and a stale in-flight response that resolves after being superseded is silently dropped rather than double-committing rows. A visible Refresh button is always present as the primary path (the drag is enhancement, never a requirement): clicking it dispatches a synthetic 96px pull onto the same drag-zone listeners, so the button and the gesture share one code path and one physics, not a separate instant fetch. onRefresh is an optional prop returning a Promise of new rows for a real feed to supply; omitted, a built-in 900-1500ms simulated request manufactures a handful of sample rows so the mechanism is demonstrable standalone. Accessibility: the wheel assembly is aria-hidden (decorative only); the list region is aria-busy while a request is in flight; a role=status aria-live=polite region announces 'Refreshing…' then 'Updated, N new items' (or a failure message, which still brakes the wheel to rest rather than leaving it spinning); a small visible status line beside the button carries the same text for sighted users, not just screen readers. prefers-reduced-motion drops the wind/freewheel/brake choreography and the row FLIP entirely: the wheel stays at a static, always-legible index-spoke position, dragging past a 4px threshold or clicking Refresh goes straight from idle to aria-busy to the resolved list with no rotation, and rows appear without motion. Zero dependencies, DOM+SVG+CSS only (no canvas), every color a CSS custom property (--background --foreground --muted --border), --accent reserved for the focus ring. Distinct from status-glyph-cadence: status-glyph-cadence is an inline status glyph encoding five agent states in idle cadence; refresh-pull-flywheel is a feed's primary refresh control where the loading indicator IS conserved gesture energy, not a state-cadence signal — reach for refresh-pull-flywheel specifically when the loading phase should feel mechanical and its duration should read as a physical consequence of how hard the user pulled.

Tags
refreshpull-to-refreshfeedphysicsgesturesvgloadingaria-live