{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-top-ring","type":"registry:ui","title":"Scroll top ring","description":"A back-to-top button whose ring fills as you read. It springs in once you have scrolled past the threshold, stays in the corner of whatever scrolls, and returns you to the top with focus at the start of the content.","dependencies":["clsx","lucide-react","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/lib/scroll-view.ts","type":"registry:ui","target":"components/ui/lib/scroll-view.ts","content":"/** The nearest ancestor that scrolls vertically, or null for the page. */\nexport function scrollParent(element: HTMLElement): HTMLElement | null {\n  for (let node = element.parentElement; node && node !== document.body; node = node.parentElement) {\n    const { overflowY } = getComputedStyle(node);\n    if ((overflowY === \"auto\" || overflowY === \"scroll\") && node.scrollHeight > node.clientHeight) return node;\n  }\n  return null;\n}\n\n/** The visible band of a scroller in viewport coordinates: its top edge and height (the window when null). */\nexport function viewOf(scroller: HTMLElement | null) {\n  if (!scroller) return { top: 0, height: window.innerHeight };\n  return { top: scroller.getBoundingClientRect().top + scroller.clientTop, height: scroller.clientHeight };\n}\n"},{"path":"ui/scroll-top-ring.tsx","type":"registry:ui","target":"components/ui/scroll-top-ring.tsx","content":"\"use client\";\nimport { ArrowUp } from \"lucide-react\";\nimport { useEffect, useRef, useState, type ComponentProps } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport { scrollParent } from \"./lib/scroll-view\";\nimport \"./scroll-top-ring.css\";\n\nexport type ScrollTopRingProps = Omit<ComponentProps<\"button\">, \"children\"> & {\n  /** Pixels scrolled before the button appears. */\n  threshold?: number;\n  label?: string;\n  /** The element that receives focus after the jump, by id; the top of the content when not given. */\n  focusTarget?: string;\n};\n\n/**\n * A back-to-top button whose ring fills as you read. It springs in once you have scrolled past the threshold, stays\n * in the corner of whatever scrolls (the page or a panel), and returns you to the top with focus at the start of\n * the content. Place it last inside the scrolling content. Under reduced motion it appears without the spring and\n * the jump is instant.\n */\nexport function ScrollTopRing({ threshold = 400, label = \"Back to top\", focusTarget, className, onClick, ...props }: ScrollTopRingProps) {\n  const button = useRef<HTMLButtonElement>(null);\n  const ring = useRef<SVGCircleElement>(null);\n  const [shown, setShown] = useState(false);\n\n  useEffect(() => {\n    const el = button.current;\n    if (!el) return;\n    const scroller = scrollParent(el);\n    let frame = 0;\n    const update = () => {\n      frame = 0;\n      const top = scroller ? scroller.scrollTop : scrollY;\n      const room = scroller ? scroller.scrollHeight - scroller.clientHeight : document.documentElement.scrollHeight - innerHeight;\n      setShown(top > threshold);\n      ring.current?.style.setProperty(\"--str-p\", String(room > 0 ? Math.min(1, top / room) : 0));\n    };\n    const schedule = () => (frame ||= requestAnimationFrame(update));\n    const target = scroller ?? window;\n    schedule();\n    target.addEventListener(\"scroll\", schedule, { passive: true });\n    return () => {\n      cancelAnimationFrame(frame);\n      target.removeEventListener(\"scroll\", schedule);\n    };\n  }, [threshold]);\n\n  return (\n    <button\n      ref={button}\n      type=\"button\"\n      aria-label={label}\n      data-slot=\"scroll-top-ring\"\n      data-shown={shown ? \"\" : undefined}\n      tabIndex={shown ? undefined : -1}\n      className={cn(className)}\n      onClick={(event) => {\n        onClick?.(event);\n        const scroller = scrollParent(event.currentTarget);\n        const still = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n        (scroller ?? window).scrollTo({ top: 0, behavior: still ? \"auto\" : \"smooth\" });\n        const goal = (focusTarget && document.getElementById(focusTarget)) || (scroller ?? document.body).querySelector<HTMLElement>(\"h1, h2, h3, h4, h5, h6\");\n        if (goal) {\n          if (!goal.hasAttribute(\"tabindex\")) goal.setAttribute(\"tabindex\", \"-1\");\n          goal.focus({ preventScroll: true });\n        }\n      }}\n      {...props}\n    >\n      <svg viewBox=\"0 0 44 44\" aria-hidden=\"true\">\n        <circle data-slot=\"str-track\" cx={22} cy={22} r={20} />\n        <circle ref={ring} data-slot=\"str-fill\" cx={22} cy={22} r={20} pathLength={1} />\n      </svg>\n      <ArrowUp aria-hidden=\"true\" />\n    </button>\n  );\n}\n"},{"path":"ui/scroll-top-ring.css","type":"registry:file","target":"components/ui/scroll-top-ring.css","content":"/* scroll-top-ring.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Scroll top ring: sticky in the scroller's corner; the ring is the reading progress. */\n  [data-slot=\"scroll-top-ring\"] { position: sticky; bottom: 16px; z-index: 5; display: grid; place-items: center; width: 44px; height: 44px; margin: 0 16px 0 auto; padding: 0; border: 0; border-radius: 50%; background: var(--background); color: var(--foreground); box-shadow: var(--shadow-overlay); cursor: pointer; opacity: 0; scale: 0.6; translate: 0 12px; pointer-events: none; transition: opacity var(--duration-normal) var(--ease-out), scale var(--duration-travel) var(--ease-settle), translate var(--duration-travel) var(--ease-settle); }\n  [data-slot=\"scroll-top-ring\"][data-shown] { opacity: 1; scale: 1; translate: 0 0; pointer-events: auto; }\n  [data-slot=\"scroll-top-ring\"] svg { position: absolute; inset: 0; width: 100%; height: 100%; rotate: -90deg; }\n  [data-slot=\"scroll-top-ring\"] circle { fill: none; stroke-width: 2.5; }\n  [data-slot=\"scroll-top-ring\"] [data-slot=\"str-track\"] { stroke: var(--border); }\n  [data-slot=\"scroll-top-ring\"] [data-slot=\"str-fill\"] { stroke: var(--foreground); stroke-linecap: round; stroke-dasharray: var(--str-p, 0) 1; transition: stroke-dasharray 0.12s linear; }\n  [data-slot=\"scroll-top-ring\"] > svg + svg { position: static; width: 18px; height: 18px; rotate: none; }\n  @media (hover: hover) { [data-slot=\"scroll-top-ring\"][data-shown]:hover > svg + svg { translate: 0 -2px; transition: translate var(--duration-normal) var(--ease-settle); } }\n  [data-slot=\"scroll-top-ring\"]:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"scroll-top-ring\"], [data-slot=\"scroll-top-ring\"] [data-slot=\"str-fill\"] { transition: none; }\n  }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"browse","docs":"/docs/components/scroll-top-ring"}}