{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scrub-text",
  "title": "Scrub Text",
  "description": "A paragraph that arrives word by word as you scroll, so the pace of reading stays in the reader's hand. People who turned motion off get the finished text.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/scrub-text.tsx",
      "content": "import type * as React from \"react\"\nimport { useRef } from \"react\"\nimport { motion, useReducedMotion, useScroll, useSpring, useTransform, type MotionValue } from \"motion/react\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype ScrubTextVariant = \"words\" | \"marker\" | \"rise\"\ntype ScrubTextEffect = \"smooth\"\n\n/** Starts as the first line nears the bottom of the view, ends with the last line above the middle. */\nconst SCRUB_OFFSET = [\"start 0.85\", \"end 0.5\"] as const\n/** Each word takes this many word-lengths to arrive, so a few are always mid-way and it reads as a wave. */\nconst WORD_OVERLAP = 4\n/** Dim enough to read as unread, bright enough that nobody thinks the text failed to load. */\nconst DIM_OPACITY = 0.24\n/** Critical damping (2 * sqrt(stiffness)) with the 0.35 s response of SPRING_DEFAULT. useSpring takes physics, not a duration. */\nconst SMOOTH_SPRING = { stiffness: 322, damping: 36, mass: 1, restDelta: 0.001 }\nconst PAGE_END = 0.999\n\nfunction splitWords(text: string) {\n  return text.trim().split(/\\s+/).filter(Boolean)\n}\n\n/** Where in the 0..1 scroll a word starts and finishes. First starts at 0 and last ends at 1, whatever the length. */\nfunction wordRange(index: number, count: number): [number, number] {\n  const span = Math.min(1, WORD_OVERLAP / Math.max(1, count))\n  const start = count > 1 ? (index / (count - 1)) * (1 - span) : 0\n  return [start, start + span]\n}\n\n/** A paragraph near the end of a page may never reach its end offset. Running out of page has to finish it, or it stays dim. */\nfunction settledProgress(progress: number, pageProgress: number) {\n  return pageProgress >= PAGE_END ? 1 : progress\n}\n\ninterface WordProps {\n  word: string\n  progress: MotionValue<number>\n  range: [number, number]\n}\n\nfunction FadeWord({ word, progress, range }: WordProps) {\n  const opacity = useTransform(progress, range, [DIM_OPACITY, 1])\n  return (\n    <>\n      <motion.span data-slot=\"scrub-text-word\" style={{ opacity }}>\n        {word}\n      </motion.span>{\" \"}\n    </>\n  )\n}\n\nfunction MarkerWord({ word, progress, range }: WordProps) {\n  const backgroundSize = useTransform(progress, range, [\"0% 100%\", \"100% 100%\"])\n  return (\n    // The space stays inside the span so the marker runs through it instead of leaving a gap at every word.\n    <motion.span\n      data-slot=\"scrub-text-word\"\n      className=\"bg-[linear-gradient(var(--mise-soft),var(--mise-soft))] bg-no-repeat box-decoration-clone\"\n      style={{ backgroundSize }}\n    >\n      {word}{\" \"}\n    </motion.span>\n  )\n}\n\nfunction RiseWord({ word, progress, range }: WordProps) {\n  const opacity = useTransform(progress, range, [0, 1])\n  const y = useTransform(progress, range, [\"0.5em\", \"0em\"])\n  return (\n    <>\n      <motion.span data-slot=\"scrub-text-word\" className=\"inline-block\" style={{ opacity, y }}>\n        {word}\n      </motion.span>{\" \"}\n    </>\n  )\n}\n\nconst WORD: Record<ScrubTextVariant, (props: WordProps) => React.JSX.Element> = {\n  words: FadeWord,\n  marker: MarkerWord,\n  rise: RiseWord,\n}\n\nconst ROOT_CLASS = \"min-w-0 text-lg leading-7 font-medium text-pretty\"\n\ntype ScrubTextProps = Omit<React.ComponentProps<\"p\">, \"children\"> & {\n  text: string\n  /** `words` from dim to full, `marker` a highlight sweeps under them, `rise` each word lifts into place. */\n  variant?: ScrubTextVariant\n  /** `smooth` eases the progress, so the steps of a mouse wheel do not show. */\n  effect?: ScrubTextEffect\n  /** The element that scrolls, when it is not the page. It needs `position: relative`. */\n  scrollRef?: React.RefObject<HTMLElement | null>\n}\n\nfunction ScrubWords({ text, variant = \"words\", effect, scrollRef, className, ...props }: ScrubTextProps) {\n  const ref = useRef<HTMLParagraphElement>(null)\n  const { scrollYProgress: passing } = useScroll({ target: ref, container: scrollRef, offset: [...SCRUB_OFFSET] })\n  const { scrollYProgress: page } = useScroll({ container: scrollRef })\n  const settled = useTransform([passing, page], ([progress, pageProgress]: number[]) => settledProgress(progress, pageProgress))\n  const eased = useSpring(settled, SMOOTH_SPRING)\n  const progress = effect === \"smooth\" ? eased : settled\n  const words = splitWords(text)\n  const Word = WORD[variant]\n  return (\n    <p ref={ref} data-slot=\"scrub-text\" data-variant={variant} data-effect={effect} className={cn(ROOT_CLASS, className)} {...props}>\n      {words.map((word, index) => (\n        <Word key={`${word}-${index}`} word={word} progress={progress} range={wordRange(index, words.length)} />\n      ))}\n    </p>\n  )\n}\n\nfunction ScrubText({ scrollRef, ...props }: ScrubTextProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  if (!reduceMotion) return <ScrubWords scrollRef={scrollRef} {...props} />\n  const { text, variant = \"words\", effect, className, ...rest } = props\n  return (\n    <p data-slot=\"scrub-text\" data-variant={variant} data-effect={effect} data-reduced=\"true\" className={cn(ROOT_CLASS, className)} {...rest}>\n      {text}\n    </p>\n  )\n}\n\nexport { ScrubText, settledProgress, splitWords, wordRange, type ScrubTextProps, type ScrubTextVariant, type ScrubTextEffect }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}