ScrollArea Pro

PreviousNext

Advanced scroll area component with horizontal and vertical scrolling, customizable styles, and smooth animations.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/scroll-areapro.tsx
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { motion, AnimatePresence, useMotionValue, useTransform } from "framer-motion"
import { cn } from "@/lib/utils"

interface ScrollAreaProProps extends React.ComponentProps<typeof ScrollAreaPrimitive.Root> {
  className?: string
  children: React.ReactNode
  crossDirectionalScroll?: boolean
  autoHide?: boolean
  showProgress?: "horizontal" | "vertical"
  onScrollChange?: (progress: { x: number; y: number }) => void
}

interface ScrollBarProProps extends React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> {
  className?: string
  show?: boolean
  autoHide?: boolean
  isScrolling?: boolean
  orientation?: "vertical" | "horizontal"
}

const isMobileDevice = (): boolean => {
  if (typeof window !== 'undefined') {
    if (window.innerWidth <= 1024) return true
    const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0
    const mobileUserAgent = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|Tablet/i.test(navigator.userAgent)
    if (hasTouchScreen && mobileUserAgent) return true
    if (window.innerWidth <= 1366 && hasTouchScreen) return true
  }
  return false
}

function ScrollBarPro({
  className,
  orientation = "vertical",
  show = true,
  autoHide = false,
  isScrolling = false,
  ...props
}: ScrollBarProProps) {
  if (!autoHide && !isScrolling) {
    return (
      <ScrollAreaPrimitive.ScrollAreaScrollbar
        data-slot="scroll-area-scrollbar"
        orientation={orientation}
        className={cn(
          "flex touch-none p-px transition-colors select-none",
          orientation === "vertical" &&
            "h-full w-2.5 border-l border-l-transparent",
          orientation === "horizontal" &&
            "h-2.5 flex-col border-t border-t-transparent",
          className
        )}
        {...props}
      >
        <ScrollAreaPrimitive.ScrollAreaThumb
          data-slot="scroll-area-thumb"
          className="bg-border relative flex-1 rounded-full"
        />
      </ScrollAreaPrimitive.ScrollAreaScrollbar>
    )
  }

  return (
    <AnimatePresence>
      {(!autoHide || show) && (
        <motion.div
          initial={{ opacity: autoHide ? 0 : 1 }}
          animate={{ opacity: show ? 1 : 0 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.3 }}
        >
          <ScrollAreaPrimitive.ScrollAreaScrollbar
            data-slot="scroll-area-scrollbar"
            orientation={orientation}
            className={cn(
              "flex touch-none select-none transition-all duration-300",
              orientation === "vertical" &&
                "h-full border-l border-l-transparent",
              orientation === "horizontal" &&
                "flex-col border-t border-t-transparent",
              "p-0.5 xs:p-px",
              orientation === "vertical" && "w-1.5 xs:w-2 sm:w-2.5 md:w-2 lg:w-2.5",
              orientation === "horizontal" && "h-1.5 xs:h-2 sm:h-2.5 md:h-2 lg:h-2.5",
              "hover:w-2 hover:h-2 xs:hover:w-2.5 xs:hover:h-2.5 sm:hover:w-3 sm:hover:h-3 md:hover:w-2.5 md:hover:h-2.5 lg:hover:w-3 lg:hover:h-3",
              className
            )}
            {...props}
          >
            <motion.div
              animate={{
                scale: isScrolling ? 1.1 : 1,
                backgroundColor: isScrolling ? "#3b82f6" : "#d1d5db"
              }}
              transition={{ duration: 0.2 }}
              className="relative flex-1 rounded-full"
            >
              <ScrollAreaPrimitive.ScrollAreaThumb
                data-slot="scroll-area-thumb"
                className="bg-current relative flex-1 rounded-full"
              />
            </motion.div>
          </ScrollAreaPrimitive.ScrollAreaScrollbar>
        </motion.div>
      )}
    </AnimatePresence>
  )
}

