{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-reveal-text","title":"Scroll Reveal Text","author":"CoDesign-Spa27","description":"A scroll-linked text reveal with spring-smoothed progress and reduced-motion support.","dependencies":["motion"],"files":[{"path":"components/ui-components/scroll-reveal-text.tsx","content":"\"use client\";\n\nimport type { MotionValue } from \"motion/react\";\nimport { motion, useReducedMotion, useScroll, useSpring, useTransform } from \"motion/react\";\nimport type { ReactNode, RefObject } from \"react\";\nimport { useRef } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type ScrollRevealPhysics = {\n  bounce?: number;\n  damping?: number;\n  mass?: number;\n  stiffness?: number;\n  visualDuration?: number;\n};\n\nexport type ScrollRevealRange = {\n  end?: number;\n  start?: number;\n};\n\nexport type ScrollRevealTextProps = {\n  className?: string;\n  lines?: readonly string[];\n  mutedClassName?: string;\n  mutedColor?: string;\n  physics?: ScrollRevealPhysics;\n  revealClassName?: string;\n  revealColor?: string;\n  revealRange?: ScrollRevealRange;\n  scrollLength?: number;\n  scrollHint?: ReactNode;\n  scrollHintClassName?: string;\n  scrollHintPosition?: \"bottom\" | \"top\";\n  scrollContainerRef?: RefObject<HTMLElement | null>;\n  showScrollHint?: boolean;\n  stickyClassName?: string;\n  textClassName?: string;\n};\n\ntype RevealLineProps = {\n  index: number;\n  line: string;\n  lineCount: number;\n  mutedClassName?: string;\n  mutedColor?: string;\n  progress: MotionValue<number>;\n  revealClassName?: string;\n  revealColor?: string;\n  revealEnd: number;\n  revealStart: number;\n  shouldReduceMotion: boolean;\n};\n\nexport const DEFAULT_SCROLL_REVEAL_LINES = [\n  \"Interfaces should not just respond.\",\n  \"They should move with intention.\",\n  \"And make every interaction feel alive.\",\n] as const;\n\nexport const DEFAULT_SCROLL_REVEAL_PHYSICS = {\n  damping: 28,\n  mass: 0.8,\n  stiffness: 200,\n} as const;\n\nfunction RevealLine({\n  index,\n  line,\n  lineCount,\n  mutedClassName,\n  mutedColor,\n  progress,\n  revealClassName,\n  revealColor,\n  revealEnd,\n  revealStart,\n  shouldReduceMotion,\n}: RevealLineProps) {\n  const revealRange = revealEnd - revealStart;\n\n  const lineStart = revealStart + (index / lineCount) * revealRange;\n  const lineEnd = revealStart + ((index + 1) / lineCount) * revealRange;\n\n  const clipPath = useTransform(\n    progress,\n    [lineStart, lineEnd],\n    [\"inset(0 100% 0 0)\", \"inset(0 0% 0 0)\"],\n    {\n      clamp: true,\n    },\n  );\n\n  return (\n    <span className=\"relative block\">\n      <span\n        className={cn(\"text-neutral-300 dark:text-neutral-700\", mutedClassName)}\n        style={mutedColor ? { color: mutedColor } : undefined}\n      >\n        {line}\n      </span>\n\n      <motion.span\n        aria-hidden=\"true\"\n        className={cn(\"absolute inset-0 text-foreground\", revealClassName)}\n        style={{\n          clipPath: shouldReduceMotion ? \"inset(0 0% 0 0)\" : clipPath,\n          color: revealColor,\n        }}\n      >\n        {line}\n      </motion.span>\n    </span>\n  );\n}\n\nexport function ScrollRevealText({\n  className,\n  lines = DEFAULT_SCROLL_REVEAL_LINES,\n  mutedClassName,\n  mutedColor,\n  physics = DEFAULT_SCROLL_REVEAL_PHYSICS,\n  revealClassName,\n  revealColor,\n  revealRange,\n  scrollLength,\n  scrollHint = \"Scroll to reveal\",\n  scrollHintClassName,\n  scrollHintPosition = \"top\",\n  scrollContainerRef,\n  showScrollHint = true,\n  stickyClassName,\n  textClassName,\n}: ScrollRevealTextProps) {\n  const sectionRef = useRef<HTMLElement>(null);\n  const shouldReduceMotion = Boolean(useReducedMotion());\n\n  const safeLines = lines.map((line) => line.trim()).filter(Boolean);\n\n  const resolvedLines = safeLines.length > 0 ? safeLines : [...DEFAULT_SCROLL_REVEAL_LINES];\n  const revealStart = Math.min(Math.max(revealRange?.start ?? 0.08, 0), 0.95);\n  const revealEnd = Math.min(Math.max(revealRange?.end ?? 0.92, revealStart + 0.05), 1);\n\n  const { scrollYProgress } = useScroll({\n    container: scrollContainerRef,\n    target: sectionRef,\n    offset: [\"start start\", \"end end\"],\n  });\n\n  /*\n   * Smooths tiny trackpad and wheel changes without making\n   * the reveal feel delayed.\n   */\n  const smoothProgress = useSpring(\n    scrollYProgress,\n    physics.visualDuration !== undefined\n      ? {\n          bounce: physics.bounce,\n          visualDuration: physics.visualDuration,\n        }\n      : {\n          damping: physics.damping ?? DEFAULT_SCROLL_REVEAL_PHYSICS.damping,\n          mass: physics.mass ?? DEFAULT_SCROLL_REVEAL_PHYSICS.mass,\n          stiffness: physics.stiffness ?? DEFAULT_SCROLL_REVEAL_PHYSICS.stiffness,\n        },\n  );\n  const scrollHintOpacity = useTransform(smoothProgress, [0, 0.12, 0.22], [1, 1, 0]);\n  const scrollHintY = useTransform(smoothProgress, [0, 0.22], [0, -8]);\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-label=\"Scroll-revealed statement\"\n      className={cn(\"relative min-h-[240svh] w-full\", className)}\n      style={scrollLength ? { minHeight: `${Math.max(scrollLength, 100)}svh` } : undefined}\n    >\n      <div\n        className={cn(\n          \"sticky top-0 flex h-svh w-full items-center justify-center overflow-hidden px-6 py-20 sm:px-10\",\n          stickyClassName,\n        )}\n      >\n        <p\n          className={cn(\n            \"w-full max-w-6xl text-balance font-sans\",\n            \"text-[clamp(2rem,6vw,5.5rem)]\",\n            \"font-semibold leading-[1.02]\",\n            \"tracking-[-0.04em]\",\n            textClassName,\n          )}\n        >\n          {resolvedLines.map((line, index) => (\n            <RevealLine\n              key={line}\n              index={index}\n              line={line}\n              lineCount={resolvedLines.length}\n              mutedClassName={mutedClassName}\n              mutedColor={mutedColor}\n              progress={shouldReduceMotion ? scrollYProgress : smoothProgress}\n              revealClassName={revealClassName}\n              revealColor={revealColor}\n              revealEnd={revealEnd}\n              revealStart={revealStart}\n              shouldReduceMotion={shouldReduceMotion}\n            />\n          ))}\n        </p>\n\n        {showScrollHint && !shouldReduceMotion ? (\n          <div\n            className={cn(\n              \"pointer-events-none absolute inset-x-0 flex justify-center px-6\",\n              scrollHintPosition === \"top\"\n                ? \"top-[max(1.5rem,env(safe-area-inset-top))]\"\n                : \"bottom-[max(1.5rem,env(safe-area-inset-bottom))]\",\n            )}\n          >\n            <motion.div\n              aria-hidden=\"true\"\n              className={cn(\n                \"flex items-center gap-2 rounded-full border border-border bg-background py-1.5 pr-3 pl-1.5 text-foreground\",\n                scrollHintClassName,\n              )}\n              style={{ opacity: scrollHintOpacity, y: scrollHintY }}\n            >\n              <span className=\"grid size-5 place-items-center rounded-full bg-foreground text-xs text-background\">\n                ↓\n              </span>\n              <span className=\"font-mono text-[11px] font-medium tracking-[0.08em]\">\n                {scrollHint}\n              </span>\n            </motion.div>\n          </div>\n        ) : null}\n      </div>\n    </section>\n  );\n}\n","type":"registry:ui"}],"categories":["text","scroll"],"type":"registry:ui"}