{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-to-top",
  "type": "registry:ui",
  "title": "Scroll to Top",
  "description": "A back-to-top button with a scroll-progress ring that appears after a threshold, for the window or any scroll box.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/scroll-to-top.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useMotionValue } from \"motion/react\";\n\nexport interface ScrollToTopProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"> {\n  /** Scrollable element to watch and scroll. Defaults to the window. */\n  container?: React.RefObject<HTMLElement | null>;\n  /** Scroll distance in px after which the button appears. */\n  threshold?: number;\n  /** Corner the button sits in. With `container`, place it inside a `relative` wrapper around the scroll box. */\n  position?: \"bottom-right\" | \"bottom-left\" | \"bottom-center\";\n  /** Distance from the edges in px. */\n  offset?: number;\n  /** Button diameter in px. */\n  size?: number;\n  /** Show the circular scroll-progress ring. */\n  showProgress?: boolean;\n  /** Ring and focus colour. */\n  accent?: string;\n  /** Move focus to the top after scrolling (focusTarget, else the container / the main landmark). */\n  focusOnTop?: boolean;\n  /** Element that receives focus after scrolling to the top. */\n  focusTarget?: React.RefObject<HTMLElement | null>;\n  /** Accessible label; the scrolled percentage is appended. */\n  label?: string;\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst RING = 2; // ring stroke width in px\nconst POSITIONS = {\n  \"bottom-right\": { right: \"var(--stt-offset)\" },\n  \"bottom-left\": { left: \"var(--stt-offset)\" },\n  \"bottom-center\": { left: \"50%\", marginLeft: \"calc(var(--stt-size) / -2)\" },\n} as const;\n\n/** Focuses an element without scrolling, making it programmatically focusable when needed. */\nfunction focusQuietly(el: HTMLElement) {\n  if (el.tabIndex < 0 && !el.hasAttribute(\"tabindex\")) el.setAttribute(\"tabindex\", \"-1\");\n  el.focus({ preventScroll: true });\n}\n\n/**\n * A back-to-top button that appears after a scroll threshold, shows scroll progress as a ring,\n * scrolls smoothly (instantly with reduced motion) and then moves focus to the top of the page or\n * container. Works with the window or any scrollable element passed as `container`.\n */\nexport function ScrollToTop({\n  container,\n  threshold = 240,\n  position = \"bottom-right\",\n  offset = 24,\n  size = 48,\n  showProgress = true,\n  accent = \"#FF7448\",\n  focusOnTop = true,\n  focusTarget,\n  label = \"Back to top\",\n  className = \"\",\n  style,\n  onClick,\n  ...props\n}: ScrollToTopProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const [visible, setVisible] = React.useState(false);\n  // Rounded to 5% steps for the accessible label, so scrolling re-renders rarely.\n  const [percent, setPercent] = React.useState(0);\n  // Scroll progress 0–1, written once per frame without re-rendering.\n  const progress = useMotionValue(0);\n  const cleanupRef = React.useRef<() => void>(() => {});\n\n  const read = React.useEffectEvent(() => {\n    const el = container?.current;\n    const top = el ? el.scrollTop : window.scrollY;\n    const max = el ? el.scrollHeight - el.clientHeight : document.documentElement.scrollHeight - window.innerHeight;\n    const p = max > 0 ? Math.min(1, Math.max(0, top / max)) : 0;\n    progress.set(p);\n    setVisible(top > threshold);\n    setPercent(Math.round(p * 20) * 5);\n  });\n\n  React.useEffect(() => {\n    const el = container?.current ?? null;\n    const target: HTMLElement | Window = el ?? window;\n    let frame = 0;\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => read());\n    };\n    target.addEventListener(\"scroll\", schedule, { passive: true });\n    window.addEventListener(\"resize\", schedule);\n    // Content growing or shrinking changes the progress without a scroll event.\n    const observer = new ResizeObserver(schedule);\n    observer.observe(el ?? document.documentElement);\n    if (el?.firstElementChild) observer.observe(el.firstElementChild);\n    schedule();\n    return () => {\n      cancelAnimationFrame(frame);\n      target.removeEventListener(\"scroll\", schedule);\n      window.removeEventListener(\"resize\", schedule);\n      observer.disconnect();\n    };\n  }, [container]);\n\n  // Cancel a pending \"focus after scroll\" on unmount.\n  React.useEffect(() => () => cleanupRef.current(), []);\n\n  const scrollToTop = (event: React.MouseEvent<HTMLButtonElement>) => {\n    onClick?.(event);\n    if (event.defaultPrevented) return;\n    const el = container?.current ?? null;\n    const scroller: HTMLElement | Window = el ?? window;\n    scroller.scrollTo({ top: 0, behavior: reduceMotion ? \"auto\" : \"smooth\" });\n    if (!focusOnTop) return;\n\n    // Move focus once the scroll has settled at the top (scrollend, polling as a fallback).\n    cleanupRef.current();\n    const atTop = () => (el ? el.scrollTop : window.scrollY) <= 1;\n    const destination = () =>\n      focusTarget?.current ?? el ?? document.querySelector<HTMLElement>(\"main, [role='main']\") ?? document.body;\n    let done = false;\n    const finish = () => {\n      if (done) return;\n      done = true;\n      cleanupRef.current();\n      focusQuietly(destination());\n    };\n    const onEnd = () => atTop() && finish();\n    const poll = window.setInterval(() => atTop() && finish(), 100);\n    const timeout = window.setTimeout(finish, 1500);\n    scroller.addEventListener(\"scrollend\", onEnd);\n    cleanupRef.current = () => {\n      window.clearInterval(poll);\n      window.clearTimeout(timeout);\n      scroller.removeEventListener(\"scrollend\", onEnd);\n      cleanupRef.current = () => {};\n    };\n    if (atTop()) finish();\n  };\n\n  const r = size / 2 - RING / 2 - 1;\n\n  return (\n    <AnimatePresence>\n      {visible ? (\n        <motion.button\n          key=\"scroll-to-top\"\n          type=\"button\"\n          aria-label={`${label}, ${percent}% scrolled`}\n          title={label}\n          initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.8 }}\n          animate={reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}\n          exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.85, transition: { duration: 0.16 } }}\n          transition={reduceMotion ? { duration: 0.12 } : { type: \"spring\", stiffness: 460, damping: 30 }}\n          whileHover={reduceMotion ? undefined : { y: -2 }}\n          whileTap={reduceMotion ? undefined : { scale: 0.92 }}\n          onClick={scrollToTop}\n          className={`group z-40 grid place-items-center rounded-full border border-zinc-900/10 bg-white text-zinc-800 shadow-[0_12px_28px_-12px_rgb(0_0_0/0.35)] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--stt-accent)] dark:border-white/10 dark:bg-[#1a1d21] dark:text-zinc-100 dark:shadow-[0_12px_28px_-12px_rgb(0_0_0/0.8)] ${container ? \"absolute\" : \"fixed\"} ${className}`}\n          style={{\n            [\"--stt-accent\" as string]: accent,\n            [\"--stt-offset\" as string]: `${offset}px`,\n            [\"--stt-size\" as string]: `${size}px`,\n            width: size,\n            height: size,\n            bottom: \"var(--stt-offset)\",\n            ...POSITIONS[position],\n            ...style,\n          }}\n          {...props}\n        >\n          {showProgress ? (\n            <svg aria-hidden=\"true\" viewBox={`0 0 ${size} ${size}`} className=\"pointer-events-none absolute inset-0 size-full -rotate-90\">\n              <circle cx={size / 2} cy={size / 2} r={r} fill=\"none\" strokeWidth={RING} className=\"stroke-zinc-900/10 dark:stroke-white/10\" />\n              <motion.circle\n                cx={size / 2}\n                cy={size / 2}\n                r={r}\n                fill=\"none\"\n                strokeWidth={RING}\n                strokeLinecap=\"round\"\n                stroke=\"var(--stt-accent)\"\n                style={{ pathLength: progress }}\n              />\n            </svg>\n          ) : null}\n          <svg\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            aria-hidden=\"true\"\n            className=\"relative size-4 transition-transform duration-300 ease-out group-hover:-translate-y-0.5\"\n          >\n            <path d=\"M8 13V3.5M3.5 7.5 8 3l4.5 4.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n        </motion.button>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/scroll-to-top"
}
