{"slug":"scroll-animation","name":"Scroll Animation","description":"Scroll-driven motion: a Lenis smooth-scroll provider and a reading-progress indicator that reads from it.","category":"motion","source_url":"https://beui.dev/r/scroll-animation/raw","detail_url":"https://beui.dev/r/scroll-animation","raw_url":"https://beui.dev/r/scroll-animation/raw","page_url":"https://beui.dev/components/motion/scroll-animation","dependencies":["clsx","lenis","motion","react","tailwind-merge"],"internal":["@/components/motion/smooth-scroll","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/smooth-scroll.tsx","type":"component","content":"\"use client\";\n\nimport type Lenis from \"lenis\";\nimport { ReactLenis, useLenis } from \"lenis/react\";\nimport { type MotionValue, useMotionValue, useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n} from \"react\";\n\n// Lenis' own expo-out curve — the canonical smooth-scroll easing. Kept as a\n// named local fn (not a lib/ease token) because tokens are bezier control\n// points for the motion lib, while Lenis needs a (t) => number easing fn.\nconst EASE_SCROLL = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t));\n\nexport type ScrollTarget = number | string | HTMLElement;\n\nexport type ScrollToOptions = {\n  offset?: number;\n  immediate?: boolean;\n  duration?: number;\n};\n\nexport type SmoothScrollApi = {\n  /** Underlying Lenis instance, or null on the reduced-motion / native path. */\n  lenis: Lenis | null;\n  /** Current scroll offset in px. */\n  scrollY: MotionValue<number>;\n  /** Scroll position as 0..1 of the scrollable height. */\n  progress: MotionValue<number>;\n  /** Signed scroll velocity (px/frame); drives velocity-based effects. */\n  velocity: MotionValue<number>;\n  /** Programmatic smooth scroll. Respects reduced motion (jumps instantly). */\n  scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;\n};\n\nconst SmoothScrollContext = createContext<SmoothScrollApi | null>(null);\n\nexport interface SmoothScrollProps {\n  children: ReactNode;\n  /** Drive the page (window) when true, or a contained scroll area when false. */\n  root?: boolean;\n  /** Smoothing factor; lower is smoother and heavier. */\n  lerp?: number;\n  /** Wheel / programmatic ease duration in seconds. */\n  duration?: number;\n  orientation?: \"vertical\" | \"horizontal\";\n  /** Wheel scroll speed multiplier. */\n  wheelMultiplier?: number;\n  /** Smooth touch scrolling. Off by default — native momentum is good on mobile. */\n  touch?: boolean;\n  className?: string;\n}\n\ntype ScrollSource = Window | HTMLElement;\n\nfunction readMetrics(target: ScrollSource) {\n  if (target instanceof Window) {\n    const max = Math.max(\n      0,\n      document.documentElement.scrollHeight - window.innerHeight,\n    );\n    return { y: window.scrollY, max };\n  }\n  return {\n    y: target.scrollTop,\n    max: Math.max(0, target.scrollHeight - target.clientHeight),\n  };\n}\n\nfunction resolveTop(\n  target: ScrollTarget,\n  source: ScrollSource,\n  offset = 0,\n): number {\n  if (typeof target === \"number\") return target + offset;\n  if (source instanceof Window) {\n    const el =\n      typeof target === \"string\" ? document.querySelector(target) : target;\n    if (!el) return window.scrollY;\n    return el.getBoundingClientRect().top + window.scrollY + offset;\n  }\n  const el =\n    typeof target === \"string\" ? source.querySelector(target) : target;\n  if (!(el instanceof HTMLElement)) return source.scrollTop;\n  return el.offsetTop + offset;\n}\n\n/** Pushes Lenis' live scroll state into the shared motion values. */\nfunction LenisBridge({\n  scrollY,\n  progress,\n  velocity,\n  lenisRef,\n}: {\n  scrollY: MotionValue<number>;\n  progress: MotionValue<number>;\n  velocity: MotionValue<number>;\n  lenisRef: { current: Lenis | null };\n}) {\n  const lenis = useLenis((instance) => {\n    scrollY.set(instance.scroll);\n    progress.set(instance.progress);\n    velocity.set(instance.velocity);\n  });\n  useEffect(() => {\n    lenisRef.current = lenis ?? null;\n    return () => {\n      lenisRef.current = null;\n    };\n  }, [lenis, lenisRef]);\n  return null;\n}\n\n/** Native scroll listener for the reduced-motion path and the no-provider fallback. */\nfunction useNativeScrollSync(\n  enabled: boolean,\n  getTarget: () => ScrollSource | null,\n  scrollY: MotionValue<number>,\n  progress: MotionValue<number>,\n  velocity: MotionValue<number>,\n) {\n  useEffect(() => {\n    if (!enabled) return;\n    const target = getTarget();\n    if (!target) return;\n    let lastY = readMetrics(target).y;\n    let lastT = performance.now();\n    const onScroll = () => {\n      const { y, max } = readMetrics(target);\n      const now = performance.now();\n      const dt = now - lastT || 16;\n      scrollY.set(y);\n      progress.set(max > 0 ? y / max : 0);\n      velocity.set(((y - lastY) / dt) * 16);\n      lastY = y;\n      lastT = now;\n    };\n    onScroll();\n    target.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => target.removeEventListener(\"scroll\", onScroll);\n  }, [enabled, getTarget, scrollY, progress, velocity]);\n}\n\nexport function SmoothScroll({\n  children,\n  root = true,\n  lerp = 0.1,\n  duration = 1.2,\n  orientation = \"vertical\",\n  wheelMultiplier = 1,\n  touch = false,\n  className,\n}: SmoothScrollProps) {\n  const reduce = useReducedMotion();\n  const scrollY = useMotionValue(0);\n  const progress = useMotionValue(0);\n  const velocity = useMotionValue(0);\n  const lenisRef = useRef<Lenis | null>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const nativeSource = useCallback(\n    (): ScrollSource | null => (root ? window : containerRef.current),\n    [root],\n  );\n\n  const scrollTo = useCallback(\n    (target: ScrollTarget, options?: ScrollToOptions) => {\n      const lenis = lenisRef.current;\n      if (lenis && !reduce) {\n        lenis.scrollTo(target, {\n          offset: options?.offset,\n          duration: options?.duration,\n          immediate: options?.immediate,\n        });\n        return;\n      }\n      const source = nativeSource();\n      const behavior = reduce || options?.immediate ? \"auto\" : \"smooth\";\n      const top = resolveTop(target, source ?? window, options?.offset);\n      (source ?? window).scrollTo({ top, behavior });\n    },\n    [reduce, nativeSource],\n  );\n\n  // Reduced motion drives the native listener; the Lenis path leaves it\n  // disabled and lets LenisBridge feed the values instead.\n  useNativeScrollSync(!!reduce, nativeSource, scrollY, progress, velocity);\n\n  const api = useMemo<SmoothScrollApi>(\n    () => ({ lenis: lenisRef.current, scrollY, progress, velocity, scrollTo }),\n    [scrollY, progress, velocity, scrollTo],\n  );\n\n  if (reduce) {\n    return (\n      <SmoothScrollContext.Provider value={api}>\n        <div ref={containerRef} className={className}>\n          {children}\n        </div>\n      </SmoothScrollContext.Provider>\n    );\n  }\n\n  return (\n    <SmoothScrollContext.Provider value={api}>\n      <ReactLenis\n        root={root}\n        className={className}\n        options={{\n          lerp,\n          duration,\n          orientation,\n          wheelMultiplier,\n          smoothWheel: true,\n          syncTouch: touch,\n          easing: EASE_SCROLL,\n        }}\n      >\n        <LenisBridge\n          scrollY={scrollY}\n          progress={progress}\n          velocity={velocity}\n          lenisRef={lenisRef}\n        />\n        {children}\n      </ReactLenis>\n    </SmoothScrollContext.Provider>\n  );\n}\n\n/**\n * Read the page's smooth-scroll state. Inside <SmoothScroll> it returns the\n * shared motion values; outside it falls back to a native window scroll\n * listener so scroll-driven components still work without the provider.\n */\nexport function useSmoothScroll(): SmoothScrollApi {\n  const ctx = useContext(SmoothScrollContext);\n  const scrollY = useMotionValue(0);\n  const progress = useMotionValue(0);\n  const velocity = useMotionValue(0);\n\n  const windowSource = useCallback((): ScrollSource => window, []);\n  useNativeScrollSync(ctx === null, windowSource, scrollY, progress, velocity);\n\n  const scrollTo = useCallback((target: ScrollTarget, options?: ScrollToOptions) => {\n    window.scrollTo({\n      top: resolveTop(target, window, options?.offset),\n      behavior: options?.immediate ? \"auto\" : \"smooth\",\n    });\n  }, []);\n\n  const fallback = useMemo<SmoothScrollApi>(\n    () => ({ lenis: null, scrollY, progress, velocity, scrollTo }),\n    [scrollY, progress, velocity, scrollTo],\n  );\n\n  return ctx ?? fallback;\n}\n"},{"path":"components/motion/scroll-progress.tsx","type":"component","content":"\"use client\";\n\nimport {\n  type MotionValue,\n  motion,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\n\nimport { useSmoothScroll } from \"@/components/motion/smooth-scroll\";\nimport { cn } from \"@/lib/utils\";\n\n// Soft follow so the indicator trails the scroll smoothly instead of snapping;\n// looser than the UI springs in lib/ease.ts on purpose.\nconst PROGRESS_SPRING = { stiffness: 120, damping: 30, mass: 0.6 };\n\ntype CommonProps = {\n  /** Override the scroll source. Defaults to the page via useSmoothScroll. */\n  progress?: MotionValue<number>;\n  /** Spring-smooth the value. Disabled automatically under reduced motion. */\n  spring?: boolean;\n  className?: string;\n};\n\nexport interface ScrollProgressBarProps extends CommonProps {\n  variant?: \"bar\";\n  position?: \"top\" | \"bottom\";\n  /** Bar thickness in px. */\n  height?: number;\n  /** Position the bar with `fixed` (page) or `absolute` (embedded). */\n  fixed?: boolean;\n}\n\nexport interface ScrollProgressCircleProps extends CommonProps {\n  variant: \"circle\";\n  /** Diameter in px. */\n  size?: number;\n  /** Stroke width in px. */\n  thickness?: number;\n}\n\nexport type ScrollProgressProps =\n  | ScrollProgressBarProps\n  | ScrollProgressCircleProps;\n\nfunction useProgressValue(source: MotionValue<number> | undefined, spring: boolean) {\n  const reduce = useReducedMotion();\n  const fallback = useSmoothScroll().progress;\n  const raw = source ?? fallback;\n  const smoothed = useSpring(raw, PROGRESS_SPRING);\n  return spring && !reduce ? smoothed : raw;\n}\n\nexport function ScrollProgress(props: ScrollProgressProps) {\n  if (props.variant === \"circle\") return <ScrollProgressCircle {...props} />;\n  return <ScrollProgressBar {...props} />;\n}\n\nfunction ScrollProgressBar({\n  progress,\n  spring = true,\n  position = \"top\",\n  height = 2,\n  fixed = true,\n  className,\n}: ScrollProgressBarProps) {\n  const value = useProgressValue(progress, spring);\n  return (\n    <motion.div\n      aria-hidden\n      style={{ height, scaleX: value }}\n      className={cn(\n        \"left-0 right-0 z-50 origin-left bg-foreground\",\n        fixed ? \"fixed\" : \"absolute\",\n        position === \"top\" ? \"top-0\" : \"bottom-0\",\n        className,\n      )}\n    />\n  );\n}\n\nfunction ScrollProgressCircle({\n  progress,\n  spring = true,\n  size = 40,\n  thickness = 3,\n  className,\n}: ScrollProgressCircleProps) {\n  const value = useProgressValue(progress, spring);\n  const radius = (size - thickness) / 2;\n  const circumference = 2 * Math.PI * radius;\n  const offset = useTransform(value, (v) => circumference * (1 - v));\n\n  return (\n    <svg\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      role=\"presentation\"\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      className={cn(\"text-foreground\", className)}\n    >\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={radius}\n        fill=\"none\"\n        strokeWidth={thickness}\n        className=\"stroke-current opacity-15\"\n      />\n      <motion.circle\n        cx={size / 2}\n        cy={size / 2}\n        r={radius}\n        fill=\"none\"\n        strokeWidth={thickness}\n        strokeLinecap=\"round\"\n        className=\"stroke-current\"\n        strokeDasharray={circumference}\n        style={{ strokeDashoffset: offset }}\n        transform={`rotate(-90 ${size / 2} ${size / 2})`}\n      />\n    </svg>\n  );\n}\n"},{"path":"components/motion/parallax.tsx","type":"component","content":"\"use client\";\n\nimport {\n  motion,\n  type MotionStyle,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { type ReactNode, type RefObject, useRef } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n// Soft follow so the drift trails the scroll smoothly; looser than the UI\n// springs in lib/ease.ts on purpose.\nconst PARALLAX_SPRING = { stiffness: 120, damping: 30, mass: 0.6 };\n\nexport interface ParallaxProps {\n  children: ReactNode;\n  /**\n   * Drift as a fraction of the element's travel through the viewport.\n   * Positive moves with the scroll (foreground), negative against it\n   * (background). ~0.1–0.5 reads best.\n   */\n  speed?: number;\n  axis?: \"x\" | \"y\";\n  /** Scroll container for contained scroll areas. Defaults to the viewport. */\n  container?: RefObject<HTMLElement | null>;\n  /** Spring-smooth the drift. Disabled automatically under reduced motion. */\n  spring?: boolean;\n  className?: string;\n}\n\nexport function Parallax({\n  children,\n  speed = 0.3,\n  axis = \"y\",\n  container,\n  spring = true,\n  className,\n}: ParallaxProps) {\n  const reduce = useReducedMotion();\n  const ref = useRef<HTMLDivElement>(null);\n  const { scrollYProgress } = useScroll({\n    target: ref,\n    container,\n    offset: [\"start end\", \"end start\"],\n    // Run after paint so a container ref defined higher in the tree is hydrated;\n    // otherwise framer falls back to the document and only the page scroll works.\n    layoutEffect: false,\n  });\n\n  // progress 0→1 as the element crosses the viewport; map to a symmetric drift.\n  const travel = speed * 100;\n  const drift = useTransform(scrollYProgress, [0, 1], [travel, -travel]);\n  const smoothed = useSpring(drift, PARALLAX_SPRING);\n  const value = spring && !reduce ? smoothed : drift;\n\n  const style: MotionStyle = reduce ? {} : axis === \"x\" ? { x: value } : { y: value };\n\n  return (\n    <motion.div ref={ref} style={style} className={cn(className)}>\n      {children}\n    </motion.div>\n  );\n}\n"},{"path":"components/motion/scroll-to.tsx","type":"component","content":"\"use client\";\n\nimport type { ButtonHTMLAttributes, ReactNode } from \"react\";\n\nimport {\n  type ScrollTarget,\n  type ScrollToOptions,\n  useSmoothScroll,\n} from \"@/components/motion/smooth-scroll\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ScrollToProps\n  extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\"> {\n  /** Where to scroll: px offset, selector string or element. */\n  to: ScrollTarget;\n  /** Extra px offset from the target (e.g. to clear a sticky header). */\n  offset?: number;\n  /** Override the ease duration in seconds. */\n  duration?: number;\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * Button that smooth-scrolls to a target via the active SmoothScroll provider\n * (or native scroll as a fallback). Respects reduced motion — jumps instantly.\n */\nexport function ScrollTo({\n  to,\n  offset,\n  duration,\n  children,\n  className,\n  ...rest\n}: ScrollToProps) {\n  const { scrollTo } = useSmoothScroll();\n  const options: ScrollToOptions = { offset, duration };\n  return (\n    <button\n      type=\"button\"\n      onClick={() => scrollTo(to, options)}\n      className={cn(className)}\n      {...rest}\n    >\n      {children}\n    </button>\n  );\n}\n"},{"path":"components/motion/scroll-reveal.tsx","type":"component","content":"\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, type RefObject, useRef } from \"react\";\n\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ScrollRevealProps {\n  children: ReactNode;\n  /** Slide distance in px before reveal. */\n  y?: number;\n  /** Enter blur in px (kept ≤ 10 per motion conventions). */\n  blur?: number;\n  /** Reveal duration in seconds. */\n  duration?: number;\n  delay?: number;\n  /** Reveal only once (default) or every time it enters view. */\n  once?: boolean;\n  /** Portion of the element that must be visible to trigger. */\n  amount?: \"some\" | \"all\" | number;\n  /** Scroll root for contained scroll areas. Defaults to the viewport. */\n  root?: RefObject<Element | null>;\n  className?: string;\n}\n\nexport function ScrollReveal({\n  children,\n  y = 16,\n  blur = 8,\n  duration = 0.6,\n  delay = 0,\n  once = true,\n  amount = 0.3,\n  root,\n  className,\n}: ScrollRevealProps) {\n  const reduce = useReducedMotion();\n  const ref = useRef<HTMLDivElement>(null);\n  const inView = useInView(ref, { root, once, amount });\n\n  const hidden = reduce\n    ? { opacity: 0 }\n    : { opacity: 0, y, filter: `blur(${blur}px)` };\n  const shown = reduce\n    ? { opacity: 1 }\n    : { opacity: 1, y: 0, filter: \"blur(0px)\" };\n\n  return (\n    <motion.div\n      ref={ref}\n      initial={hidden}\n      animate={inView ? shown : hidden}\n      transition={{ duration, ease: EASE_OUT, delay }}\n      className={cn(className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"}]}