Moving Border

PreviousNext

Animated gradient border that smoothly travels around any element, from rounded rectangles to perfect circles.

Docs
phucbmcomponent

Preview

Loading preview…
registry/phucbm/blocks/moving-border/moving-border.tsx
"use client";
import * as React from 'react';
import {JSX, useRef} from 'react';
import gsap from "gsap";
import {useGSAP} from "@gsap/react";
import {ScrollTrigger} from "gsap/ScrollTrigger";
import {MotionPathPlugin} from "gsap/MotionPathPlugin";
import {cn} from "@/lib/utils";

gsap.registerPlugin(ScrollTrigger, MotionPathPlugin)

export type MovingBorderProps = {
    /** The content to be displayed inside the border. */
    children: React.ReactNode;

    /** Additional CSS classes for the inner content container. */
    className?: string;

    /** Additional CSS classes for the outer wrapper container. */
    outerClassName?: string;

    /** Width of the border in pixels. @default 1 */
    borderWidth?: number;

    /** Width of the gradient effect in pixels. If not specified, defaults to borderWidth * 10. */
    gradientWidth?: number;

    /** Border radius in pixels. Ignored if isCircle is true. @default 15 */
    radius?: number;

    /** Duration of one complete animation cycle in seconds. @default 3 */
    duration?: number;

    /** Array of color values for the gradient. If multiple colors provided, they will be animated in sequence. @default ["#355bd2"] */
    colors?: string[];

    /** Whether to render as a perfect circle with circular path animation. @default false */
    isCircle?: boolean;

    /** Tag name for the outer wrapper (e.g. 'div', 'section', 'figure', etc.). @default "div" */
    as?: keyof JSX.IntrinsicElements;
};

export function MovingBorder({
                                 children,
                                 className,
                                 outerClassName,
                                 borderWidth = 1,
                                 radius = 15,
                                 gradientWidth,
                                 duration = 3,
                                 colors = ["#355bd2"],
                                 isCircle = false,
                                 as = "div",
                             }: MovingBorderProps) {
    const scope = useRef<HTMLElement | null>(null);

    // Use a large radius for perfect circle
    const effectiveRadius = isCircle ? 9999 : radius;

    useGSAP(
        () => {
            const root = scope.current as HTMLElement | null;
            if (!root) return;

            const movingGradient = root.querySelector<HTMLElement>(".moving-gradient");
            if (!movingGradient) return;

            let pathTl: gsap.core.Timeline | null = null;
            let colorTl: gsap.core.Timeline | null = null;

            // Function to create/update the path animation
            const updateAnimation = () => {
                // Kill existing timeline if it exists
                if (pathTl) {
                    pathTl.kill();
                }

                // Get current dimensions
                const rect = root.getBoundingClientRect();
                const width = rect.width - borderWidth * 2;
                const height = rect.height - borderWidth * 2;

                let path: { x: number; y: number; }[];

                if (isCircle) {
                    // Create a circular path using 64 coordinate points
                    const centerX = width / 2;
                    const centerY = height / 2;
                    const circleRadius = Math.min(width, height) / 2;
                    const numPoints = 64;

                    path = Array.from({length: numPoints}, (_, i) => {
                        const angle = (i / numPoints) * Math.PI * 2;
                        return {
                            x: centerX + circleRadius * Math.cos(angle),
                            y: centerY + circleRadius * Math.sin(angle)
                        };
                    });
                } else {
                    // Calculate the path points accounting for border radius (rounded rectangle)
                    path = [
                        {x: effectiveRadius, y: 0},
                        {x: width - effectiveRadius, y: 0},
                        {x: width, y: effectiveRadius},
                        {x: width, y: height - effectiveRadius},
                        {x: width - effectiveRadius, y: height},
                        {x: effectiveRadius, y: height},
                        {x: 0, y: height - effectiveRadius},
                        {x: 0, y: effectiveRadius},
                        {x: effectiveRadius, y: 0},
                    ];
                }

                // Create new timeline for path
                pathTl = gsap.timeline({
                    repeat: -1,
                    defaults: {ease: "none", duration: duration}
                });

                pathTl.to(movingGradient, {
                    motionPath: {
                        path: path,
                        fromCurrent: false,
                        curviness: isCircle ? 1 : 1.5,
                    }
                });
            };

            // Function to create color animation
            const setupColorAnimation = () => {
                if (colors.length <= 1) {
                    // Single color - just set it
                    root.style.setProperty('--color', colors[0]);
                    return;
                }

                // Set initial color
                root.style.setProperty('--color', colors[0]);

                // Multiple colors - animate through them
                colorTl = gsap.timeline({
                    repeat: -1,
                    defaults: {ease: "none", duration: duration / colors.length}
                });

                // Animate through all colors and back to first for seamless loop
                colors.forEach((_, index) => {
                    const nextColor = colors[(index + 1) % colors.length];
                    colorTl!.to(root, {'--color': nextColor});
                });
            };

            // Initial setup
            updateAnimation();
            setupColorAnimation();

            // Watch for size changes
            const resizeObserver = new ResizeObserver(() => {
                updateAnimation();
            });

            resizeObserver.observe(root);

            // Cleanup
            return () => {
                if (pathTl) {
                    pathTl.kill();
                }
                if (colorTl) {
                    colorTl.kill();
                }
                resizeObserver.disconnect();
            };
        },
        {scope, dependencies: [borderWidth, effectiveRadius, gradientWidth, duration, colors, isCircle, as]}
    );

    const Wrapper = as as any;

    return (
        // wrapper
        <Wrapper ref={scope} className={cn(`wrapper relative overflow-hidden inline-block`, outerClassName)}
                 style={{
                     ['--color' as any]: colors[0],
                     padding: `${borderWidth}px`,
                     borderRadius: `${effectiveRadius + borderWidth}px`,
                 }}>

            {/* moving gradient*/}
            <div className="moving-gradient aspect-square absolute top-0 left-0" style={{width: `${borderWidth}px`}}>
                <div
                    className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 aspect-square rounded-full"
                    style={{
                        width: `${gradientWidth || borderWidth * 10}px`,
                        background: `radial-gradient(circle, var(--color) 0%, transparent 70%)`
                    }}>
                </div>
            </div>

            {/*inner*/}
            <div className={cn(`inner relative z-30 bg-white`, className)}
                 style={{
                     borderRadius: `${effectiveRadius}px`,
                 }}>
                {children}
            </div>
        </Wrapper>
    );
}

Installation

npx shadcn@latest add @phucbm/moving-border

Usage

import { MovingBorder } from "@/components/moving-border"
<MovingBorder />