{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "back-to-top",
  "type": "registry:ui",
  "title": "Back to Top",
  "description": "Arrives after a screen, rings with progress, and hands focus back at the top.",
  "categories": [
    "scroll"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/back-to-top.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUp } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype Target = React.RefObject<HTMLElement | null>;\ntype Scroller = HTMLElement | null; // null means the page\n\n// The native scroll-driven timeline, where the browser has it. Not in the DOM typings yet.\ntype ScrollTimelineCtor = new (options: { source: Element; axis?: \"block\" | \"inline\" }) => AnimationTimeline;\nconst nativeScrollTimeline = () => (window as unknown as { ScrollTimeline?: ScrollTimelineCtor }).ScrollTimeline;\n\nconst readY = (s: Scroller) => (s ? s.scrollTop : window.scrollY);\nconst maxY = (s: Scroller) => (s ? s.scrollHeight - s.clientHeight : document.documentElement.scrollHeight - window.innerHeight);\nconst viewH = (s: Scroller) => (s ? s.clientHeight : window.innerHeight);\n\nfunction goTop(s: Scroller, smooth: boolean) {\n  (s ?? window).scrollTo({ top: 0, behavior: smooth ? \"smooth\" : \"instant\" });\n}\n\n/** Moves focus to the start of the content without scrolling, making it focusable if it isn't. */\nfunction focusStart(el: HTMLElement) {\n  if (el.tabIndex < 0 && !el.hasAttribute(\"tabindex\")) el.setAttribute(\"tabindex\", \"-1\");\n  el.focus({ preventScroll: true });\n}\n\nexport type BackToTopProps = Omit<React.ComponentProps<\"button\">, \"children\"> & {\n  /** The scroll container. Defaults to the page. */\n  scrollRoot?: Target;\n  /** Pixels scrolled before it appears. Defaults to one screen of the container. */\n  threshold?: number;\n  /** Where keyboard focus lands once the top is reached. Defaults to the container, or the page's main. */\n  focusTarget?: Target;\n  /** Accessible name, and the visible text when showLabel is on. */\n  label?: string;\n  /** A pill with the label beside the ring, instead of a round icon button. */\n  showLabel?: boolean;\n  /** fixed sits in the viewport corner; absolute in the nearest positioned parent's. */\n  position?: \"fixed\" | \"absolute\";\n};\n\nexport function BackToTop({\n  scrollRoot,\n  threshold,\n  focusTarget,\n  label = \"Back to top\",\n  showLabel = false,\n  position = \"fixed\",\n  className,\n  onClick,\n  ...rest\n}: BackToTopProps) {\n  const reduce = useReducedMotion();\n  const scroller = useRef<Scroller>(null);\n  const [visible, setVisible] = useState(false);\n  const [launches, setLaunches] = useState(0);\n  // Set while a trip to the top is under way; cleared on arrival or if the user takes over.\n  const trip = useRef(false);\n\n  useEffect(() => {\n    const s = scrollRoot?.current ?? null;\n    scroller.current = s;\n    const source: HTMLElement | Window = s ?? window;\n    let frame = 0;\n    let shown = false;\n\n    const update = () => {\n      frame = 0;\n      const y = readY(s);\n      const show = threshold ?? viewH(s);\n      if (trip.current && y <= 1) {\n        trip.current = false;\n        focusStart(focusTarget?.current ?? s ?? document.querySelector(\"main\") ?? document.body);\n      }\n      // Shown past the threshold; once shown it stays until half of it, so it\n      // doesn't flicker for someone reading right at the line. On a trip it\n      // rides all the way up and leaves at the top.\n      shown = trip.current ? shown : shown ? y > show / 2 : y > show;\n      setVisible(shown);\n    };\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n    // Any hand on the wheel, screen or keys during the trip means they've taken over.\n    const takeOver = () => {\n      trip.current = false;\n    };\n    source.addEventListener(\"scroll\", onScroll, { passive: true });\n    source.addEventListener(\"wheel\", takeOver, { passive: true });\n    source.addEventListener(\"touchstart\", takeOver, { passive: true });\n    source.addEventListener(\"keydown\", takeOver);\n    frame = requestAnimationFrame(update);\n    return () => {\n      source.removeEventListener(\"scroll\", onScroll);\n      source.removeEventListener(\"wheel\", takeOver);\n      source.removeEventListener(\"touchstart\", takeOver);\n      source.removeEventListener(\"keydown\", takeOver);\n      cancelAnimationFrame(frame);\n    };\n  }, [scrollRoot, threshold, focusTarget]);\n\n  return (\n    <AnimatePresence>\n      {visible && (\n        <motion.button\n          key=\"back-to-top\"\n          type=\"button\"\n          aria-label={showLabel ? undefined : label}\n          data-position={position}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: 8, filter: \"blur(2px)\" }}\n          animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.92, y: 4, filter: \"blur(2px)\", transition: { duration: 0.16, ease: ease.in } }}\n          transition={reduce ? { duration: 0.16 } : spring.snappy}\n          onClick={(e) => {\n            onClick?.(e);\n            if (e.defaultPrevented) return;\n            trip.current = true;\n            setLaunches((n) => n + 1);\n            goTop(scroller.current, !reduce);\n          }}\n          className={cn(\n            \"group/top z-(--z-sticky) inline-flex items-center justify-center rounded-full bg-raised text-fg shadow-pop\",\n            \"bottom-[max(16px,env(safe-area-inset-bottom))] right-4\",\n            position === \"fixed\" ? \"fixed\" : \"absolute\",\n            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            // 40px drawn, 48px to a finger.\n            \"before:absolute before:-inset-1 before:rounded-full before:content-[''] pointer-fine:before:hidden\",\n            \"transition-[background-color,border-color] duration-150 hover:bg-hover\",\n            // Press is handled by Motion so it composes with the entrance transform.\n            // As a pill it has a border; as a circle the ring itself is the edge.\n            showLabel ? \"h-10 gap-2 border border-line-2 pl-1 pr-3.5 text-[12.5px] font-medium hover:border-fg-4\" : \"size-10\",\n            className,\n          )}\n          whileTap={reduce ? undefined : { scale: 0.92, transition: { duration: 0.08 } }}\n          {...(rest as React.ComponentProps<typeof motion.button>)}\n        >\n          {!showLabel && <ProgressRing scroller={scroller} className=\"absolute inset-0 size-10\" />}\n          <span className=\"relative grid size-8 place-items-center\">\n            {showLabel && <ProgressRing scroller={scroller} className=\"absolute inset-0 size-8\" />}\n            <span className=\"relative grid size-4 place-items-center overflow-hidden transition-transform duration-200 ease-out-expo group-hover/top:-translate-y-px motion-reduce:transition-none\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  key={launches}\n                  className=\"grid place-items-center\"\n                  // Each press sends the arrow up and out, and a fresh one rises into place.\n                  initial={reduce ? { opacity: 0 } : { y: 12, opacity: 0 }}\n                  animate={{ y: 0, opacity: 1 }}\n                  exit={reduce ? { opacity: 0 } : { y: -14, opacity: 0, transition: { duration: 0.16, ease: ease.in } }}\n                  transition={reduce ? { duration: 0.12 } : { ...spring.pop, delay: 0.06 }}\n                >\n                  <ArrowUp />\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </span>\n          {showLabel && label}\n        </motion.button>\n      )}\n    </AnimatePresence>\n  );\n}\n\n/**\n * How far down the container is, drawn around the arrow. A native scroll\n * timeline drives it where supported; otherwise the scroll position scrubs it.\n */\nfunction ProgressRing({ scroller, className }: { scroller: React.RefObject<Scroller>; className?: string }) {\n  const arc = useRef<SVGCircleElement>(null);\n  useEffect(() => {\n    const el = arc.current;\n    if (!el) return;\n    const s = scroller.current;\n    const keyframes = [{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }];\n    const TL = nativeScrollTimeline();\n    if (TL) {\n      const anim = el.animate(keyframes, { fill: \"both\", timeline: new TL({ source: s ?? document.documentElement, axis: \"block\" }) } as KeyframeAnimationOptions);\n      return () => anim.cancel();\n    }\n    const anim = el.animate(keyframes, { duration: 1000, fill: \"both\" });\n    anim.pause();\n    const source: HTMLElement | Window = s ?? window;\n    let frame = 0;\n    const update = () => {\n      frame = 0;\n      const max = maxY(s);\n      anim.currentTime = max > 0 ? Math.min(1, readY(s) / max) * 1000 : 0;\n    };\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n    source.addEventListener(\"scroll\", onScroll, { passive: true });\n    update();\n    return () => {\n      source.removeEventListener(\"scroll\", onScroll);\n      cancelAnimationFrame(frame);\n      anim.cancel();\n    };\n  }, [scroller]);\n\n  return (\n    <svg viewBox=\"0 0 40 40\" className={cn(\"pointer-events-none -rotate-90\", className)} aria-hidden>\n      <circle cx=\"20\" cy=\"20\" r=\"19.25\" fill=\"none\" strokeWidth=\"1.5\" className=\"stroke-line-2 transition-[stroke] duration-150 group-hover/top:stroke-fg-4\" />\n      <circle ref={arc} cx=\"20\" cy=\"20\" r=\"19.25\" fill=\"none\" strokeWidth=\"1.5\" strokeLinecap=\"round\" pathLength={1} strokeDasharray=\"1 1\" strokeDashoffset={1} className=\"stroke-fg\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/back-to-top"
}