function ScrollAreaPro({
  className,
  children,
  crossDirectionalScroll = false,
  autoHide = false,
  showProgress,
  onScrollChange,
  ...props
}: ScrollAreaProProps) {
  const [isScrolling, setIsScrolling] = React.useState<boolean>(false)
  const [showScrollbars, setShowScrollbars] = React.useState<boolean>(!autoHide)
  const [isFullyVisible, setIsFullyVisible] = React.useState<boolean>(true)
  const [isMobile, setIsMobile] = React.useState<boolean>(false)
  const [containerHeight, setContainerHeight] = React.useState(0)
  const [isScrollingY, setIsScrollingY] = React.useState(false)
  const scrollTimeoutVerticalRef = React.useRef<NodeJS.Timeout | null>(null)
  const viewportRef = React.useRef<HTMLDivElement>(null)
  const containerRef = React.useRef<HTMLDivElement>(null)
  const scrollTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)

  const scrollX = useMotionValue(0)
  const scrollY = useMotionValue(0)
  const progressBarScaleX = useTransform(scrollX, [0, 100], [0, 1])
  const progressBarScaleY = useTransform(scrollY, [0, 100], [0, 1])
  
  const verticalScrollProgress = useMotionValue(0)
  const progressHeight = containerHeight - 20
  const y1 = useTransform(verticalScrollProgress, [0, 0.8], [50, progressHeight])
  const y2 = useTransform(verticalScrollProgress, [0, 1], [50, Math.max(50, progressHeight - 200)])

  React.useEffect(() => {
    const checkMobile = () => {
      setIsMobile(isMobileDevice())
    }
    checkMobile()
    
    const updateContainerHeight = () => {
      if (containerRef.current) {
        setContainerHeight(containerRef.current.offsetHeight)
      }
    }
    
    updateContainerHeight()
    
    window.addEventListener('resize', checkMobile)
    window.addEventListener('orientationchange', checkMobile)
    window.addEventListener('resize', updateContainerHeight)
    
    return () => {
      window.removeEventListener('resize', checkMobile)
      window.removeEventListener('orientationchange', checkMobile)
      window.removeEventListener('resize', updateContainerHeight)
    }
  }, [])

  const handleScroll = React.useCallback((): void => {
    if (!viewportRef.current) return
    const element = viewportRef.current
    const maxScrollX = element.scrollWidth - element.clientWidth
    const maxScrollY = element.scrollHeight - element.clientHeight
    const progressX = maxScrollX > 0 ? (element.scrollLeft / maxScrollX) * 100 : 0
    const progressY = maxScrollY > 0 ? (element.scrollTop / maxScrollY) * 100 : 0
    scrollX.set(progressX)
    scrollY.set(progressY)
    verticalScrollProgress.set(progressY / 100)
    onScrollChange?.({ x: progressX, y: progressY })
    if (autoHide) {
      setIsScrolling(true)
      setShowScrollbars(true)
      if (scrollTimeoutRef.current) {
        clearTimeout(scrollTimeoutRef.current)
      }
      if (scrollTimeoutVerticalRef.current) {
        clearTimeout(scrollTimeoutVerticalRef.current)
      }
      scrollTimeoutRef.current = setTimeout(() => {
        setIsScrolling(false)
        setShowScrollbars(false)
      }, 1500)
    }
    if (showProgress === "vertical") {
      setIsScrollingY(true)
      if (scrollTimeoutVerticalRef.current) {
        clearTimeout(scrollTimeoutVerticalRef.current)
      }
      scrollTimeoutVerticalRef.current = setTimeout(() => {
        setIsScrollingY(false)
      }, 2000)
    }
  }, [autoHide, onScrollChange, scrollX, scrollY, showProgress, verticalScrollProgress])

  const handleWheel = React.useCallback((e: WheelEvent): void => {
    if (!crossDirectionalScroll || !viewportRef.current || !isFullyVisible || isMobile) return
    if (Math.abs(e.deltaY) > 0) {
      e.preventDefault()
      viewportRef.current.scrollBy({
        left: e.deltaY,
        behavior: 'auto'
      })
    }
  }, [crossDirectionalScroll, isFullyVisible, isMobile])

  const handleTouchStart = React.useCallback((e: TouchEvent): void => {
    if (!crossDirectionalScroll || !viewportRef.current || !isFullyVisible || isMobile) return
    const touch = e.touches[0]
    viewportRef.current.dataset.touchStartY = touch.clientY.toString()
  }, [crossDirectionalScroll, isFullyVisible, isMobile])

  const handleTouchMove = React.useCallback((e: TouchEvent): void => {
    if (!crossDirectionalScroll || !viewportRef.current || !isFullyVisible || isMobile) return
    const element = viewportRef.current
    const startY = parseFloat(element.dataset.touchStartY || '0')
    const touch = e.touches[0]
    const deltaY = touch.clientY - startY
    if (Math.abs(deltaY) > 10) {
      e.preventDefault()
      const currentScrollLeft = element.scrollLeft
      const maxScrollLeft = element.scrollWidth - element.clientWidth
      const scrollAmount = -deltaY * 2
      const newScrollLeft = Math.max(0, Math.min(maxScrollLeft, currentScrollLeft + scrollAmount))
      element.scrollTo({ left: newScrollLeft, behavior: 'auto' })
      element.dataset.touchStartY = touch.clientY.toString()
    }
  }, [crossDirectionalScroll, isFullyVisible, isMobile])

  React.useEffect(() => {
    const element = viewportRef.current
    if (!element) return
    element.addEventListener('scroll', handleScroll, { passive: true })
    if (crossDirectionalScroll && isFullyVisible && !isMobile) {
      element.addEventListener('wheel', handleWheel, { passive: false })
      element.addEventListener('touchstart', handleTouchStart, { passive: false })
      element.addEventListener('touchmove', handleTouchMove, { passive: false })
    }
    return () => {
      element.removeEventListener('scroll', handleScroll)
      if (crossDirectionalScroll && !isMobile) {
        element.removeEventListener('wheel', handleWheel)
        element.removeEventListener('touchstart', handleTouchStart)
        element.removeEventListener('touchmove', handleTouchMove)
      }
      if (scrollTimeoutRef.current) {
        clearTimeout(scrollTimeoutRef.current)
      }
    }
  }, [handleScroll, handleWheel, handleTouchStart, handleTouchMove, crossDirectionalScroll, isFullyVisible, isMobile])

  React.useEffect(() => {
    if (!crossDirectionalScroll || isMobile) return
    const container = containerRef.current
    if (!container) return
    const observer = new IntersectionObserver(
      ([entry]) => setIsFullyVisible(entry.intersectionRatio >= 0.90),
      { threshold: 0.90 }
    )
    observer.observe(container)
    return () => observer.disconnect()
  }, [crossDirectionalScroll, isMobile])

  React.useEffect(() => {
    const resizeObserver = new ResizeObserver(() => {
      if (containerRef.current) {
        setContainerHeight(containerRef.current.offsetHeight)
      }
    })
    
    if (containerRef.current) {
      resizeObserver.observe(containerRef.current)
    }
    
    return () => {
      resizeObserver.disconnect()
    }
  }, [])

  return (
    <ScrollAreaPrimitive.Root
      ref={containerRef}
      data-slot="scroll-area"
      className={cn("relative", className)}
      {...props}
    >
      <ScrollAreaPrimitive.Viewport
        ref={viewportRef}
        data-slot="scroll-area-viewport"
        className={cn(
          "focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1",
          "max-w-full min-w-0 min-h-0",
          crossDirectionalScroll && !isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-auto",
          crossDirectionalScroll && !isFullyVisible && !isMobile && "opacity-75"
        )}
      >
        {children}
      </ScrollAreaPrimitive.Viewport>
      
      {(!crossDirectionalScroll || isMobile) && (
        <ScrollBarPro 
          orientation="vertical" 
          show={showScrollbars} 
          autoHide={autoHide}
          isScrolling={isScrolling}
        />
      )}
      <ScrollBarPro 
        orientation="horizontal" 
        show={showScrollbars} 
        autoHide={autoHide}
        isScrolling={isScrolling}
      />
      <ScrollAreaPrimitive.Corner />

      <AnimatePresence>
        {showProgress === "vertical" && isScrollingY && containerHeight > 0 && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.3 }}
            className="absolute top-3 right-0 flex items-start pointer-events-none"
          >
            <motion.div
              initial={{ boxShadow: "rgba(0, 0, 0, 0.24) 0px 3px 8px" }}
              animate={{
                boxShadow:
                  verticalScrollProgress.get() > 0
                    ? "none"
                    : "rgba(0, 0, 0, 0.24) 0px 3px 8px",
              }}
              transition={{ duration: 0.2, delay: 0.5 }}
              className="border-neutral-200 flex h-4 w-4 items-center justify-center rounded-full border shadow-sm"
            >
              <motion.div
                initial={{ backgroundColor: "#10b981", borderColor: "#059669" }}
                animate={{
                  backgroundColor:
                    verticalScrollProgress.get() > 0 ? "white" : "#10b981",
                  borderColor:
                    verticalScrollProgress.get() > 0 ? "white" : "#059669",
                }}
                transition={{ duration: 0.2, delay: 0.5 }}
                className="h-2 w-2 rounded-full border border-neutral-300 bg-white"
              />
            </motion.div>

            <svg
              viewBox={`0 0 20 ${progressHeight}`}
              width="20"
              height={progressHeight}
              className="block"
              aria-hidden="true"
            >
              <motion.path
                d={`M 10 0 V ${progressHeight}`}
                fill="none"
                stroke="#9091A0"
                strokeOpacity="0.16"
              />
              <motion.path
                d={`M 10 0 V ${progressHeight}`}
                fill="none"
                stroke="url(#gradient)"
                strokeWidth="1.25"
                className="motion-reduce:hidden"
              />
              <defs>
                <motion.linearGradient
                  id="gradient"
                  gradientUnits="userSpaceOnUse"
                  x1="0"
                  x2="0"
                  y1={y1}
                  y2={y2}
                >
                  <stop stopColor="#18CCFC" stopOpacity="0" />
                  <stop stopColor="#18CCFC" />
                  <stop offset="0.325" stopColor="#6344F5" />
                  <stop offset="1" stopColor="#AE48FF" stopOpacity="0" />
                </motion.linearGradient>
              </defs>
            </svg>
          </motion.div>
        )}
      </AnimatePresence>

      <AnimatePresence>
        {showProgress === "horizontal" && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 0.7 }}
            exit={{ opacity: 0 }}
            className="absolute inset-0 pointer-events-none"
          >
            <motion.div 
              className="absolute bottom-0 left-0 right-0 bg-gray-200/50 dark:bg-gray-700/50"
              style={{ height: "clamp(2px, 0.3vw, 6px)" }}
              initial={{ scaleX: 0 }}
              animate={{ scaleX: 1 }}
              transition={{ delay: 0.2 }}
            >
              <motion.div 
                className="h-full bg-gradient-to-r from-blue-500 to-purple-500 origin-left"
                style={{ scaleX: progressBarScaleX }}
              />
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </ScrollAreaPrimitive.Root>
  )
}

export { ScrollAreaPro, ScrollBarPro }

Installation

npx shadcn@latest add @scrollxui/scroll-areapro

Usage

import { ScrollAreapro } from "@/components/scroll-areapro"
<ScrollAreapro />