Button

PreviousNext

Reusable button component with multiple variants and sizes.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/followcursor.tsx
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { Renderer, Transform, Vec3, Color, Polyline, Program } from "ogl";

const vertexShader = `
    precision highp float;

    attribute vec3 position;
    attribute vec3 next;
    attribute vec3 prev;
    attribute vec2 uv;
    attribute float side;

    uniform vec2 uResolution;
    uniform float uDPR;
    uniform float uThickness;
    uniform float uTime;

    vec4 getPosition() {
        vec4 current = vec4(position, 1);

        vec2 aspect = vec2(uResolution.x / uResolution.y, 1);
        vec2 nextScreen = next.xy * aspect;
        vec2 prevScreen = prev.xy * aspect;

        
        vec2 tangent = normalize(nextScreen - prevScreen);

        
        vec2 normal = vec2(-tangent.y, tangent.x);
        normal /= aspect;

        
        float noise = sin(position.x * 100.0 + uTime * 2.0) * 0.1;
        normal *= 1.0 + noise;

        
        float taper = smoothstep(0.0, 0.3, uv.y) * (1.0 - smoothstep(0.7, 1.0, uv.y));
        normal *= taper;

        
        float dist = length(nextScreen - prevScreen);
        normal *= smoothstep(0.0, 0.02, dist);

        float pixelWidthRatio = 1.0 / (uResolution.y / uDPR);
        float pixelWidth = current.w * pixelWidthRatio;
        normal *= pixelWidth * uThickness;
        current.xy -= normal * side;

        return current;
    }

    void main() {
        gl_Position = getPosition();
    }
`;

const fragmentShader = `
    precision highp float;
    
    uniform vec3 uColor;
    uniform float uTime;
    uniform float uIntensity;
    
    void main() {
        
        vec3 color = uColor * (0.9 + 0.1 * sin(uTime * 0.5));
        
        
        float glow = uIntensity * 0.5;
        color += glow * vec3(1.0, 1.0, 1.0);
        
        gl_FragColor = vec4(color, 1.0);
    }
`;

interface PolylineConfig {
  color: string;
  thickness: number;
  count: number;
  spring: number;
  friction: number;
  offset: Vec3;
}

interface PolylineItem extends PolylineConfig {
  points: Vec3[];
  polyline: Polyline;
  program: Program;
  velocity: Vec3;
  targetIntensity: number;
}

interface FollowCursorProps {
  className?: string;
  style?: React.CSSProperties;
  colors?: string[];
  thickness?: { min: number; max: number };
  count?: { min: number; max: number };
  bgColor?: string;
}

