{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pull-to-refresh","type":"registry:component","title":"Pull to refresh","description":"Pull the feed down and new posts slide in at the top.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/pull-to-refresh.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Post = {\n  id: string;\n  name: string;\n  handle: string;\n  avatar: string;\n  time: string;\n  text: string;\n};\n\n// Pulled this far (after resistance), letting go refreshes.\nconst THRESHOLD = 64;\n// Stands in for a network round trip.\nconst REFRESH_FOR = 1200;\n// Roughly the screen height; the band stiffens relative to it.\nconst RUBBER_DIMENSION = 480;\n// Looser than Apple's 0.55 scroll edge, so reaching the threshold takes a\n// comfortable ~110px of hand travel rather than ~150px.\nconst RUBBER = 0.7;\n// Past this many px a mouse press becomes a pull rather than a click.\nconst DRAG_SLOP = 4;\nconst TICKS = 8;\n// Critically damped: an indicator that overshoots would bounce the feed.\nconst SETTLE = { type: \"spring\", visualDuration: 0.35, bounce: 0 } as const;\n// Longer than a UI transition on purpose: the feed travels up to ~150px and\n// the new posts should be seen arriving, not just appear.\nconst SLIDE_IN = { type: \"spring\", visualDuration: 0.5, bounce: 0 } as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\n// Apple's rubber band: follows 1:1 at first, then gives less and less.\nfunction rubberband(distance: number) {\n  return (\n    (distance * RUBBER_DIMENSION * RUBBER) /\n    (RUBBER_DIMENSION + RUBBER * distance)\n  );\n}\n\n// Where the hand would have to be for the band to show this offset, so a\n// pull caught mid-spring continues from exactly where it is.\nfunction unband(offset: number) {\n  return (offset * RUBBER_DIMENSION) / (RUBBER * (RUBBER_DIMENSION - offset));\n}\n\n// The indeterminate spin is a stepped rotation of graded ticks, like the\n// system activity indicator, and it only runs while refreshing.\nconst CSS = `\n.ptr-spin[data-spinning=\"true\"] { animation: ptr-spin 0.8s steps(8) infinite; }\n@keyframes ptr-spin { to { rotate: 360deg; } }\n@media (prefers-reduced-motion: reduce) {\n  .ptr-spin[data-spinning=\"true\"] { animation: none; }\n}\n`;\n\ntype Anim = ReturnType<typeof animate>;\n\nexport function PullToRefresh({\n  initialPosts,\n  loadPosts,\n  className,\n}: {\n  initialPosts: Post[];\n  // Returns the posts to add on top. Called once per refresh.\n  loadPosts: () => Post[];\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [posts, setPosts] = useState(() =>\n    initialPosts.map((post) => ({ ...post, fresh: false })),\n  );\n  const [refreshing, setRefreshing] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const busy = useRef(false);\n  const added = useRef(0);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const yAnim = useRef<Anim | null>(null);\n  const fadeAnim = useRef<Anim | null>(null);\n  const loader = useRef(loadPosts);\n\n  const y = useMotionValue(0);\n  // Dropped to 0 when the refresh ends, so the spinner fades rather than\n  // shrinking tick by tick as the feed retracts.\n  const fade = useMotionValue(1);\n  // Share of ticks drawn in; grows with the pull like the system indicator.\n  const reveal = useMotionValue(0);\n  const opacity = useTransform(\n    () => Math.min(Math.max(y.get() / THRESHOLD, 0), 1) * fade.get(),\n  );\n  const rotate = useTransform(y, (v) => v * 3);\n\n  useEffect(() => {\n    loader.current = loadPosts;\n  });\n\n  const stopY = () => {\n    yAnim.current?.stop();\n    yAnim.current = null;\n  };\n\n  const moveY = (target: number, transition: object) => {\n    stopY();\n    if (reduceMotion) {\n      y.jump(target);\n      return;\n    }\n    yAnim.current = animate(y, target, transition);\n  };\n\n  // One code path for touch, mouse and the button, so all three look alike.\n  const gesture = useRef<{ base: number; samples: { y: number; t: number }[] } | null>(\n    null,\n  );\n\n  const begin = () => {\n    stopY();\n    fadeAnim.current?.stop();\n    fade.jump(1);\n    gesture.current = { base: unband(Math.max(y.get(), 0)), samples: [] };\n  };\n\n  const pull = (distance: number, t: number) => {\n    const g = gesture.current;\n    if (!g) return;\n    const offset = rubberband(Math.max(g.base + distance, 0));\n    y.set(offset);\n    reveal.set(Math.min(offset / THRESHOLD, 1));\n    g.samples.push({ y: offset, t });\n    // Only the last 100ms say how fast the feed is moving now.\n    while (g.samples.length > 2 && t - g.samples[0].t > 100) g.samples.shift();\n  };\n\n  const startRefresh = (velocity = 0) => {\n    busy.current = true;\n    setRefreshing(true);\n    setAnnouncement(\"\");\n    reveal.jump(1);\n    fade.jump(1);\n    moveY(THRESHOLD, { ...SETTLE, velocity });\n    clearTimeout(timer.current);\n    timer.current = setTimeout(() => {\n      const fresh = loader.current().map((post) => ({ ...post, fresh: true }));\n      added.current = fresh.length;\n      if (!reduceMotion)\n        fadeAnim.current = animate(fade, 0, { duration: 0.15, ease: EASE_OUT });\n      setPosts((p) => [...fresh, ...p].slice(0, 40));\n      setRefreshing(false);\n      setAnnouncement(\n        `Updated, ${fresh.length} new post${fresh.length === 1 ? \"\" : \"s\"}`,\n      );\n      busy.current = false;\n    }, REFRESH_FOR);\n  };\n\n  const end = () => {\n    const g = gesture.current;\n    gesture.current = null;\n    if (!g) return;\n    const first = g.samples[0];\n    const last = g.samples[g.samples.length - 1];\n    const dt = first && last ? (last.t - first.t) / 1000 : 0;\n    const velocity = dt > 0 ? (last.y - first.y) / dt : 0;\n    if (y.get() >= THRESHOLD) startRefresh(velocity);\n    else moveY(0, { ...SETTLE, velocity });\n  };\n\n  // New posts land above the ones on screen. Shifting the feed up by their\n  // height keeps everything visually still for a frame, then the feed\n  // springs down from the threshold, which slides the posts in from the top\n  // and retracts the indicator in one motion.\n  useLayoutEffect(() => {\n    const count = added.current;\n    const list = listRef.current;\n    if (!count || !list) return;\n    added.current = 0;\n    const firstOld = list.children[count] as HTMLElement | undefined;\n    const height = firstOld\n      ? firstOld.offsetTop - (list.children[0] as HTMLElement).offsetTop\n      : 0;\n    stopY();\n    if (reduceMotion) {\n      y.jump(0);\n      return;\n    }\n    y.jump(y.get() - height);\n    yAnim.current = animate(y, 0, SLIDE_IN);\n  }, [posts, reduceMotion, y]);\n\n  // Touch listeners are attached by hand because the pull has to cancel the\n  // browser's own scroll, which React's passive touchmove can't do.\n  useEffect(() => {\n    const el = scrollerRef.current;\n    if (!el) return;\n    let start: { y: number; pulling: boolean } | null = null;\n\n    const onStart = (e: TouchEvent) => {\n      start = null;\n      // Only from the very top, and never while a refresh or the slide in\n      // is still running.\n      if (busy.current || e.touches.length > 1 || el.scrollTop > 0 || y.get() < 0)\n        return;\n      start = { y: e.touches[0].clientY, pulling: false };\n    };\n    const onMove = (e: TouchEvent) => {\n      if (!start) return;\n      const dy = e.touches[0].clientY - start.y;\n      if (!start.pulling) {\n        // Scrolling up the feed is a normal scroll; leave it alone.\n        if (dy <= 0 || el.scrollTop > 0) {\n          start = null;\n          return;\n        }\n        start.pulling = true;\n        begin();\n      }\n      e.preventDefault();\n      pull(dy, e.timeStamp);\n    };\n    const onEnd = () => {\n      if (start?.pulling) end();\n      start = null;\n    };\n\n    el.addEventListener(\"touchstart\", onStart, { passive: true });\n    el.addEventListener(\"touchmove\", onMove, { passive: false });\n    el.addEventListener(\"touchend\", onEnd);\n    el.addEventListener(\"touchcancel\", onEnd);\n    return () => {\n      el.removeEventListener(\"touchstart\", onStart);\n      el.removeEventListener(\"touchmove\", onMove);\n      el.removeEventListener(\"touchend\", onEnd);\n      el.removeEventListener(\"touchcancel\", onEnd);\n    };\n  });\n\n  useEffect(\n    () => () => {\n      clearTimeout(timer.current);\n      yAnim.current?.stop();\n      fadeAnim.current?.stop();\n    },\n    [],\n  );\n\n  const mouse = useRef<{ y: number; pulling: boolean } | null>(null);\n\n  return (\n    // 44px outer radius around 10px of bezel leaves 34px for the screen.\n    <div\n      className={cn(\n        \"h-[480px] w-[min(320px,100%)] rounded-[44px] bg-muted p-2.5 shadow-raised\",\n        className,\n      )}\n    >\n      <style href=\"pull-to-refresh\" precedence=\"default\">\n        {CSS}\n      </style>\n      <div className=\"relative flex h-full flex-col overflow-hidden rounded-[34px] bg-background\">\n        <div className=\"flex h-14 shrink-0 items-center justify-between border-b border-border pr-3 pl-5\">\n          <h3 className=\"text-[17px] font-semibold tracking-[-0.01em] text-foreground\">\n            Following\n          </h3>\n          <button\n            type=\"button\"\n            aria-label=\"Refresh feed\"\n            aria-disabled={refreshing}\n            onClick={() => {\n              if (busy.current) return;\n              scrollerRef.current?.scrollTo({\n                top: 0,\n                behavior: reduceMotion ? \"auto\" : \"smooth\",\n              });\n              startRefresh();\n            }}\n            className=\"flex size-9 items-center justify-center rounded-full text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] aria-disabled:opacity-50 motion-reduce:transition-[color,background-color]\"\n          >\n            <svg\n              viewBox=\"0 0 16 16\"\n              className=\"size-4\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden\n            >\n              <path d=\"M13.25 8a5.25 5.25 0 1 1-1.6-3.77M13.25 2.5v2.75H10.5\" />\n            </svg>\n          </button>\n        </div>\n\n        <div className=\"relative min-h-0 flex-1\">\n          <motion.div\n            aria-hidden\n            style={{ opacity }}\n            className=\"absolute inset-x-0 top-0 flex h-16 items-center justify-center text-foreground\"\n          >\n            <motion.div style={{ rotate }}>\n              <svg\n                viewBox=\"0 0 24 24\"\n                className=\"ptr-spin size-6\"\n                data-spinning={refreshing}\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={2}\n                strokeLinecap=\"round\"\n              >\n                {Array.from({ length: TICKS }, (_, i) => (\n                  <Tick key={i} index={i} reveal={reveal} />\n                ))}\n              </svg>\n            </motion.div>\n          </motion.div>\n\n          <div\n            ref={scrollerRef}\n            aria-busy={refreshing}\n            tabIndex={0}\n            role=\"region\"\n            aria-label=\"Feed\"\n            className=\"relative h-full overflow-y-auto overscroll-contain outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-foreground\"\n            onPointerDown={(e) => {\n              if (e.pointerType !== \"mouse\" || e.button !== 0) return;\n              if (busy.current || e.currentTarget.scrollTop > 0 || y.get() < 0) return;\n              mouse.current = { y: e.clientY, pulling: false };\n            }}\n            onPointerMove={(e) => {\n              const m = mouse.current;\n              if (!m) return;\n              const dy = e.clientY - m.y;\n              if (!m.pulling) {\n                if (dy < -DRAG_SLOP) mouse.current = null;\n                if (dy <= DRAG_SLOP) return;\n                m.pulling = true;\n                // Starts counting from here, so crossing the slop never\n                // makes the feed jump.\n                m.y = e.clientY;\n                e.currentTarget.setPointerCapture(e.pointerId);\n                begin();\n              }\n              pull(e.clientY - m.y, e.timeStamp);\n            }}\n            onPointerUp={() => {\n              if (mouse.current?.pulling) end();\n              mouse.current = null;\n            }}\n            onPointerCancel={() => {\n              if (mouse.current?.pulling) end();\n              mouse.current = null;\n            }}\n          >\n            <motion.div style={{ y }} className=\"min-h-full bg-background select-none\">\n              <div ref={listRef}>\n                {posts.map((post) => (\n                  <motion.article\n                    key={post.id}\n                    initial={\n                      post.fresh\n                        ? reduceMotion\n                          ? { opacity: 0 }\n                          : { opacity: 0, filter: \"blur(4px)\" }\n                        : false\n                    }\n                    animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                    transition={{ duration: 0.3, ease: EASE_OUT }}\n                    className=\"flex gap-3 border-b border-border px-5 py-3.5\"\n                  >\n                    <span className=\"size-9 shrink-0 overflow-hidden rounded-full bg-[oklch(0.97_0_0)] outline-1 -outline-offset-1 outline-[oklch(0_0_0/0.1)] dark:outline-[oklch(1_0_0/0.1)]\">\n                      {/* eslint-disable-next-line @next/next/no-img-element */}\n                      <img src={post.avatar} alt=\"\" className=\"size-full\" draggable={false} />\n                    </span>\n                    <div className=\"min-w-0\">\n                      <p className=\"flex items-baseline gap-1.5 text-[14px]\">\n                        <span className=\"truncate font-medium text-foreground\">\n                          {post.name}\n                        </span>\n                        <span className=\"shrink-0 text-[13px] text-muted-foreground\">\n                          {post.handle} · {post.time}\n                        </span>\n                      </p>\n                      <p className=\"mt-0.5 text-[14px] leading-5 text-pretty text-foreground\">\n                        {post.text}\n                      </p>\n                    </div>\n                  </motion.article>\n                ))}\n              </div>\n            </motion.div>\n          </div>\n        </div>\n\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {announcement}\n        </span>\n      </div>\n    </div>\n  );\n}\n\n// Graded like the system spinner: the leading tick is solid and each one\n// behind it fades, so the stepped rotation reads as a sweep.\nfunction Tick({ index, reveal }: { index: number; reveal: MotionValue<number> }) {\n  // Ticks are drawn in clockwise from the top as the pull grows.\n  const order = (TICKS - index) % TICKS;\n  const opacity = useTransform(reveal, (r) =>\n    r * TICKS > order ? 1 - index * 0.1 : 0,\n  );\n  return (\n    <motion.line\n      x1=\"12\"\n      y1=\"3\"\n      x2=\"12\"\n      y2=\"7\"\n      style={{ opacity }}\n      transform={`rotate(${-index * (360 / TICKS)} 12 12)`}\n    />\n  );\n}\n\nconst PEOPLE = [\n  { name: \"Ava Chen\", handle: \"@ava\", avatar: \"/avatars/ava.svg\" },\n  { name: \"Ben Ortiz\", handle: \"@ben\", avatar: \"/avatars/ben.svg\" },\n  { name: \"Cara Nwosu\", handle: \"@cara\", avatar: \"/avatars/cara.svg\" },\n  { name: \"Dev Patel\", handle: \"@dev\", avatar: \"/avatars/dev.svg\" },\n  { name: \"Fay Laurent\", handle: \"@fay\", avatar: \"/avatars/fay.svg\" },\n];\n\nconst INITIAL: Post[] = [\n  \"Shipped the new onboarding today. Two screens instead of five.\",\n  \"Hot take: most loading spinners should be skeletons.\",\n  \"Springs over durations for anything you can touch. Every time.\",\n  \"Rewrote the settings page with plain CSS grid. Deleted 400 lines.\",\n  \"The best animation is often the one you remove.\",\n  \"Anyone else test gestures at 10% speed? It changes everything.\",\n  \"Tabular numbers in every table, please.\",\n  \"Friday demo went well. The drag-to-reorder finally feels right.\",\n].map((text, i) => ({\n  ...PEOPLE[i % PEOPLE.length],\n  id: `seed-${i}`,\n  time: `${(i + 1) * 7}m`,\n  text,\n}));\n\nconst INCOMING = [\n  \"Just found out our checkout button had a 300ms tap delay. Fixed.\",\n  \"Reduced motion is not no motion. Cross-fades still help.\",\n  \"Pairing on the new command palette this afternoon.\",\n  \"Velocity handoff is the whole trick. Nobody notices, everyone feels it.\",\n  \"Our design review now includes a slow-motion pass.\",\n  \"New icons landed. One stroke weight across the whole set.\",\n];\n\nexport default function PullToRefreshDemo() {\n  const next = useRef(0);\n  return (\n    <PullToRefresh\n      initialPosts={INITIAL}\n      loadPosts={() =>\n        Array.from({ length: 2 }, () => {\n          const n = next.current++;\n          return {\n            ...PEOPLE[(n + 3) % PEOPLE.length],\n            id: `new-${n}`,\n            time: \"now\",\n            text: INCOMING[n % INCOMING.length],\n          };\n        })\n      }\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/pull-to-refresh"}