Wavy Button

PreviousNext

Interactive wavybutton with animated text and customizable styles for modern uis.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/wavy-button.tsx
use client";
import * as React from "react";
import { motion } from "framer-motion";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
  "relative inline-flex items-center justify-center gap-2 font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 overflow-hidden [&_svg]:pointer-events-none",
  {
    variants: {
      variant: {
        default: "bg-primary text-white hover:bg-primary/90",
        destructive: "bg-red-600 text-white hover:bg-red-500",
        outline:
          "border-2 border-gray-500 bg-transparent text-black dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800",
        secondary: "bg-gray-500 text-white hover:bg-gray-400",
        success: "bg-green-600 text-white hover:bg-green-500",
        warning: "bg-yellow-400 text-black hover:bg-yellow-300",
        info: "bg-blue-600 text-white hover:bg-blue-500",
        gradient: "bg-gradient-to-r from-purple-600 to-pink-500 text-white",
        link: "text-primary underline-offset-4 hover:underline bg-transparent shadow-none",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:gap-2",
        sm: "h-8 rounded-md px-3 text-xs has-[>svg]:gap-1.5",
        lg: "h-10 rounded-md px-8 has-[>svg]:gap-2.5",
        xl: "h-24 px-20 text-2xl has-[>svg]:gap-3",
        icon: "h-9 w-9",
        "icon-sm": "h-12 w-12",
        "icon-lg": "h-20 w-20",
      },
      radius: {
        default: "rounded-full",
        sm: "rounded-lg",
        lg: "rounded-[2rem]",
        none: "rounded-none",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
      radius: "default",
    },
  }
);

interface VariantColorsType {
  fromBg: string;
  toBg: string;
  stroke?: string;
}

const variantColors: Record<
  NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
  VariantColorsType
> = {
  default: { fromBg: "#4a6b3f", toBg: "#d5e798", stroke: "#d5e798" },
  destructive: { fromBg: "#dc2626", toBg: "#fca5a5", stroke: "#fca5a5" },
  outline: {
    fromBg: "transparent",
    toBg: "transparent",
    stroke: "currentColor",
  },
  secondary: { fromBg: "#64748b", toBg: "#cbd5e1", stroke: "#cbd5e1" },
  success: { fromBg: "#16a34a", toBg: "#86efac", stroke: "#86efac" },
  warning: { fromBg: "#eab308", toBg: "#fde047", stroke: "#fde047" },
  info: { fromBg: "#3b82f6", toBg: "#93c5fd", stroke: "#93c5fd" },
  gradient: { fromBg: "#8b5cf6", toBg: "#ec4899", stroke: "#ec4899" },
  link: { fromBg: "transparent", toBg: "transparent", stroke: "currentColor" },
};

interface WavyTextProps {
  text: string;
  isHovered: boolean;
  className?: string;
  duration: number;
  delay: number;
}

const WavyText: React.FC<WavyTextProps> = ({
  text,
  isHovered,
  className = "",
  duration,
  delay,
}) => {
  const chars = text.split("");
  return (
    <span className="relative z-20 inline-flex">
      {chars.map((char, index) => (
        <motion.span
          key={index}
          className={className}
          animate={isHovered ? { y: [0, -8, 0] } : { y: 0 }}
          transition={
            {
              duration,
              delay: index * delay,
              ease: [0.4, 0, 0.2, 1],
            }
          }
          style={
            {
              display: "inline-block",
              whiteSpace: char === " " ? "pre" : "normal",
            }
          }
        >
          {char === " " ? "\u00A0" : char}
        </motion.span>
      ))}
    </span>
  );
};

interface WavyButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  children: React.ReactNode;
  animationDuration?: number;
  strokeWidth?: number;
  splitDelay?: number;
  asChild?: boolean;
  disableTextAnimation?: boolean;
}