export default function FollowCursor({
  className = "",
  style = {},
  colors = [
    "#FF6B6B",
    "#4ECDC4",
    "#45B7D1",
    "#FFBE0B",
    "#FB5607",
    "#8338EC",
    "#3A86FF",
    "#FF006E",
    "#A5FFD6",
    "#FF9E00",
  ],
  thickness = { min: 30, max: 100 },
  count = { min: 15, max: 25 },
  bgColor = "rgba(26, 26, 38, 1)",
}: FollowCursorProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const rendererRef = useRef<Renderer | null>(null);
  const sceneRef = useRef<Transform | null>(null);
  const linesRef = useRef<PolylineItem[]>([]);
  const mouseRef = useRef(new Vec3());
  const animationRef = useRef<number>(0);
  const timeRef = useRef(0);
  const resizeObserverRef = useRef<ResizeObserver | null>(null);

  const isSetupCompleteRef = useRef(false);
  const [isMounted, setIsMounted] = useState(false);

  const lerp = (a: number, b: number, t: number) => a * (1 - t) + b * t;

  const generateConfigs = useCallback((): PolylineConfig[] => {
    return colors.map((color) => ({
      color,
      thickness:
        thickness.min + Math.random() * (thickness.max - thickness.min),
      count: count.min + Math.floor(Math.random() * (count.max - count.min)),
      spring: 0.1 + Math.random() * 0.15,
      friction: 0.85 + Math.random() * 0.1,
      offset: new Vec3(
        (Math.random() - 0.5) * 0.05,
        (Math.random() - 0.5) * 0.05,
        0
      ),
    }));
  }, [colors, thickness, count]);

  useEffect(() => {
    setIsMounted(true);
    return () => setIsMounted(false);
  }, []);

  const getCanvasPosition = () => {
    if (!canvasRef.current) return { left: 0, top: 0 };
    const rect = canvasRef.current.getBoundingClientRect();
    return { left: rect.left, top: rect.top };
  };

  useEffect(() => {
    if (!isMounted || !canvasRef.current || !containerRef.current) return;

    if (isSetupCompleteRef.current && rendererRef.current) return;

    let isComponentMounted = true;

    const container = containerRef.current;
    const canvas = canvasRef.current;

    canvas.width = container.clientWidth;
    canvas.height = container.clientHeight;

    const renderer = new Renderer({
      canvas: canvas,
      dpr: Math.min(window.devicePixelRatio, 2),
      antialias: true,
    });
    const gl = renderer.gl;
    rendererRef.current = renderer;

    const bgColorObj = new Color(bgColor);
    gl.clearColor(bgColorObj.r, bgColorObj.g, bgColorObj.b, 1);

    const scene = new Transform();
    sceneRef.current = scene;

    const configs = generateConfigs();
    const lines: PolylineItem[] = [];

    try {
      for (const config of configs) {
        const points = Array.from({ length: config.count }, (_, i) => {
          const offset = i * 0.01;
          return new Vec3(offset, offset, 0);
        });

        const polyline = new Polyline(gl, {
          points,
          vertex: vertexShader,
          fragment: fragmentShader,
          uniforms: {
            uColor: { value: new Color(config.color) },
            uThickness: { value: config.thickness },
            uTime: { value: 0 },
            uIntensity: { value: 0 },
          },
        });

        polyline.mesh.setParent(scene);

        lines.push({
          ...config,
          points,
          polyline,
          program: polyline.mesh.program,
          velocity: new Vec3(),
          targetIntensity: 0,
        });
      }

      linesRef.current = lines;
    } catch (error) {
      console.error("Failed to create polylines:", error);
      return;
    }

    isSetupCompleteRef.current = true;

    const handleResize = () => {
      if (!isComponentMounted || !renderer || !container) return;

      try {
        renderer.setSize(container.clientWidth, container.clientHeight);

        if (lines && Array.isArray(lines)) {
          lines.forEach((line) => {
            if (
              line &&
              line.polyline &&
              typeof line.polyline.resize === "function"
            ) {
              line.polyline.resize();
            }
          });
        }
      } catch (error) {
        console.error("Failed to resize:", error);
      }
    };

    const handlePointerMove = (e: MouseEvent | TouchEvent) => {
      if (!isComponentMounted || !renderer || !canvas) return;

      const event = "touches" in e ? e.touches[0] : e;

      const canvasPos = getCanvasPosition();

      const x = event.clientX - canvasPos.left;
      const y = event.clientY - canvasPos.top;

      mouseRef.current.set(
        (x / canvas.width) * 2 - 1,
        (y / canvas.height) * -2 + 1,
        0
      );

      if (lines && Array.isArray(lines)) {
        lines.forEach((line) => {
          line.targetIntensity = 0.7 + Math.random() * 0.3;

          if (line.points && line.points.length > 0) {
            const firstPoint = line.points[0];
            if (firstPoint) {
              firstPoint.lerp(mouseRef.current.clone().add(line.offset), 0.6);
            }
          }
        });
      }
    };

    resizeObserverRef.current = new ResizeObserver(() => {
      handleResize();
    });

    if (container) {
      resizeObserverRef.current.observe(container);
    }

    window.addEventListener("mousemove", handlePointerMove);
    window.addEventListener("touchmove", handlePointerMove);

    mouseRef.current.set(0, 0, 0);

    handleResize();

    const animate = (time: number) => {
      if (!isComponentMounted || !isSetupCompleteRef.current) {
        return;
      }

      timeRef.current = time * 0.001;
      const tmp = new Vec3();

      try {
        const currentLines = linesRef.current;
        if (
          currentLines &&
          Array.isArray(currentLines) &&
          currentLines.length > 0
        ) {
          for (const line of currentLines) {
            for (let i = line.points.length - 1; i >= 0; i--) {
              if (i === 0) {
                tmp
                  .copy(mouseRef.current)
                  .add(line.offset)
                  .sub(line.points[i])
                  .multiply(line.spring * 6.0);

                line.velocity.add(tmp).multiply(0.92);
                line.points[i].add(line.velocity);

                line.points[i].lerp(
                  mouseRef.current.clone().add(line.offset),
                  0.4
                );
              } else {
                line.points[i].lerp(
                  line.points[i - 1],
                  0.88 + Math.sin(timeRef.current + i) * 0.05
                );
              }
            }

            if (line.program && line.program.uniforms) {
              if (line.program.uniforms.uTime) {
                line.program.uniforms.uTime.value = timeRef.current;
              }

              if (line.program.uniforms.uIntensity) {
                line.program.uniforms.uIntensity.value = lerp(
                  line.program.uniforms.uIntensity.value || 0,
                  line.targetIntensity,
                  0.1
                );
              }
            }

            line.targetIntensity *= 0.9;

            line.polyline.updateGeometry();
          }
        }

        if (rendererRef.current && sceneRef.current) {
          rendererRef.current.render({ scene: sceneRef.current });
        }
      } catch (error) {
        console.error("Animation error:", error);
      }

      if (isComponentMounted) {
        animationRef.current = requestAnimationFrame(animate);
      }
    };

    animationRef.current = requestAnimationFrame(animate);

    return () => {
      isComponentMounted = false;
      isSetupCompleteRef.current = false;

      if (resizeObserverRef.current) {
        resizeObserverRef.current.disconnect();
      }

      window.removeEventListener("mousemove", handlePointerMove);
      window.removeEventListener("touchmove", handlePointerMove);

      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }

      if (gl && typeof gl.getExtension === "function") {
        try {
          const extension = gl.getExtension("WEBGL_lose_context");
          if (extension) {
            extension.loseContext();
          }
        } catch (error) {
          console.error("Error losing WebGL context:", error);
        }
      }
    };
  }, [isMounted, bgColor]); // eslint-disable-line react-hooks/exhaustive-deps

  return (
    <div
      ref={containerRef}
      className={`relative w-full h-full overflow-hidden ${className}`}
      style={style}
    >
      <canvas ref={canvasRef} className="block w-full h-full" />
    </div>
  );
}

Installation

npx shadcn@latest add @scrollxui/followcursor

Usage

import { Followcursor } from "@/components/followcursor"
<Followcursor />