Border Glide

PreviousNext

Modern UI cards with a moving border and smooth transitions.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/border-glide.tsx
"use client"
import React, { useRef, createContext, useContext, useCallback } from "react"
import {
  motion,
  AnimatePresence,
  PanInfo,
  useSpring,
  useMotionTemplate,
  useTransform,
} from "framer-motion"
import {
  Card,
  CardContent,
  CardHeader,
  CardFooter,
  CardTitle,
  CardDescription
} from "@/components/ui/card"
import { cn } from "@/lib/utils"

interface BorderGlideContextType {
  currentIndex: React.MutableRefObject<number>
  direction: React.MutableRefObject<number>
  handleDragEnd: (e: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => void
  totalItems: number
  triggerUpdate: () => void
}

const BorderGlideContext = createContext<BorderGlideContextType | undefined>(undefined)

const useBorderGlideContext = () => {
  const context = useContext(BorderGlideContext)
  if (!context) {
    throw new Error("BorderGlide components must be used within BorderGlide")
  }
  return context
}

const MovingBorder: React.FC<{
  children: React.ReactNode
  duration?: number
  rx?: string
  ry?: string
  color?: string
  width?: string
  height?: string
  opacity?: number
}> = ({ children, duration = 3000, rx = "1.5rem", ry = "1.5rem", color = "#3b82f6", width = "12rem", height = "0.5rem", opacity = 0.8 }) => {
  const pathRef = useRef<SVGRectElement>(null)
  const animationRef = useRef<number | null>(null)
  const startTimeRef = useRef<number>(Date.now())

  const time = useSpring(0, {
    stiffness: 100,
    damping: 20,
    mass: 0.5
  })

  const animate = useCallback(() => {
    const elapsed = Date.now() - startTimeRef.current
    const speed = 1000 / duration
    time.set(elapsed * speed)
    animationRef.current = requestAnimationFrame(animate)
  }, [time, duration])

  React.useLayoutEffect(() => {
    startTimeRef.current = Date.now()
    animate()
    return () => {
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current)
      }
    }
  }, [animate])

  const progress = useTransform(time, (val) => {
    if (!pathRef.current) return 0
    const length = pathRef.current.getTotalLength()
    return (val % length)
  })

  const x = useTransform(progress, (val) => {
    if (!pathRef.current) return 0
    return pathRef.current.getPointAtLength(val).x
  })

  const y = useTransform(progress, (val) => {
    if (!pathRef.current) return 0
    return pathRef.current.getPointAtLength(val).y
  })

  const angle = useTransform(progress, (val) => {
    if (!pathRef.current) return 0
    const length = pathRef.current.getTotalLength()
    const p1 = pathRef.current.getPointAtLength(val)
    const p2 = pathRef.current.getPointAtLength((val + 1) % length)
    return Math.atan2(p2.y - p1.y, p2.x - p1.x) * (180 / Math.PI)
  })

  const transform = useMotionTemplate`
    translateX(${x}px) 
    translateY(${y}px) 
    translateX(-50%) 
    translateY(-50%) 
    rotate(${angle}deg)
  `

  const getBackgroundStyle = (color: string) => {
    if (color.includes('gradient') || color.includes('linear-gradient') || color.includes('radial-gradient') || color.includes('conic-gradient')) {
      return color
    }
    return `radial-gradient(${color} 40%, transparent 60%)`
  }

  return (
    <>
      <svg
        xmlns="http://www.w3.org/2000/svg"
        preserveAspectRatio="none"
        className="absolute h-full w-full pointer-events-none"
        style={{ willChange: "auto" }}
      >
        <rect
          fill="none"
          width="100%"
          height="100%"
          rx={rx}
          ry={ry}
          ref={pathRef}
          style={{ willChange: "auto" }}
        />
      </svg>
      <motion.div
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          transform,
          willChange: "transform"
        }}
      >
        <div
          className="rounded-full"
          style={{
            height,
            width,
            opacity,
            background: getBackgroundStyle(color),
            borderRadius: "50%"
          }}
        />
      </motion.div>
    </>
  )
}

interface BorderGlideProps {
  children: React.ReactNode
  className?: string
  autoPlayInterval?: number
  borderDuration?: number
  borderColor?: string
  borderWidth?: string
  borderHeight?: string
  borderOpacity?: number
}