const WavyButton = React.forwardRef<HTMLButtonElement, WavyButtonProps>(
  (
    {
      className,
      variant = "default",
      size,
      radius,
      children,
      animationDuration = 0.8,
      strokeWidth = 30,
      splitDelay = 0.04,
      asChild = false,
      disableTextAnimation = false,
      ...props
    },
    ref
  ) => {
    const [isHovered, setIsHovered] = React.useState(false);
    const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
    const colors = variantColors[variant ?? "default"];
    const Component: React.ElementType = asChild ? Slot : motion.button;

    const handleTouchStart = () => {
      setIsHovered(true);
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => {
        setIsHovered(false);
      }, 2000);
    };

    React.useEffect(() => {
      return () => {
        if (timeoutRef.current) {
          clearTimeout(timeoutRef.current);
        }
      };
    }, []);

    return (
      <Component
        ref={ref}
        className={cn(buttonVariants({ variant, size, radius, className }))}
        onMouseEnter={() => setIsHovered(true)}
        onMouseLeave={() => setIsHovered(false)}
        onTouchStart={handleTouchStart}
        animate={
          !asChild ? { backgroundColor: isHovered ? colors.toBg : colors.fromBg } : undefined
        }
        transition={
          !asChild
            ? { duration: animationDuration, ease: [0.4, 0, 0.2, 1] }
            : undefined
        }
        {...props}
      >
        <svg
          viewBox="0 0 260 64"
          fill="none"
          xmlns="http://www.w3.org/2000/svg"
          className="absolute inset-0 w-full h-full z-10 pointer-events-none"
          preserveAspectRatio="none"
        >
          <defs>
            <clipPath id="clip-wave">
              <rect width="260" height="64" fill="white" />
            </clipPath>
          </defs>
          <g clipPath="url(#clip-wave)">
            <motion.path
              d="M-11.7907 25.5948C-1.99079 7.39406 53.3086 -7.30655 91.8081 -10.8067C130.308 -14.3068 164.607 -12.2068 129.608 1.79383C94.6081 15.7944 37.9088 5.29517 -4.79076 43.0967C-47.4903 80.8983 1.50917 68.9978 11.3091 61.2975C21.1089 53.5972 55.4086 37.4965 79.2083 36.0965C103.008 34.6964 153.407 32.5939 174.407 1.79383C195.407 -29.0063 219.207 -29.0063 196.807 13.6955C174.407 56.3973 105.808 57.7985 84.8083 61.2975C63.8085 64.7965 44.9087 67.5966 32.3089 78.0971C19.709 88.5975 127.508 83.6962 157.607 72.4968C187.707 61.2975 218.507 24.8948 227.607 -1.00624C236.707 -26.9073 261.906 -7.3065 252.806 7.39411C243.706 22.0947 217.807 55.6961 207.307 66.8966C196.807 78.0971 219.207 96.9978 236.007 72.4968C252.806 47.9958 280.106 15.7945 285.706 7.39411"
              stroke={colors.stroke}
              strokeWidth={strokeWidth}
              pathLength={1}
              initial={{ pathLength: 0 }}
              animate={isHovered ? { pathLength: 1 } : { pathLength: 0 }}
              transition={
                {
                  duration: animationDuration,
                  ease: [0.4, 0, 0.2, 1],
                }
              }
            />
          </g>
        </svg>

        <div
          className={cn(
            "relative z-20 inline-flex items-center",
            isHovered ? "text-white dark:text-black" : "text-black dark:text-white"
          )}
        >
          {typeof children === "string" && !disableTextAnimation ? (
            <WavyText
              text={children}
              isHovered={isHovered}
              duration={animationDuration}
              delay={splitDelay}
            />
          ) : (
            children
          )}
        </div>
      </Component>
    );
  }
);

WavyButton.displayName = "WavyButton";

export default WavyButton;

Installation

npx shadcn@latest add @scrollxui/wavy-button

Usage

import { WavyButton } from "@/components/wavy-button"
<WavyButton />