{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elastic-list",
  "type": "registry:component",
  "title": "Elastic List",
  "description": "A scrolling list with real rubber-band edges: drag or flick past either end and it stretches with growing resistance, then springs back, pulling down from the top winds up a spinner that snaps when it's ready to refresh, and the wheel hands off to the page once you reach an end.",
  "categories": [
    "motion",
    "Surfaces"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/elastic-list.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  type AnimationPlaybackControls,\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type ValueAnimationTransition,\n} from \"motion/react\";\nimport {\n  type ComponentProps,\n  type FocusEvent,\n  type KeyboardEvent,\n  type PointerEvent,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype Status = \"idle\" | \"armed\" | \"refreshing\" | \"done\";\n\nexport interface ElasticListActions {\n  refresh: () => void;\n}\n\nexport interface ElasticListProps extends Omit<ComponentProps<\"div\">, \"onRefresh\"> {\n  onRefresh?: () => Promise<unknown> | unknown;\n  threshold?: number;\n  label?: string;\n  actionsRef?: RefObject<ElasticListActions | null>;\n}\n\nconst RESISTANCE = 0.55;\nconst HOLD = 52;\nconst TICKS = 8;\nconst settleSpring = { type: \"spring\", stiffness: 340, damping: 36 } as const;\n\nfunction useCalmMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nfunction outerCanScroll(from: HTMLElement, delta: number) {\n  for (let el = from.parentElement; el; el = el.parentElement) {\n    const scroller = el === document.scrollingElement || /auto|scroll|overlay/.test(getComputedStyle(el).overflowY);\n    if (!scroller || el.scrollHeight <= el.clientHeight + 1) continue;\n    if (delta > 0 ? el.scrollTop + el.clientHeight < el.scrollHeight - 1 : el.scrollTop > 1) return true;\n  }\n  return false;\n}\n\nconst rubber = (distance: number, size: number) => (1 - 1 / ((distance * RESISTANCE) / size + 1)) * size;\nconst unrubber = (offset: number, size: number) => (size / RESISTANCE) * (1 / (1 - Math.min(offset, size * 0.98) / size) - 1);\n\nfunction Tick({ index, wind, spinning }: { index: number; wind: MotionValue<number>; spinning: boolean }) {\n  const winding = useTransform(wind, (w) => (w * TICKS > index ? 0.35 + 0.65 * Math.min(1, w * TICKS - index) : 0));\n  return (\n    <motion.span\n      className=\"absolute top-0 left-1/2 h-[30%] w-[2.5px] -translate-x-1/2 rounded-full bg-current\"\n      style={{\n        opacity: spinning ? 0.25 + (0.75 * index) / (TICKS - 1) : winding,\n        rotate: index * (360 / TICKS),\n        transformOrigin: \"50% 166%\",\n      }}\n    />\n  );\n}\n\nexport function ElasticList({\n  onRefresh,\n  threshold = 64,\n  label = \"List\",\n  actionsRef,\n  className,\n  children,\n  ...props\n}: ElasticListProps) {\n  const viewport = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const reduce = useCalmMotion();\n  const y = useMotionValue(0);\n  const thumbOpacity = useMotionValue(0);\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [size, setSize] = useState({ view: 0, content: 0 });\n  const statusRef = useRef<Status>(\"idle\");\n  const motionRef = useRef<AnimationPlaybackControls | null>(null);\n  const drag = useRef<{ id: number; start: number; from: number; active: boolean; scale: number } | null>(null);\n  const dragged = useRef(false);\n  const wheel = useRef<{\n    raw: number;\n    atTop: boolean;\n    atBottom: boolean;\n    passing: boolean;\n    timer: ReturnType<typeof setTimeout> | null;\n  }>({\n    raw: 0,\n    atTop: false,\n    atBottom: false,\n    passing: false,\n    timer: null,\n  });\n  const alive = useRef(true);\n\n  const setPhase = useCallback((next: Status) => {\n    statusRef.current = next;\n    setStatus(next);\n  }, []);\n\n  const limits = useCallback(() => {\n    const view = viewport.current?.clientHeight ?? 0;\n    const full = content.current?.offsetHeight ?? 0;\n    const top = statusRef.current === \"refreshing\" ? HOLD : 0;\n    return { top, bottom: Math.min(0, view - full), view: Math.max(view, 1) };\n  }, []);\n\n  const band = useCallback(\n    (raw: number) => {\n      const { top, bottom, view } = limits();\n      if (raw > top) return top + rubber(raw - top, view);\n      if (raw < bottom) return bottom - rubber(bottom - raw, view);\n      return raw;\n    },\n    [limits],\n  );\n\n  const unband = useCallback(\n    (offset: number) => {\n      const { top, bottom, view } = limits();\n      if (offset > top) return top + unrubber(offset - top, view);\n      if (offset < bottom) return bottom - unrubber(bottom - offset, view);\n      return offset;\n    },\n    [limits],\n  );\n\n  const run = useCallback(\n    (target: number, options: ValueAnimationTransition<number> = settleSpring) => {\n      motionRef.current?.stop();\n      motionRef.current = animate(y, target, reduce ? { duration: 0.2, ease: [0.23, 1, 0.32, 1] } : options);\n      return motionRef.current;\n    },\n    [y, reduce],\n  );\n\n  const refresh = useCallback(() => {\n    if (statusRef.current === \"refreshing\") return;\n    setPhase(\"refreshing\");\n    run(HOLD);\n    Promise.resolve(onRefresh?.())\n      .catch(() => undefined)\n      .then(() => {\n        if (!alive.current) return;\n        setPhase(\"done\");\n        if (y.get() > 0) run(0).finished.then(() => alive.current && statusRef.current === \"done\" && setPhase(\"idle\"));\n        else setPhase(\"idle\");\n      });\n  }, [onRefresh, run, setPhase, y]);\n\n  useImperativeHandle(actionsRef, () => ({ refresh }), [refresh]);\n\n  const settle = useCallback(\n    (velocity: number) => {\n      const { top, bottom } = limits();\n      const offset = y.get();\n      if (statusRef.current === \"armed\") {\n        refresh();\n        return;\n      }\n      if (offset > top || offset < bottom) {\n        run(offset > top ? top : bottom, { ...settleSpring, velocity });\n        return;\n      }\n      if (reduce || Math.abs(velocity) < 20) return;\n      motionRef.current?.stop();\n      motionRef.current = animate(y, offset, {\n        type: \"inertia\",\n        velocity,\n        min: bottom,\n        max: top,\n        power: 0.8,\n        timeConstant: 340,\n        bounceStiffness: 320,\n        bounceDamping: 34,\n        restDelta: 0.5,\n      });\n    },\n    [limits, y, refresh, run, reduce],\n  );\n\n  useMotionValueEvent(y, \"change\", (offset) => {\n    const pulling = drag.current?.active || (wheel.current.timer !== null && wheel.current.atTop);\n    const current = statusRef.current;\n    if (pulling && onRefresh && current === \"idle\" && offset >= threshold) setPhase(\"armed\");\n    else if (current === \"armed\" && offset < threshold) setPhase(\"idle\");\n  });\n\n  useEffect(() => {\n    let hide: ReturnType<typeof setTimeout> | null = null;\n    const stop = y.on(\"change\", () => {\n      if (thumbOpacity.get() < 1 && !thumbOpacity.isAnimating()) animate(thumbOpacity, 1, { duration: 0.12 });\n      if (hide) clearTimeout(hide);\n      hide = setTimeout(() => animate(thumbOpacity, 0, { duration: 0.3, ease: \"easeOut\" }), 700);\n    });\n    return () => {\n      stop();\n      if (hide) clearTimeout(hide);\n    };\n  }, [y, thumbOpacity]);\n\n  useEffect(() => {\n    const view = viewport.current;\n    const list = content.current;\n    if (!view || !list) return;\n    const measure = () => {\n      setSize({ view: view.clientHeight, content: list.offsetHeight });\n      const { bottom } = limits();\n      if (!drag.current?.active && !y.isAnimating() && y.get() < bottom) run(bottom);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(view);\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [limits, run, y]);\n\n  useEffect(() => {\n    const view = viewport.current;\n    if (!view) return;\n    const state = wheel.current;\n    const onWheel = (event: WheelEvent) => {\n      if (event.ctrlKey) return;\n      const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? view.clientHeight : 1);\n      const { top, bottom, view: height } = limits();\n      const starting = state.timer === null;\n      if (starting) {\n        const offset = y.get();\n        state.atTop = offset >= top - 1;\n        state.atBottom = offset <= bottom + 1;\n        state.passing = ((delta < 0 && state.atTop) || (delta > 0 && state.atBottom)) && outerCanScroll(view, delta);\n      } else if (state.timer) clearTimeout(state.timer);\n      state.timer = setTimeout(() => {\n        state.timer = null;\n        if (!state.passing) settle(0);\n      }, 140);\n      if (state.passing) return;\n      event.preventDefault();\n      if (drag.current?.active) return;\n      if (starting) {\n        motionRef.current?.stop();\n        state.raw = unband(y.get());\n      }\n      const reach = height * 0.6;\n      state.raw = Math.min(top + (state.atTop ? reach : 40), Math.max(bottom - (state.atBottom ? reach : 40), state.raw - delta));\n      y.set(band(state.raw));\n    };\n    view.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      view.removeEventListener(\"wheel\", onWheel);\n      if (state.timer) clearTimeout(state.timer);\n      state.timer = null;\n    };\n  }, [band, unband, limits, settle, y]);\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n      motionRef.current?.stop();\n    };\n  }, []);\n\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\" && event.button !== 0) return;\n    const box = event.currentTarget.getBoundingClientRect();\n    drag.current = {\n      id: event.pointerId,\n      start: event.clientY,\n      from: y.get(),\n      active: false,\n      scale: box.height / (event.currentTarget.offsetHeight || 1) || 1,\n    };\n    dragged.current = false;\n  };\n\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== event.pointerId) return;\n    const moved = (event.clientY - d.start) / d.scale;\n    if (!d.active) {\n      if (Math.abs(moved) < 5) return;\n      d.active = true;\n      d.start = event.clientY;\n      dragged.current = true;\n      motionRef.current?.stop();\n      d.from = unband(y.get());\n      event.currentTarget.setPointerCapture(event.pointerId);\n      return;\n    }\n    y.set(band(d.from + moved));\n  };\n\n  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== event.pointerId) return;\n    drag.current = null;\n    if (!d.active) return;\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);\n    settle(event.type === \"pointercancel\" ? 0 : y.getVelocity());\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.target !== event.currentTarget && ![\"PageDown\", \"PageUp\", \"Home\", \"End\"].includes(event.key)) return;\n    const { top, bottom, view } = limits();\n    const step: Record<string, number> = {\n      ArrowDown: -48,\n      ArrowUp: 48,\n      PageDown: -(view - 48),\n      PageUp: view - 48,\n      \" \": event.shiftKey ? view - 48 : -(view - 48),\n    };\n    let target: number | null = null;\n    if (event.key === \"Home\") target = top;\n    else if (event.key === \"End\") target = bottom;\n    else if (event.key in step) target = Math.min(top, Math.max(bottom, y.get() + step[event.key]));\n    if (target === null) return;\n    event.preventDefault();\n    run(target, { type: \"spring\", stiffness: 420, damping: 42 });\n  };\n\n  const onFocus = (event: FocusEvent<HTMLDivElement>) => {\n    const view = viewport.current;\n    const list = content.current;\n    if (!view || !list || event.target === view) return;\n    view.scrollTop = 0;\n    const scale = view.getBoundingClientRect().height / (view.offsetHeight || 1) || 1;\n    const rect = (event.target as HTMLElement).getBoundingClientRect();\n    const listTop = list.getBoundingClientRect().top;\n    const start = (rect.top - listTop) / scale;\n    const end = start + rect.height / scale;\n    const { top, bottom, view: height } = limits();\n    const offset = y.get();\n    if (start + offset < 8) run(Math.min(top, Math.max(bottom, 8 - start)));\n    else if (end + offset > height - 8) run(Math.min(top, Math.max(bottom, height - 8 - end)));\n  };\n\n  const wind = useTransform(y, (offset) => Math.min(1, Math.max(0, offset / threshold)));\n  const spinnerY = useTransform(y, (offset) => Math.max(offset, 0) / 2 - 12);\n  const spinnerRotate = useTransform(wind, (w) => w * 220);\n  const scrollable = size.content > size.view + 1;\n  const thumbSize = scrollable ? Math.max(28, (size.view * size.view) / size.content) : 0;\n  const track = size.view - thumbSize - 8;\n  const thumbY = useTransform(y, (offset) => {\n    const range = size.content - size.view;\n    if (range <= 0) return 0;\n    return 4 + Math.min(1, Math.max(0, -offset / range)) * track;\n  });\n  const thumbScale = useTransform(y, (offset) => {\n    const range = size.content - size.view;\n    const over = offset > 0 ? offset : offset < -range ? -range - offset : 0;\n    return Math.max(0.35, 1 - over / (thumbSize * 1.6 || 1));\n  });\n  const thumbOrigin = useTransform(y, (offset) => (offset > 0 ? 0 : 1));\n\n  return (\n    <div\n      aria-label={label}\n      className={cn(\"group relative touch-none select-none overflow-hidden outline-none\", className)}\n      onClickCapture={(event) => {\n        if (dragged.current) {\n          event.preventDefault();\n          event.stopPropagation();\n          dragged.current = false;\n        }\n      }}\n      onFocus={onFocus}\n      onKeyDown={onKeyDown}\n      onPointerCancel={onPointerUp}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onScroll={(event) => {\n        event.currentTarget.scrollTop = 0;\n      }}\n      ref={viewport}\n      role=\"region\"\n      tabIndex={0}\n      {...props}\n    >\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {status === \"refreshing\" ? \"Refreshing\" : status === \"armed\" ? \"Release to refresh\" : \"\"}\n      </span>\n      {onRefresh && (\n        <motion.div\n          aria-hidden\n          className=\"pointer-events-none absolute top-0 left-1/2 -ml-3 size-6 text-muted-foreground\"\n          style={{ y: spinnerY }}\n        >\n          <motion.div\n            animate={\n              status === \"done\"\n                ? { scale: 0.6, opacity: 0 }\n                : status === \"armed\"\n                  ? { scale: [1, 1.18, 1], opacity: 1 }\n                  : { scale: 1, opacity: 1 }\n            }\n            className=\"size-full\"\n            transition={status === \"armed\" ? { duration: 0.28, ease: [0.23, 1, 0.32, 1] } : { duration: 0.18, ease: \"easeOut\" }}\n          >\n            <motion.div\n              className={cn(\"relative size-full\", status === \"refreshing\" && \"animate-spin [animation-duration:0.9s]\")}\n              style={status === \"refreshing\" ? undefined : { rotate: spinnerRotate }}\n            >\n              {Array.from({ length: TICKS }, (_, i) => (\n                <Tick index={i} key={i} spinning={status === \"refreshing\" || status === \"done\"} wind={wind} />\n              ))}\n            </motion.div>\n          </motion.div>\n        </motion.div>\n      )}\n      <motion.div ref={content} style={{ y }}>\n        {children}\n      </motion.div>\n      {scrollable && (\n        <motion.span\n          aria-hidden\n          className=\"pointer-events-none absolute top-0 right-1 w-1 rounded-full bg-foreground/30\"\n          style={{ height: thumbSize, y: thumbY, scaleY: thumbScale, originY: thumbOrigin, opacity: thumbOpacity }}\n        />\n      )}\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit] ring-ring ring-inset group-focus-visible:ring-2\"\n      />\n    </div>\n  );\n}\n"
    }
  ]
}