Element Along Svg Path

PreviousNext

A ui component.

Docs
fancyui

Preview

Loading preview…
fancy/blocks/element-along-svg-path.tsx
import {
  createContext,
  RefObject,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react"
import {
  motion,
  MotionValue,
  useMotionValue,
  useScroll,
  UseScrollOptions,
  useSpring,
  useTime,
  useTransform,
} from "motion/react"

import { cn } from "@/lib/utils"

type PreserveAspectRatioAlign =
  | "none"
  | "xMinYMin"
  | "xMidYMin"
  | "xMaxYMin"
  | "xMinYMid"
  | "xMidYMid"
  | "xMaxYMid"
  | "xMinYMax"
  | "xMidYMax"
  | "xMaxYMax"

type PreserveAspectRatioMeetOrSlice = "meet" | "slice"

type PreserveAspectRatio =
  | PreserveAspectRatioAlign
  | `${Exclude<PreserveAspectRatioAlign, "none">} ${PreserveAspectRatioMeetOrSlice}`

interface ElementAlongPathProps {
  // Path properties
  path: string
  pathId?: string
  className?: string
  preserveAspectRatio?: PreserveAspectRatio
  showPath?: boolean
  direction?: "normal" | "reverse"

  // SVG properties
  width?: string | number
  height?: string | number
  viewBox?: string

  // Animation properties
  animationType?: "auto" | "scroll"

  // Animation properties if animationType is auto
  duration?: number
  transition?: any

  // Scroll animation properties if animationType is scroll
  scrollContainer?: RefObject<HTMLElement | null>
  scrollOffset?: UseScrollOptions["offset"]
  scrollTransformValues?: [number, number]

  // Children
  children?: React.ReactNode
}

interface ElementAlongPathItemProps {
  children: React.ReactNode
  className?: string
  startOffset?: number // 0-100 percentage
  transition?: any // Override parent transition
}

// Create context
const ElementAlongPathContext = createContext<{
  path: string
  animationType: "auto" | "scroll"
  direction: "normal" | "reverse" // Add direction to context
  progress: MotionValue<number>
  scrollYProgress: MotionValue<number>
  scrollTransformValues: [number, number]
  transition: any
  setHovered: (isHovered: boolean) => void
} | null>(null)

// Context hook
export const useElementAlongPathContext = () => {
  const context = useContext(ElementAlongPathContext)
  if (!context) {
    throw new Error("ElementAlongPathItem must be used within ElementAlongPath")
  }
  return context
}

// Item component
export const ElementAlongPathItem = ({
  children,
  className,
  startOffset = 0,
  transition: itemTransition,
}: ElementAlongPathItemProps) => {
  const {
    path,
    animationType,
    progress,
    scrollYProgress,
    scrollTransformValues,
    direction,
    transition: parentTransition,
    setHovered,
  } = useElementAlongPathContext()

  // Use item transition if provided, otherwise use parent transition
  const transition = itemTransition || parentTransition

  const initialOffset =
    direction === "normal" ? `${startOffset}%` : `${100 - startOffset}%`
  const animateOffset = direction === "normal" ? "100%" : "0%"

  const scp = useTransform(
    scrollYProgress,
    [0, 1],
    [scrollTransformValues[0], scrollTransformValues[1]]
  )

  return (
    <motion.div
      className={cn("absolute top-0 left-0", className)}
      initial={{ offsetDistance: initialOffset }}
      animate={{
        offsetDistance: animationType === "auto" ? animateOffset : undefined,
      }}
      style={{
        offsetPath: `path('${path}')`,
        offsetDistance: animationType === "scroll" ? scp : undefined,
      }}
      transition={transition}
      // onHoverStart={() => setHovered(true)}
      // onHoverEnd={() => setHovered(false)}
    >
      {children}
    </motion.div>
  )
}

const ElementAlongPath = ({
  children,
  // Path defaults
  path,
  pathId,
  preserveAspectRatio = "xMidYMid meet",
  showPath = false,
  className,

  // SVG defaults
  width = "100%",
  height = "100%",
  viewBox = "0 0 100 100",

  // Animation type
  animationType = "auto",
  direction = "normal",

  // Animation defaults
  duration = 4,
  transition = { duration: 4, repeat: Infinity, ease: "linear" },

  // Scroll animation defaults
  scrollContainer,
  scrollOffset = ["start end", "end end"],
  scrollTransformValues = [0, 100],
}: ElementAlongPathProps) => {
  const container = useRef<HTMLDivElement>(null)
  const [isHovered, setIsHovered] = useState(false)

  // Create a time scale factor that changes based on hover state
  const timeScale = useMotionValue(1)

  // Update time scale when hover state changes
  useEffect(() => {
    timeScale.set(isHovered ? 0.3 : 1) // Slow down to 30% speed when hovered
  }, [isHovered, timeScale])

  // Create a spring-based time scale for smooth transitions
  const smoothTimeScale = useSpring(timeScale, {
    stiffness: 100,
    damping: 30,
  })

  const t = useTime()
  const scaledTime = useTransform(t, (time) => time * smoothTimeScale.get())

  const progress = useTransform(
    scaledTime,
    [0, duration],
    direction === "normal" ? [0, 100] : [100, 0]
  )
  // naive id for the path. you should rather use yours :)
  const id =
    pathId || `animated-path-${Math.random().toString(36).substring(7)}`

  const { scrollYProgress } = useScroll({
    container: scrollContainer || container,
    offset: scrollOffset,
  })

  // Adjust scroll progress based on direction
  const scrollProgressValues =
    direction === "normal"
      ? [scrollTransformValues[0], scrollTransformValues[1]]
      : [scrollTransformValues[1], scrollTransformValues[0]]

  const scrollProgress = useTransform(
    scrollYProgress,
    [0, 1],
    scrollProgressValues
  )

  // Create the progress value based on animation type
  const finalProgress = animationType === "auto" ? progress : scrollProgress

  return (
    <ElementAlongPathContext.Provider
      value={{
        path,
        animationType,
        direction,
        progress: finalProgress,
        scrollYProgress,
        scrollTransformValues,
        transition: {
          ...transition,
        },
        setHovered: setIsHovered,
      }}
    >
      <div
        ref={container}
        className={cn("relative", className)}
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          viewBox={viewBox}
          width={width}
          height={height}
          preserveAspectRatio={preserveAspectRatio}
          className="w-full h-full"
        >
          <motion.path
            id={id}
            d={path}
            // initial={{ pathLength: 0.001 }}
            // animate={{ pathLength: 1 }}
            stroke={showPath ? "currentColor" : "none"}
            fill="none"
            transition={transition}
          />
        </svg>
        {children}
      </div>
    </ElementAlongPathContext.Provider>
  )
}

export default ElementAlongPath

Installation

npx shadcn@latest add @fancy/element-along-svg-path

Usage

import { ElementAlongSvgPath } from "@/components/ui/element-along-svg-path"
<ElementAlongSvgPath />