{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-progress-ring","type":"registry:ui","title":"Scroll Progress Ring","description":"A floating ring that shows how far a page has been read and turns into a back-to-top button at the end.","dependencies":[],"registryDependencies":["https://ui.mekaigames.com/r/theme.json"],"files":[{"path":"components/ui/scroll-progress-ring.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type MouseEvent, type RefObject } from \"react\";\n\nexport type ScrollProgressRingProps = {\n  /** Scrolling element to follow. Leave it out to follow the page. With a target the ring is absolutely positioned, so wrap both in a relative parent. */\n  target?: RefObject<HTMLElement | null>;\n  /** Diameter of the ring, in pixels. */\n  size?: number;\n  /** Thickness of the line, in pixels. */\n  thickness?: number;\n  /** Color of the progress line and of the finished button. */\n  color?: string;\n  /** Color of the arrow on the finished button. */\n  iconColor?: string;\n  /** Show the percentage inside the ring. */\n  showPercent?: boolean;\n  /** Progress at which the ring appears, from 0 to 1. */\n  showAfter?: number;\n  /** Progress at which it turns into the back-to-top button, from 0 to 1. */\n  endAt?: number;\n  /** Corner of the page it floats in. */\n  side?: \"left\" | \"right\";\n  /** Distance from the corner, in pixels. */\n  offset?: number;\n  /** Accessible name of the button. */\n  label?: string;\n  /** Called when the button is pressed. */\n  onBackToTop?: () => void;\n  className?: string;\n};\n\nconst EASE = \"ease-[cubic-bezier(0.2,0.9,0.25,1.2)]\";\n\n/** A floating ring that shows how far a page has been read and turns into a back-to-top button at the end. */\nexport function ScrollProgressRing({\n  target,\n  size = 56,\n  thickness = 3,\n  color = \"#ff784f\",\n  iconColor = \"#070606\",\n  showPercent = true,\n  showAfter = 0.03,\n  endAt = 0.985,\n  side = \"right\",\n  offset = 24,\n  label = \"Back to top\",\n  onBackToTop,\n  className = \"\",\n}: ScrollProgressRingProps) {\n  const button = useRef<HTMLButtonElement>(null);\n  const line = useRef<SVGCircleElement>(null);\n  const percent = useRef<HTMLSpanElement>(null);\n  const [visible, setVisible] = useState(false);\n  const [done, setDone] = useState(false);\n\n  useEffect(() => {\n    const scroller = target?.current ?? null;\n    const metrics = scroller ?? document.documentElement;\n    let frame = 0;\n    let shown = -1;\n\n    // Scrolling writes straight to the DOM. React only renders when the ring appears or finishes.\n    const read = () => {\n      frame = 0;\n      const range = metrics.scrollHeight - metrics.clientHeight;\n      const top = scroller ? scroller.scrollTop : window.scrollY;\n      const progress = range > 1 ? Math.min(1, Math.max(0, top / range)) : 0;\n      const whole = Math.round(progress * 100);\n      if (line.current) line.current.style.strokeDashoffset = String(1 - progress);\n      if (whole !== shown) {\n        shown = whole;\n        if (percent.current) percent.current.textContent = String(whole);\n        button.current?.setAttribute(\"aria-label\", `${label}. ${whole}% read.`);\n      }\n      setVisible(range > 1 && progress >= showAfter);\n      setDone(range > 1 && progress >= endAt);\n    };\n    const schedule = () => {\n      if (!frame) frame = requestAnimationFrame(read);\n    };\n\n    const source = scroller ?? window;\n    source.addEventListener(\"scroll\", schedule, { passive: true });\n    window.addEventListener(\"resize\", schedule);\n    // Images and late content change the length of the page without a scroll.\n    const resize = new ResizeObserver(schedule);\n    if (scroller) Array.from(scroller.children).forEach((child) => resize.observe(child));\n    else resize.observe(document.documentElement);\n    schedule();\n\n    return () => {\n      cancelAnimationFrame(frame);\n      source.removeEventListener(\"scroll\", schedule);\n      window.removeEventListener(\"resize\", schedule);\n      resize.disconnect();\n    };\n  }, [endAt, label, showAfter, target]);\n\n  function toTop(e: MouseEvent<HTMLButtonElement>) {\n    const scroller = target?.current ?? null;\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    (scroller ?? window).scrollTo({ top: 0, behavior: reduce ? \"auto\" : \"smooth\" });\n    // The button hides at the top, so keyboard focus moves to the start of the content.\n    if (e.detail === 0) {\n      const home = scroller ?? document.body;\n      if (!home.hasAttribute(\"tabindex\")) {\n        home.setAttribute(\"tabindex\", \"-1\");\n        home.style.setProperty(\"outline\", \"none\");\n        home.addEventListener(\n          \"blur\",\n          () => {\n            home.removeAttribute(\"tabindex\");\n            home.style.removeProperty(\"outline\");\n          },\n          { once: true },\n        );\n      }\n      home.focus({ preventScroll: true });\n    }\n    onBackToTop?.();\n  }\n\n  const radius = (size - thickness) / 2;\n\n  return (\n    <button\n      ref={button}\n      type=\"button\"\n      aria-label={label}\n      onClick={toTop}\n      className={`group ${target ? \"absolute\" : \"fixed\"} z-50 grid place-items-center rounded-full shadow-[0_14px_34px_-12px_rgb(0_0_0/0.85)] transition-[opacity,scale,visibility] duration-300 focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-ember ${EASE} ${\n        visible ? \"visible scale-100 opacity-100 hover:scale-105 active:scale-95\" : \"invisible scale-75 opacity-0\"\n      } ${className}`}\n      style={{\n        width: size,\n        height: size,\n        [side]: offset,\n        bottom: `calc(${offset}px + env(safe-area-inset-bottom, 0px))`,\n      }}\n    >\n      <span aria-hidden=\"true\" className=\"absolute inset-0 rounded-full bg-ink-850/90 backdrop-blur-md\" />\n      <span\n        aria-hidden=\"true\"\n        className={`absolute inset-0 rounded-full transition-[scale] duration-500 ${EASE}`}\n        style={{ background: color, scale: done ? 1 : 0 }}\n      />\n      <svg aria-hidden=\"true\" width={size} height={size} className=\"absolute inset-0 -rotate-90\">\n        <circle cx={size / 2} cy={size / 2} r={radius} fill=\"none\" stroke=\"rgb(255 255 255 / 0.12)\" strokeWidth={thickness} />\n        {/* pathLength lets the dash work in fractions of the circle, whatever its size. */}\n        <circle\n          ref={line}\n          cx={size / 2}\n          cy={size / 2}\n          r={radius}\n          fill=\"none\"\n          stroke={color}\n          strokeWidth={thickness}\n          strokeLinecap=\"round\"\n          pathLength={1}\n          strokeDasharray={1}\n          strokeDashoffset={1}\n          className=\"transition-[stroke-dashoffset] duration-150 ease-out\"\n        />\n      </svg>\n      {showPercent ? (\n        <span\n          aria-hidden=\"true\"\n          className={`absolute inset-0 grid place-items-center font-display font-bold tabular-nums text-bone transition-[opacity,scale] duration-200 ${\n            done ? \"scale-50 opacity-0\" : \"group-hover:scale-75 group-hover:opacity-0 group-focus-visible:scale-75 group-focus-visible:opacity-0\"\n          }`}\n          style={{ fontSize: Math.max(10, size * 0.23) }}\n        >\n          <span>\n            <span ref={percent}>0</span>\n            <span className=\"opacity-60\" style={{ fontSize: \"0.72em\" }}>\n              %\n            </span>\n          </span>\n        </span>\n      ) : null}\n      <svg\n        aria-hidden=\"true\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        strokeWidth=\"2.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        style={{ width: size * 0.4, height: size * 0.4, stroke: done ? iconColor : color }}\n        className={`relative transition-[opacity,translate] duration-300 ${EASE} ${\n          done || !showPercent\n            ? \"translate-y-0 opacity-100\"\n            : \"translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100\"\n        }`}\n      >\n        <path d=\"M12 19V5M5.5 11.5 12 5l6.5 6.5\" />\n      </svg>\n    </button>\n  );\n}\n"}]}