{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "smooth-scroll", "type": "registry:component", "title": "Smooth Scroll", "description": "Inertial wheel scrolling with shared scroll motion values.", "dependencies": [ "motion@^14.0.0" ], "files": [ { "path": "components/motion/smooth-scroll.tsx", "type": "registry:component", "target": "components/motion/smooth-scroll.tsx", "content": "import { 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// Expo-out curve for programmatic scrolls.\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 /** Reserved for a third-party scroll engine; always null in DX UI. */\n lenis: null;\n /** Current scroll offset in px. */\n scrollY: MotionValue;\n /** Scroll position as 0..1 of the scrollable height. */\n progress: MotionValue;\n /** Signed scroll velocity (px/frame); drives velocity-based effects. */\n velocity: MotionValue;\n /** Programmatic smooth scroll. Respects reduced motion (jumps instantly). */\n scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;\n};\n\nconst SmoothScrollContext = createContext(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 defaultnative 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\ntype Engine = {\n scrollTo: (top: number, opts?: { duration?: number; immediate?: boolean }) => void;\n destroy: () => void;\n};\n\n/**\n * Minimal wheel smoother: intercepts wheel deltas, lerps the scroll position\n * toward an accumulated target each frame, and eases programmatic scrolls.\n * Touch and keyboard keep native behaviour.\n */\nfunction createEngine(\n source: ScrollSource,\n opts: { lerp: number; wheelMultiplier: number; horizontal: boolean },\n): Engine {\n const el = source instanceof Window ? document.documentElement : source;\n const read = () =>\n opts.horizontal\n ? source instanceof Window ? window.scrollX : source.scrollLeft\n : source instanceof Window ? window.scrollY : source.scrollTop;\n const maxOf = () =>\n opts.horizontal\n ? el.scrollWidth - (source instanceof Window ? window.innerWidth : el.clientWidth)\n : el.scrollHeight - (source instanceof Window ? window.innerHeight : el.clientHeight);\n const write = (v: number) => {\n if (opts.horizontal) source.scrollTo({ left: v, behavior: \"instant\" });\n else source.scrollTo({ top: v, behavior: \"instant\" });\n };\n let target = read();\n let current = target;\n let raf = 0;\n let tween: { from: number; to: number; start: number; dur: number } | null = null;\n\n const tick = (now: number) => {\n if (tween) {\n const t = Math.min(1, (now - tween.start) / tween.dur);\n current = tween.from + (tween.to - tween.from) * EASE_SCROLL(t);\n write(current);\n if (t >= 1) {\n tween = null;\n target = current;\n raf = 0;\n return;\n }\n } else {\n current += (target - current) * opts.lerp;\n if (Math.abs(target - current) < 0.5) current = target;\n write(current);\n if (current === target) {\n raf = 0;\n return;\n }\n }\n raf = requestAnimationFrame(tick);\n };\n const run = () => {\n if (!raf) raf = requestAnimationFrame(tick);\n };\n const onWheel = (e: WheelEvent) => {\n if (e.ctrlKey) return;\n const max = Math.max(0, maxOf());\n if (max <= 0) return;\n const delta = (opts.horizontal ? e.deltaX || e.deltaY : e.deltaY) * opts.wheelMultiplier;\n const scale = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? max : 1;\n if (!raf) current = read();\n const next = Math.min(max, Math.max(0, target + delta * scale));\n if (next === target && (next === 0 || next === max)) return;\n e.preventDefault();\n tween = null;\n target = next;\n run();\n };\n const onScroll = () => {\n if (!raf) target = current = read();\n };\n const listenOn: Window | HTMLElement = source;\n listenOn.addEventListener(\"wheel\", onWheel as EventListener, { passive: false });\n listenOn.addEventListener(\"scroll\", onScroll, { passive: true });\n return {\n scrollTo(top, o) {\n const max = Math.max(0, maxOf());\n const to = Math.min(max, Math.max(0, top));\n if (o?.immediate) {\n cancelAnimationFrame(raf);\n raf = 0;\n tween = null;\n target = current = to;\n write(to);\n return;\n }\n current = read();\n tween = { from: current, to, start: performance.now(), dur: (o?.duration ?? 1.2) * 1000 };\n run();\n },\n destroy() {\n cancelAnimationFrame(raf);\n listenOn.removeEventListener(\"wheel\", onWheel as EventListener);\n listenOn.removeEventListener(\"scroll\", onScroll);\n },\n };\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,\n progress: MotionValue,\n velocity: MotionValue,\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 className,\n}: SmoothScrollProps) {\n const reduce = useReducedMotion();\n const scrollY = useMotionValue(0);\n const progress = useMotionValue(0);\n const velocity = useMotionValue(0);\n const engineRef = useRef(null);\n const containerRef = useRef(null);\n\n const nativeSource = useCallback(\n (): ScrollSource | null => (root ? window : containerRef.current),\n [root],\n );\n\n useEffect(() => {\n if (reduce) return;\n const source = nativeSource();\n if (!source) return;\n const engine = createEngine(source, {\n lerp,\n wheelMultiplier,\n horizontal: orientation === \"horizontal\",\n });\n engineRef.current = engine;\n return () => {\n engine.destroy();\n engineRef.current = null;\n };\n }, [reduce, nativeSource, lerp, wheelMultiplier, orientation]);\n\n const scrollTo = useCallback(\n (target: ScrollTarget, options?: ScrollToOptions) => {\n const source = nativeSource() ?? window;\n const top = resolveTop(target, source, options?.offset);\n const engine = engineRef.current;\n if (engine && !reduce) {\n engine.scrollTo(top, {\n duration: options?.duration ?? duration,\n immediate: options?.immediate,\n });\n return;\n }\n const behavior = reduce || options?.immediate ? \"auto\" : \"smooth\";\n source.scrollTo({ top, behavior });\n },\n [reduce, nativeSource, duration],\n );\n\n useNativeScrollSync(true, nativeSource, scrollY, progress, velocity);\n\n const api = useMemo(\n () => ({ lenis: null, scrollY, progress, velocity, scrollTo }),\n [scrollY, progress, velocity, scrollTo],\n );\n\n return (\n \n \n {children}\n \n \n );\n}\n\n/**\n * Read the page's smooth-scroll state. Inside 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(\n () => ({ lenis: null, scrollY, progress, velocity, scrollTo }),\n [scrollY, progress, velocity, scrollTo],\n );\n\n return ctx ?? fallback;\n}\n" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/smooth-scroll" } }