const BorderGlide: React.FC<BorderGlideProps> = ({
  children,
  className,
  autoPlayInterval = 5000,
  borderDuration = 3000,
  borderColor = "#3b82f6",
  borderWidth = "6rem",
  borderHeight = "6rem",
  borderOpacity = 0.8,
}) => {
  const currentIndexRef = useRef(0)
  const directionRef = useRef(0)
  const autoPlayRef = useRef<NodeJS.Timeout | null>(null)
  const forceUpdateRef = useRef(0)
  const [, setForceUpdate] = React.useReducer((x: number) => x + 1, 0)

  const childrenArray = React.Children.toArray(children)
  const totalItems = childrenArray.length

  const swipeConfidenceThreshold = 10000
  const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity

  const triggerUpdate = useCallback(() => {
    forceUpdateRef.current += 1
    setForceUpdate()
  }, [])

  const paginate = useCallback((newDirection: number) => {
    directionRef.current = newDirection
    if (newDirection === 1) {
      currentIndexRef.current = currentIndexRef.current === totalItems - 1 ? 0 : currentIndexRef.current + 1
    } else {
      currentIndexRef.current = currentIndexRef.current === 0 ? totalItems - 1 : currentIndexRef.current - 1
    }
    triggerUpdate()
  }, [totalItems, triggerUpdate])

  const handleDragEnd = useCallback((e: MouseEvent | TouchEvent | PointerEvent, { offset, velocity }: PanInfo) => {
    const swipe = swipePower(offset.x, velocity.x)
    if (swipe < -swipeConfidenceThreshold) {
      paginate(1)
    } else if (swipe > swipeConfidenceThreshold) {
      paginate(-1)
    }
  }, [paginate])

  const setupAutoPlay = useCallback(() => {
    if (autoPlayRef.current) {
      clearInterval(autoPlayRef.current)
    }
    if (autoPlayInterval > 0 && totalItems > 1) {
      autoPlayRef.current = setInterval(() => {
        paginate(1)
      }, autoPlayInterval)
    }
  }, [autoPlayInterval, totalItems, paginate])

  React.useLayoutEffect(() => {
    setupAutoPlay()
    return () => {
      if (autoPlayRef.current) {
        clearInterval(autoPlayRef.current)
      }
    }
  }, [setupAutoPlay])

  const contextValue: BorderGlideContextType = {
    currentIndex: currentIndexRef,
    direction: directionRef,
    handleDragEnd,
    totalItems,
    triggerUpdate,
  }

  const slideVariants = {
    enter: (direction: number) => ({
      x: direction > 0 ? "100%" : "-100%",
      opacity: 0,
      scale: 0.95
    }),
    center: {
      zIndex: 1,
      x: "0%",
      opacity: 1,
      scale: 1
    },
    exit: (direction: number) => ({
      zIndex: 0,
      x: direction < 0 ? "100%" : "-100%",
      opacity: 0,
      scale: 0.95
    }),
  }

  const spring = {
    type: "spring" as const,
    stiffness: 300,
    damping: 30,
    mass: 0.8
  }

  return (
    <BorderGlideContext.Provider value={contextValue}>
      <div className={cn("relative w-full", className)}>
        <div className="relative w-full h-full overflow-hidden rounded-xl bg-transparent p-[2px]">
          <div className="absolute inset-0 pointer-events-none">
            <MovingBorder
              duration={borderDuration}
              rx="0.75rem"
              ry="0.75rem"
              color={borderColor}
              width={borderWidth}
              height={borderHeight}
              opacity={borderOpacity}
            >
              <div />
            </MovingBorder>
          </div>
          <div className="relative w-full h-full rounded-xl overflow-hidden bg-white dark:bg-[#09090b] backdrop-blur-sm">
            <AnimatePresence initial={false} custom={directionRef.current} mode="wait">
              <motion.div
                key={currentIndexRef.current}
                custom={directionRef.current}
                variants={slideVariants}
                initial="enter"
                animate="center"
                exit="exit"
                transition={spring}
                drag="x"
                dragConstraints={{ left: 0, right: 0 }}
                dragElastic={0.2}
                onDragEnd={handleDragEnd}
                className="absolute inset-0 cursor-grab active:cursor-grabbing will-change-transform"
                style={{ willChange: "transform" }}
              >
                {childrenArray[currentIndexRef.current]}
              </motion.div>
            </AnimatePresence>
          </div>
        </div>
      </div>
    </BorderGlideContext.Provider>
  )
}

interface BorderGlideCardProps {
  children: React.ReactNode
  className?: string
}

const BorderGlideCard: React.FC<BorderGlideCardProps> = ({
  children,
  className,
}) => {
  return (
    <Card className={cn("bg-transparent border shadow-none text-foreground w-full h-full", className)}>
      {children}
    </Card>
  )
}

interface BorderGlideContentProps {
  children: React.ReactNode
  className?: string
}

const BorderGlideContent: React.FC<BorderGlideContentProps> = ({
  children,
  className,
}) => {
  return (
    <CardContent className={cn("p-0 w-full h-full", className)}>
      {children}
    </CardContent>
  )
}

interface BorderGlideHeaderProps {
  children: React.ReactNode
  className?: string
}

const BorderGlideHeader: React.FC<BorderGlideHeaderProps> = ({
  children,
  className,
}) => {
  return (
    <CardHeader className={cn("flex flex-col space-y-1.5 p-6", className)}>
      {children}
    </CardHeader>
  )
}

interface BorderGlideFooterProps {
  children: React.ReactNode
  className?: string
}

const BorderGlideFooter: React.FC<BorderGlideFooterProps> = ({
  children,
  className,
}) => {
  return (
    <CardFooter className={cn("flex items-center p-6 pt-0", className)}>
      {children}
    </CardFooter>
  )
}

interface BorderGlideTitleProps {
  children: React.ReactNode
  className?: string
}

const BorderGlideTitle: React.FC<BorderGlideTitleProps> = ({
  children,
  className,
}) => {
  return (
    <CardTitle className={cn("font-semibold leading-none tracking-tight", className)}>
      {children}
    </CardTitle>
  )
}

interface BorderGlideDescriptionProps {
  children: React.ReactNode
  className?: string
}

const BorderGlideDescription: React.FC<BorderGlideDescriptionProps> = ({
  children,
  className,
}) => {
  return (
    <CardDescription className={cn("text-sm text-muted-foreground", className)}>
      {children}
    </CardDescription>
  )
}

export {
  BorderGlide,
  BorderGlideCard,
  BorderGlideContent,
  BorderGlideHeader,
  BorderGlideFooter,
  BorderGlideTitle,
  BorderGlideDescription
};

Installation

npx shadcn@latest add @scrollxui/border-glide

Usage

import { BorderGlide } from "@/components/border-glide"
<BorderGlide />