{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"reading-progress","type":"registry:component","title":"Reading progress","description":"A progress bar fills as you read while the time left counts down.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/reading-progress.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Section = { heading?: string; paragraphs: string[] };\n\n// A common average for silent reading of screen text.\nconst WORDS_PER_MINUTE = 220;\n// Far enough in that it is clear the reader has left the top, and a jump\n// back is worth a button.\nconst SHOW_TOP_AFTER = 0.3;\n// Scroll positions land a few pixels short of the true end on some\n// trackpads; this still counts as finished.\nconst END_SLACK = 4;\nconst SWAP = { duration: 0.2, ease: [0.23, 1, 0.32, 1] } as const;\n\n// Runs on the compositor where scroll-driven animations exist, so the bar\n// never waits on the main thread. The inline scaleX(0) is the fallback's\n// starting point; an animation outranks it where supported.\nconst BAR_CSS = `\n@keyframes reading-progress-fill { from { transform: scaleX(0) } to { transform: scaleX(1) } }\n@supports (animation-timeline: scroll()) {\n  .reading-progress-bar { animation: reading-progress-fill linear both; animation-timeline: scroll(nearest block); }\n}`;\n\nconst countWords = (text: string) => text.split(/\\s+/).filter(Boolean).length;\n\nfunction label(minutes: number) {\n  return minutes === 0 ? \"Finished\" : `${minutes} min left`;\n}\n\nexport function ReadingProgress({\n  title,\n  sections,\n  className,\n}: {\n  title: string;\n  sections: Section[];\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const scroller = useRef<HTMLDivElement>(null);\n  const bar = useRef<HTMLDivElement>(null);\n  const heading = useRef<HTMLHeadingElement>(null);\n  const totalWords = sections.reduce(\n    (sum, s) => sum + countWords([s.heading ?? \"\", ...s.paragraphs].join(\" \")),\n    0,\n  );\n  const [minutes, setMinutes] = useState(() => Math.ceil(totalWords / WORDS_PER_MINUTE));\n  const [showTop, setShowTop] = useState(false);\n\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const native = CSS.supports(\"animation-timeline: scroll()\");\n    let frame = 0;\n    // Batched to one read per frame. React only hears about it when the\n    // rounded minutes or the button's visibility actually change.\n    const update = () => {\n      frame = 0;\n      const range = el.scrollHeight - el.clientHeight;\n      const progress = range > 0 ? el.scrollTop / range : 1;\n      if (!native && bar.current) bar.current.style.transform = `scaleX(${progress})`;\n      const done = el.scrollTop >= range - END_SLACK;\n      setMinutes(done ? 0 : Math.max(1, Math.ceil((totalWords * (1 - progress)) / WORDS_PER_MINUTE)));\n      setShowTop(progress > SHOW_TOP_AFTER);\n    };\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => {\n      el.removeEventListener(\"scroll\", onScroll);\n      cancelAnimationFrame(frame);\n    };\n  }, [totalWords]);\n\n  const backToTop = () => {\n    scroller.current?.scrollTo({ top: 0, behavior: reduceMotion ? \"auto\" : \"smooth\" });\n    // The button is about to hide, so hand focus to the start of the article\n    // instead of letting it fall back to the page.\n    heading.current?.focus({ preventScroll: true });\n  };\n\n  const text = label(minutes);\n\n  return (\n    <div\n      className={cn(\n        \"relative h-[360px] w-[480px] max-w-full overflow-hidden rounded-2xl bg-muted shadow-raised\",\n        className,\n      )}\n    >\n      <style>{BAR_CSS}</style>\n      <div\n        ref={scroller}\n        tabIndex={0}\n        role=\"region\"\n        aria-label=\"Reading article\"\n        className=\"h-full overflow-y-auto overscroll-contain outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-foreground\"\n      >\n        {/* Sticky inside the scroller, so scroll() finds this container as\n            its nearest scroll ancestor. */}\n        <div className=\"sticky top-0 z-10 bg-muted\">\n          <div\n            ref={bar}\n            aria-hidden\n            style={{ transform: \"scaleX(0)\" }}\n            className=\"reading-progress-bar h-0.5 origin-left bg-foreground\"\n          />\n          <div className=\"flex h-11 items-center justify-between gap-4 border-b border-border px-6 text-[13px] text-muted-foreground\">\n            <span className=\"truncate\">Design notes</span>\n            <span className=\"relative flex justify-end tabular-nums whitespace-nowrap\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={text}\n                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, filter: \"blur(4px)\", y: 4 }}\n                  animate={{ opacity: 1, filter: \"blur(0px)\", y: 0 }}\n                  exit={\n                    reduceMotion\n                      ? { opacity: 0, transition: { duration: 0.12 } }\n                      : { opacity: 0, filter: \"blur(4px)\", y: -4, transition: { duration: 0.12 } }\n                  }\n                  transition={SWAP}\n                >\n                  {text}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </div>\n        </div>\n\n        <article className=\"px-6 pt-5 pb-16 text-[15px] leading-relaxed text-pretty text-muted-foreground\">\n          <h2\n            ref={heading}\n            tabIndex={-1}\n            className=\"rounded-sm text-xl font-semibold text-balance text-foreground outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\"\n          >\n            {title}\n          </h2>\n          {sections.map((section, i) => (\n            <section key={i}>\n              {section.heading && (\n                <h3 className=\"mt-6 text-base font-medium text-foreground\">{section.heading}</h3>\n              )}\n              {section.paragraphs.map((p) => (\n                <p key={p.slice(0, 24)} className=\"mt-3\">\n                  {p}\n                </p>\n              ))}\n            </section>\n          ))}\n        </article>\n      </div>\n\n      {/* Outside the scroller, so it stays pinned to the corner. Comes in\n          over 200ms and leaves in 150ms; while hidden it is inert, so it can\n          never be tabbed to or clicked. */}\n      <button\n        type=\"button\"\n        onClick={backToTop}\n        inert={!showTop}\n        aria-label=\"Back to top\"\n        className={cn(\n          \"absolute right-4 bottom-4 flex size-10 touch-manipulation items-center justify-center rounded-full bg-background text-foreground shadow-raised outline-hidden select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\",\n          \"transition-[opacity,translate,scale] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:translate-y-0 motion-reduce:transition-[opacity]\",\n          showTop ? \"translate-y-0 opacity-100 duration-200\" : \"translate-y-2 opacity-0 duration-150\",\n        )}\n      >\n        <svg\n          viewBox=\"0 0 16 16\"\n          aria-hidden\n          className=\"size-4\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <path d=\"M8 12.5v-9M4 7l4-4 4 4\" />\n        </svg>\n      </button>\n    </div>\n  );\n}\n\nconst ARTICLE: Section[] = [\n  {\n    paragraphs: [\n      \"Most interface motion goes unnoticed, and that is the goal. When a menu opens from the button you pressed, or a toast leaves the way it came in, nobody stops to admire it. They simply understand what happened and carry on. Good motion answers a question before the user thinks to ask it.\",\n      \"Bad motion is the opposite. It asks for attention it has not earned. A dialog that takes half a second to appear feels slow even when the network is instant, and a list that staggers in on every visit turns a quick glance into a small wait. The cost is paid every single time the thing happens.\",\n    ],\n  },\n  {\n    heading: \"Start from frequency\",\n    paragraphs: [\n      \"The first question is how often someone will see an animation. Something used a hundred times a day, like a command palette, should appear at once. Something seen a few times a day, like a drawer or a modal, can take around two hundred milliseconds. Something rare, like finishing onboarding, has room for a little delight.\",\n      \"Easing matters as much as duration. An ease-out curve moves quickly at the start, so the interface responds the moment you act, then settles gently. An ease-in curve does the reverse and feels sluggish, because the slow part lands exactly when you are watching most closely.\",\n    ],\n  },\n  {\n    heading: \"Motion as explanation\",\n    paragraphs: [\n      \"The best animations explain structure. A panel that slides in from the right tells you it can be dismissed by sliding it back. A highlight that glides between tabs tells you the tabs belong together. Change the element without motion and you lose that small lesson about how the interface is built.\",\n      \"Finally, motion should always be interruptible. People change their minds mid-gesture, click twice, or press Escape halfway through an opening. Transitions and springs can reverse from wherever they are; fixed keyframes restart from zero and feel broken. Build for the interruption and the rest tends to follow.\",\n    ],\n  },\n];\n\nexport default function ReadingProgressDemo() {\n  return <ReadingProgress title=\"Motion people never notice\" sections={ARTICLE} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/reading-progress"}