{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pull-to-refresh",
  "title": "Pull To Refresh",
  "description": "A list that reloads when it is pulled down from the top, the way a native app does, plus a Refresh button that always does the same job. The indicator follows the finger one-to-one until releasing would refresh — so \"release to refresh\" appears exactly when the finger has gone the distance the label implies — then tapers towards a ceiling it never reaches, so the list cannot be dragged off the bottom of the screen. Reach for it on a mobile web list people expect to behave like an app: a PWA's inbox or feed, a notifications or activity list, chat or message threads, an orders or deliveries screen, stock and price panels that go stale while they are looked at, a dashboard on a phone, a queue or ticket list a support agent keeps checking, and search results after the network came back. Common asks it answers: \"pull to refresh react\", \"pull to refresh web\", \"swipe down to refresh\", \"pull down to reload a list\", \"pull to refresh shadcn\", \"pull to refresh without a library\", \"react-simple-pull-to-refresh alternative\", \"react-pull-to-refresh alternative\", \"pwa pull to refresh\", \"mobile web refresh gesture\", \"disable the browser's own pull to refresh\", \"two spinners when I pull to refresh on android\", \"overscroll-behavior contain\", \"Unable to preventDefault inside passive event listener invocation\", \"onTouchMove preventDefault not working in react\", \"pull to refresh broken on ios\", \"ios rubber band scrollTop negative\", \"pull to refresh fires while the list is still scrolling\", \"accessible pull to refresh\", \"refresh a list without a pull gesture\". Official shadcn/ui has nothing for this, and nothing to build it out of: touchstart, touchmove, touchend, touchcancel, TouchEvent, clientY, pullDistance, refreshing, onRefresh and overscroll-behavior are each zero hits across all 62 of its fetchable items (measured 2026-09-27) — it does not handle a touch anywhere. The two overscroll hits are both Tailwind classes and neither is a refresh: overscroll-x-contain on the attachment strip, and overscroll-contain on message-scroller, a chat log that autoscrolls downward and reads no gesture at all. Distinct from the pulld pieces it sits beside: infinite-scroll is the bottom of the same list and loads the next page, this is the top of it and replaces the first; scroll-progress reports how far down the page has gone; scroll-shadow says there is more to see sideways; network-status says the connection dropped, and this is what someone does about it once it is back; save-status reports writes going out rather than reads coming in. Six things separate it from the version built out of onTouchMove and a clientY subtraction. First, the scroller is this component's own element, so overscroll-behavior-y: contain is not something a consumer has to remember: without it Chrome on Android runs its own pull-to-refresh over the top of this one and a single gesture shows two spinners. It is contain and not none, so the rubber-band bounce that tells people they have reached the end survives. Second, touchmove is registered by hand with passive: false, because React registers onTouchMove passively and a passive listener's preventDefault is dropped with a console warning — the indicator comes down and the browser overscrolls behind it, and no prop changes that. touchstart, touchend and touchcancel stay React props precisely because none of them ever has to prevent anything. Third, a negative scrollTop is refused rather than welcomed: it is iOS saying it is already sliding the content down, and a translate added on top of its bounce moves the list twice as far as the finger. Whether a gesture may pull at all is settled once, at touchstart, and thereafter only ever taken away — an implementation that asks whether scrollTop is 0 on every move hands out pulls in the middle of scrolls, because a list gliding to a halt passes through 0 with a steadying finger already resting on it. Being at rest is measured as time since the last scroll event, which is the only test that works in engines that hold scrollTop at 0 throughout their bounce. \"At the top\" is a one-pixel window, not a comparison against zero, because browser zoom and fractional device pixel ratios park a scroller at 0.5. Fourth, the gestures that are not pulls are each turned away: a second finger (a pinch-zoom starting inside the list), a sideways drag (a carousel or a swipeable row — ties go to horizontal, since refusing a pull costs one more try while stealing a swipe breaks what is under the finger), a drag upward, which ends the gesture rather than waiting at zero for the finger to drift back down and become a pull mid-scroll, and a move the browser has already marked non-cancelable. An axis once decided is never re-decided, so the arc a thumb actually describes does not kill the pull half way down. Fifth, the gesture is touch-only, and to a keyboard, a switch, a desktop mouse or a screen reader a list whose sole way to reload is a pull cannot be reloaded: so a Refresh button is always rendered, it carries aria-disabled rather than disabled so pressing it never drops focus out of the list, every stage is announced through a polite live region that is in the DOM before it has anything to say, the scroller gets aria-busy, and the indicator itself is aria-hidden because it is a drawing of where a finger is. Sixth, it cannot get stuck: one refresh runs at a time whether it came from the gesture or the button, a rejected promise and a loader that throws synchronously both announce the failure and let the next attempt through instead of leaving the spinner turning for the session, a touchcancel — an incoming call, the system's own edge gesture — closes without refreshing rather than sharing the release handler and refreshing on an interruption nobody released, and nothing is set after unmount. Controlled by one prop: pass onRefresh and give the scroller a height with scrollClassName. Optional threshold (default 64px), maxDistance, disabled, hideButton for callers supplying their own control, and labels to reword or translate every string. Styled with shadcn tokens so it follows light and dark themes and honours prefers-reduced-motion; lucide-react is the only dependency, with no Radix, no gesture library and no react-dom import.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/pull-to-refresh.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, Loader2, RefreshCw } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** How far the finger has to travel before releasing refreshes, in CSS pixels. */\nexport const DEFAULT_PULL_THRESHOLD = 64\n\n/**\n * How far past the threshold the indicator can still be dragged, as a multiple of the threshold.\n * The pull tapers towards this and never reaches it, so the list cannot be dragged off the screen.\n */\nexport const DEFAULT_MAX_DISTANCE_RATIO = 2\n\n/**\n * \"At the top\" is a window rather than `scrollTop === 0`.\n *\n * Browser zoom and fractional device pixel ratios both put a scroller at 0.5, and a list that will\n * not refresh at certain zoom levels is the bug that comes out of comparing against zero exactly.\n */\nexport const SCROLL_TOP_EPSILON = 1\n\n/**\n * How long the scroller has to have been still before a touch may start a pull, in milliseconds.\n *\n * This is the guard against the gesture that has no business being a pull: a flick that is still\n * gliding to a stop, with a finger put back down to arrest it. Momentum fires `scroll` the whole\n * way, so \"when did this last move\" answers the question that `scrollTop` cannot.\n */\nexport const SETTLE_MS = 120\n\n/** How far the finger has to move before the gesture commits to an axis, in CSS pixels. */\nexport const AXIS_SLOP = 8\n\n/**\n * The distance the indicator shows for `raw` pixels of finger travel.\n *\n * One-to-one up to the threshold, because that is the only point in the gesture where the number\n * means something to the person making it: the indicator has to reach \"release to refresh\" exactly\n * when the finger has gone as far as the label implies. Past it the pull tapers — every further\n * pixel gives less than the one before — so the list cannot be dragged an arbitrary distance down\n * the screen, and the gesture feels like it is pulling against something rather than sliding.\n *\n * The taper is `extra / (extra + room)`: smooth, monotonic, asymptotic to `maxDistance` without\n * ever reaching it, and no exponential to reason about.\n */\nexport function resistedDistance(\n  raw: number,\n  threshold: number,\n  maxDistance: number\n): number {\n  // Written as `raw > 0` rather than `raw <= 0` so a NaN — a clientY read from a touch that has\n  // already ended is the way one gets here — collapses to zero instead of propagating through the\n  // arithmetic and into a `translateY(NaNpx)` that silently drops the transform.\n  if (!(raw > 0)) return 0\n  if (raw <= threshold) return raw\n  const room = Math.max(0, maxDistance - threshold)\n  if (room === 0) return threshold\n  const extra = raw - threshold\n  return threshold + (room * extra) / (extra + room)\n}\n\n/**\n * Whether a touch landing now may begin a pull: the scroller is at the top **and at rest**.\n *\n * Both halves matter, and the obvious `scrollTop === 0` has neither.\n *\n * A negative `scrollTop` is not \"even more at the top than zero\" — it is the tell that the browser\n * is part-way through a rubber-band bounce and is already sliding the content down itself. Adding a\n * translate on top of that counts one pull twice and the list leaps away from the finger. iOS\n * reports it; other engines hold at 0 through the same bounce, which is why the second half of the\n * test cannot be a scroll position at all. `msSinceScroll` covers both: a bounce that moves the\n * content fires `scroll` whether or not the number it reports goes below zero.\n *\n * What is left over, honestly: an engine that neither reports a negative `scrollTop` nor fires\n * `scroll` during its bounce is indistinguishable from one at rest here, and a pull started then\n * will double up with the bounce. The threshold is what keeps that from becoming a refresh — a\n * finger put down to stop a list is not travelling 64 further pixels.\n */\nexport function canBeginPull(scrollTop: number, msSinceScroll: number): boolean {\n  if (scrollTop <= -SCROLL_TOP_EPSILON) return false\n  if (scrollTop >= SCROLL_TOP_EPSILON) return false\n  return msSinceScroll >= SETTLE_MS\n}\n\n/**\n * Whether a pull in progress has to be given up because the scroller moved down under it.\n *\n * Asked on every move — but only ever to take the pull away, never to grant one. That asymmetry is\n * the whole of it: an implementation that instead asks \"is `scrollTop` 0?\" on each move hands out\n * pulls in the middle of scrolls, because a list gliding to a halt passes through 0 with the finger\n * already resting on it. Whether a gesture is allowed to pull at all is settled once, at touchstart,\n * by `canBeginPull`.\n */\nexport function hasScrolledAway(scrollTop: number): boolean {\n  return scrollTop > SCROLL_TOP_EPSILON\n}\n\n/** Which way a gesture has turned out to be going. */\nexport type PullAxis = \"undecided\" | \"vertical\" | \"horizontal\"\n\n/**\n * Decides the axis once the finger has left the slop circle.\n *\n * Ties go to horizontal on purpose. A drag that is exactly as sideways as it is downward is far\n * more likely to be someone starting on a carousel or a swipeable row inside the list than someone\n * pulling the list itself, and refusing the pull costs them one more try while stealing the swipe\n * breaks the thing they were touching.\n */\nexport function decideAxis(dx: number, dy: number, slop: number = AXIS_SLOP): PullAxis {\n  const ax = Math.abs(dx)\n  const ay = Math.abs(dy)\n  if (ax < slop && ay < slop) return \"undecided\"\n  return ay > ax ? \"vertical\" : \"horizontal\"\n}\n\n/** One touch sequence, from the finger landing to it lifting. */\nexport interface PullGesture {\n  originX: number\n  originY: number\n  axis: PullAxis\n  /** Resisted pixels the indicator is currently showing. */\n  distance: number\n  /** Once true, nothing later in this same touch sequence can pull. */\n  abandoned: boolean\n}\n\nexport function beginGesture(x: number, y: number): PullGesture {\n  return { originX: x, originY: y, axis: \"undecided\", distance: 0, abandoned: false }\n}\n\n/** What `advanceGesture` needs from a `touchmove`. */\nexport interface PullMove {\n  x: number\n  y: number\n  scrollTop: number\n  /** `event.touches.length`. A second finger means a pinch, not a pull. */\n  touchCount: number\n  /**\n   * `event.cancelable`. False once the browser has committed this sequence to scrolling, after\n   * which `preventDefault` does nothing but log a warning.\n   */\n  cancelable: boolean\n}\n\nexport interface PullMoveResult {\n  gesture: PullGesture\n  /** True when the caller has to call `preventDefault()` on this event. */\n  claim: boolean\n}\n\nconst abandoned = (gesture: PullGesture): PullMoveResult => ({\n  gesture: { ...gesture, distance: 0, abandoned: true },\n  claim: false,\n})\n\n/**\n * Folds one `touchmove` into the gesture, and says whether the event has to be claimed.\n *\n * Every `return abandoned(...)` here is a gesture this component deliberately does not treat as a\n * pull, and each one is a way the three-line version misbehaves: a pinch-zoom that opens the\n * indicator, a horizontal swipe on a row inside the list that refreshes it, a scroll upward that\n * becomes a pull the moment the finger crosses back down over where it started, and a pull drawn on\n * top of a native overscroll the browser has already taken charge of.\n */\nexport function advanceGesture(\n  gesture: PullGesture,\n  move: PullMove,\n  { threshold, maxDistance }: { threshold: number; maxDistance: number }\n): PullMoveResult {\n  if (gesture.abandoned) return { gesture, claim: false }\n  if (move.touchCount > 1) return abandoned(gesture)\n  if (hasScrolledAway(move.scrollTop)) return abandoned(gesture)\n\n  const dx = move.x - gesture.originX\n  const dy = move.y - gesture.originY\n\n  let axis = gesture.axis\n  if (axis === \"undecided\") {\n    axis = decideAxis(dx, dy)\n    // Still inside the slop: nothing decided, nothing claimed, and crucially nothing abandoned —\n    // the same sequence gets to try again on the next move.\n    if (axis === \"undecided\") return { gesture, claim: false }\n    if (axis === \"horizontal\") return abandoned(gesture)\n  }\n\n  // Back at or above where the finger landed. The list is being scrolled, so the pull is over —\n  // given up rather than held at zero, because holding it at zero lets a scroll turn into a pull\n  // the instant the finger wanders back down, which is the same bug as testing the scroll position\n  // on every move.\n  if (dy <= 0) return abandoned(gesture)\n\n  // The browser decides whether a touch sequence still belongs to the page or to us, and once it\n  // has chosen the page this flag is how it says so. Carrying on would draw an indicator over the\n  // top of a native overscroll that cannot be stopped.\n  if (!move.cancelable) return abandoned(gesture)\n\n  return {\n    gesture: { ...gesture, axis, distance: resistedDistance(dy, threshold, maxDistance) },\n    claim: true,\n  }\n}\n\n/** Whether releasing at this distance refreshes. */\nexport function isArmed(distance: number, threshold: number): boolean {\n  return distance >= threshold\n}\n\n/** Every string a user reads or hears. Override to translate or to reword. */\nexport interface PullToRefreshLabels {\n  /** The visible button, and its accessible name. */\n  refresh: string\n  /** In the indicator while the finger is still short of the threshold. */\n  pull: string\n  /** In the indicator once releasing would refresh. */\n  release: string\n  /** In the indicator and announced while a refresh is in flight. */\n  refreshing: string\n  /** Announced once a refresh finishes — the only feedback a screen-reader user gets. */\n  refreshed: string\n  /** Announced when `onRefresh` rejects. */\n  error: string\n}\n\nconst defaultLabels: PullToRefreshLabels = {\n  refresh: \"Refresh\",\n  pull: \"Pull to refresh\",\n  release: \"Release to refresh\",\n  refreshing: \"Refreshing…\",\n  refreshed: \"List updated.\",\n  error: \"Couldn't refresh.\",\n}\n\nexport interface PullToRefreshProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"onTouchStart\" | \"onTouchEnd\" | \"onTouchCancel\" | \"children\"\n  > {\n  /**\n   * Reload the list. Return the promise and the indicator stays up until it settles; a rejection is\n   * caught here and announced rather than leaving the spinner running forever.\n   *\n   * Called at most once at a time. A second pull, or the button, while one is in flight does\n   * nothing — so this does not have to be idempotent.\n   */\n  onRefresh: () => void | Promise<unknown>\n  /**\n   * The list. It is laid out inside the scroller this renders, so give that scroller a height with\n   * `scrollClassName` (`h-dvh`, `h-96`, `flex-1`) — without one it grows to fit and never scrolls,\n   * and a box that never scrolls is never at its top.\n   */\n  children: React.ReactNode\n  /** Finger travel that arms the refresh, in CSS pixels (default 64). */\n  threshold?: number\n  /** How far the pull can taper to, in CSS pixels (default `threshold * 2`). */\n  maxDistance?: number\n  /** Turns the gesture off and puts the button into `aria-disabled`. */\n  disabled?: boolean\n  /**\n   * Drop the button.\n   *\n   * Only pass this alongside your own control wired to the same `onRefresh`. The gesture is touch\n   * only: to a keyboard, a switch, a screen reader or a desktop mouse, a list whose sole way to\n   * reload is a pull cannot be reloaded at all.\n   */\n  hideButton?: boolean\n  /** Classes for the scrolling box. Its height belongs here. */\n  scrollClassName?: string\n  labels?: Partial<PullToRefreshLabels>\n}\n\n/**\n * A list that reloads when it is pulled down from the top, with a button that always does the same\n * job.\n *\n * The scroller is this component's own element so that `overscroll-behavior-y: contain` is not\n * something a consumer has to remember: without it Chrome on Android runs its own pull-to-refresh\n * over the top of this one and the page shows two spinners for one gesture.\n */\nexport const PullToRefresh = React.forwardRef<HTMLDivElement, PullToRefreshProps>(\n  function PullToRefresh(\n    {\n      onRefresh,\n      children,\n      threshold = DEFAULT_PULL_THRESHOLD,\n      maxDistance,\n      disabled = false,\n      hideButton = false,\n      scrollClassName,\n      labels,\n      className,\n      ...props\n    },\n    ref\n  ) {\n    const text = { ...defaultLabels, ...labels }\n    const ceiling = maxDistance ?? threshold * DEFAULT_MAX_DISTANCE_RATIO\n\n    const scrollRef = React.useRef<HTMLDivElement | null>(null)\n    const [distance, setDistance] = React.useState(0)\n    const [refreshing, setRefreshing] = React.useState(false)\n    const [message, setMessage] = React.useState(\"\")\n    /** True only while a finger is down and pulling, which is when the transform must not animate. */\n    const [dragging, setDragging] = React.useState(false)\n\n    const gestureRef = React.useRef<PullGesture | null>(null)\n    /** Guards re-entry: one refresh at a time, whether it came from the gesture or the button. */\n    const busyRef = React.useRef(false)\n    const mountedRef = React.useRef(true)\n    React.useEffect(\n      () => () => {\n        mountedRef.current = false\n      },\n      []\n    )\n\n    /**\n     * When the scroller last moved. `canBeginPull` reads it to tell a list at rest from one still\n     * gliding; see the note there for why a scroll position cannot answer that on its own.\n     *\n     * Kept in a ref and stamped from the event, so a scroll costs nothing but an assignment — a\n     * momentum scroll fires this every frame, and re-rendering the list on each one would be worse\n     * than the bug it is here to prevent.\n     */\n    const lastScrollAtRef = React.useRef(-Infinity)\n    const onScroll = React.useCallback(() => {\n      lastScrollAtRef.current = Date.now()\n    }, [])\n\n    const run = React.useCallback(() => {\n      if (busyRef.current || disabled) return\n      busyRef.current = true\n      setRefreshing(true)\n      setMessage(text.refreshing)\n      const settle = (announcement: string) => {\n        busyRef.current = false\n        if (!mountedRef.current) return\n        setRefreshing(false)\n        setDistance(0)\n        setMessage(announcement)\n      }\n      try {\n        const result = onRefresh()\n        if (result && typeof (result as PromiseLike<unknown>).then === \"function\") {\n          Promise.resolve(result).then(\n            () => settle(text.refreshed),\n            () => settle(text.error)\n          )\n          return\n        }\n      } catch {\n        // A loader that throws synchronously is the same failure as one that rejects, and leaving\n        // `busyRef` set here would wedge the component for good.\n        settle(text.error)\n        return\n      }\n      // Nothing to wait on. The refresh has already happened as far as this component can tell.\n      settle(text.refreshed)\n    }, [disabled, onRefresh, text.error, text.refreshed, text.refreshing])\n\n    /**\n     * The finger lifted. This is the only path that may refresh.\n     */\n    const onTouchEnd = React.useCallback(() => {\n      const gesture = gestureRef.current\n      gestureRef.current = null\n      setDragging(false)\n      // Closed unconditionally, before deciding whether to refresh. `run` holds the indicator open\n      // by way of `refreshing`, so an armed release looks the same either way — but `run` also\n      // *declines*, when the component was disabled or a refresh began between the finger landing\n      // and it lifting, and returning early on that path left the list translated down with\n      // \"Release to refresh\" showing and no further touch coming to clear it.\n      setDistance(0)\n      if (gesture && !gesture.abandoned && isArmed(gesture.distance, threshold)) run()\n    }, [run, threshold])\n\n    /**\n     * The touch was taken away — an incoming call, the system's own edge gesture, a finger sliding\n     * off the digitiser. Deliberately *not* the same handler as `onTouchEnd`: the list is far enough\n     * down to be armed at that moment, and sharing one handler refreshes on an interruption the\n     * person never released. It has to close without refreshing, and without leaving the indicator\n     * hanging open, since no `touchend` is coming.\n     */\n    const onTouchCancel = React.useCallback(() => {\n      gestureRef.current = null\n      setDragging(false)\n      setDistance(0)\n    }, [])\n\n    const onTouchStart = React.useCallback(\n      (event: React.TouchEvent<HTMLDivElement>) => {\n        gestureRef.current = null\n        if (disabled || busyRef.current) return\n        if (event.touches.length !== 1) return\n        const scroller = scrollRef.current\n        const scrollTop = scroller?.scrollTop ?? 0\n        if (!canBeginPull(scrollTop, Date.now() - lastScrollAtRef.current)) return\n        const touch = event.touches[0]\n        gestureRef.current = beginGesture(touch.clientX, touch.clientY)\n      },\n      [disabled]\n    )\n\n    /**\n     * `touchmove` is attached by hand, with `passive: false`.\n     *\n     * React registers its own `onTouchMove` passively, and `preventDefault` on a passive listener is\n     * dropped with a console warning — so the indicator would come down *and* the browser would\n     * overscroll behind it. No prop changes that; the listener has to be added here. `touchstart`,\n     * `touchend` and `touchcancel` stay React props precisely because none of them ever has to\n     * prevent anything.\n     *\n     * Registered once, against a handler kept in a ref and replaced each render, so a pull does not\n     * re-subscribe the scroller sixty times a second.\n     */\n    const moveHandler = React.useRef<(event: TouchEvent) => void>(() => {})\n    React.useEffect(() => {\n      moveHandler.current = (event: TouchEvent) => {\n        const gesture = gestureRef.current\n        if (!gesture) return\n        const touch = event.touches[0]\n        const { gesture: next, claim } = advanceGesture(\n          gesture,\n          {\n            x: touch?.clientX ?? gesture.originX,\n            y: touch?.clientY ?? gesture.originY,\n            scrollTop: scrollRef.current?.scrollTop ?? 0,\n            touchCount: event.touches.length,\n            cancelable: event.cancelable,\n          },\n          { threshold, maxDistance: ceiling }\n        )\n        gestureRef.current = next\n        if (claim) event.preventDefault()\n        setDragging(claim)\n        setDistance(next.distance)\n      }\n    })\n    React.useEffect(() => {\n      const scroller = scrollRef.current\n      if (!scroller?.addEventListener) return\n      const listener = (event: TouchEvent) => moveHandler.current(event)\n      scroller.addEventListener(\"touchmove\", listener, { passive: false })\n      return () => scroller.removeEventListener(\"touchmove\", listener)\n    }, [])\n\n    const armed = isArmed(distance, threshold)\n    // Held open at the threshold while the request is out, so the spinner has somewhere to sit\n    // instead of the list snapping shut over it the instant the finger lifts.\n    const offset = refreshing ? threshold : distance\n    // Read off `offset` rather than `distance`, because `offset` is the gap actually on screen and\n    // the fade has to match it. The two part company only while a refresh is in flight, which is\n    // the whole of the button's path: there was never a finger there, so `distance` is 0 and fading\n    // by it left \"Refreshing…\" at `opacity: 0` in an indicator opened expressly to show it.\n    const progress = Math.min(1, threshold > 0 ? offset / threshold : 0)\n\n    return (\n      <div ref={ref} className={cn(\"flex flex-col\", className)} {...props}>\n        <div className=\"flex items-center justify-between gap-2 px-1 pb-1\">\n          {/*\n            Left out rather than hidden, because the `hidden` attribute would not have hidden it:\n            `[hidden]` is a user-agent rule and any `display` coming from a class beats it, so the\n            button would have stayed on screen while claiming to be gone.\n\n            `aria-disabled`, not `disabled`: a disabled button loses focus the moment it is pressed,\n            which drops the keyboard user out of the list they were refreshing. This one keeps focus\n            and simply does nothing.\n          */}\n          {hideButton ? null : (\n            <button\n              type=\"button\"\n              onClick={run}\n              aria-disabled={refreshing || disabled}\n              className={cn(\n                \"inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5\",\n                \"text-sm font-medium text-foreground shadow-sm transition-colors\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n                refreshing || disabled\n                  ? \"cursor-not-allowed opacity-50\"\n                  : \"hover:bg-accent hover:text-accent-foreground\"\n              )}\n            >\n              <RefreshCw\n                className={cn(\"size-4\", refreshing && \"animate-spin\")}\n                aria-hidden=\"true\"\n              />\n              {text.refresh}\n            </button>\n          )}\n          {/*\n            The live region is the whole of the accessible feedback: the indicator below is a\n            drawing of a finger's position, which is nothing at all to a screen reader. It is\n            rendered unconditionally so the region exists in the DOM before it has anything to say —\n            one inserted at the same moment as its text is commonly announced late or not at all.\n          */}\n          <span role=\"status\" aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n            {message}\n          </span>\n        </div>\n\n        <div\n          ref={scrollRef}\n          onTouchStart={onTouchStart}\n          onTouchEnd={onTouchEnd}\n          onTouchCancel={onTouchCancel}\n          onScroll={onScroll}\n          aria-busy={refreshing || undefined}\n          className={cn(\n            // `contain`, not `none`: it stops the gesture reaching the browser's own\n            // pull-to-refresh and stops the scroll chaining to the page behind, while leaving the\n            // rubber-band bounce that tells people they have reached the end.\n            \"relative overflow-y-auto overscroll-y-contain\",\n            scrollClassName\n          )}\n        >\n          <div\n            style={{ transform: offset > 0 ? `translateY(${offset}px)` : undefined }}\n            className={cn(\n              \"relative\",\n              // Animated on release, never during the drag: a transition while the finger is moving\n              // makes the list trail behind it.\n              !dragging && \"transition-transform duration-200 motion-reduce:transition-none\"\n            )}\n          >\n            {/*\n              Sits directly above the content and is clipped by the scroller until the transform\n              reveals it. `aria-hidden` because everything it conveys is already in the live region\n              and in the button's spinner.\n            */}\n            <div\n              aria-hidden=\"true\"\n              className=\"absolute inset-x-0 bottom-full flex flex-col items-center justify-end gap-1 pb-2\"\n              style={{ height: Math.max(threshold, offset) }}\n            >\n              {refreshing ? (\n                <Loader2 className=\"size-5 animate-spin text-muted-foreground\" />\n              ) : (\n                <ArrowDown\n                  className=\"size-5 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none\"\n                  style={{ transform: `rotate(${armed ? 180 : 0}deg)`, opacity: progress }}\n                />\n              )}\n              <span className=\"text-xs text-muted-foreground\" style={{ opacity: progress }}>\n                {refreshing ? text.refreshing : armed ? text.release : text.pull}\n              </span>\n            </div>\n            {children}\n          </div>\n        </div>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
