Native Liquid Button

PreviousNext

Button with animated liquid fill effect, progress tracking, and multiple visual variants for engaging interactions.

Docs
uitripledcomponent

Preview

Loading preview…
components/native/shadcnui/native-liquid-button-shadcnui.tsx
"use client";

import {
  NativeButton,
  type NativeButtonProps,
} from "@/components/native/shadcnui/native-button-shadcnui";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";

export interface NativeLiquidButtonProps
  extends Omit<NativeButtonProps, "onClick" | "loading"> {
  /**
   * Progress value (0-100)
   */
  progress?: number;
  /**
   * Loading state
   */
  loading?: boolean;
  /**
   * Callback when button is clicked
   */
  onClick?: () => void | Promise<void>;
  /**
   * Visual variant
   * Default: "default"
   */
  liquidVariant?: "default" | "gradient" | "glow" | "wave";
  /**
   * Liquid color
   */
  liquidColor?: string;
  /**
   * Show percentage text
   * Default: false
   */
  showPercentage?: boolean;
  /**
   * Auto-simulate loading (for demo purposes)
   */
  autoSimulate?: boolean;
  /**
   * Success state
   */
  success?: boolean;
  /**
   * Error state
   */
  error?: boolean;
}

const sizeVariants = {
  sm: "h-9 px-4 text-sm min-w-[100px]",
  default: "h-11 px-6 text-base min-w-[140px]",
  lg: "h-14 px-8 text-lg min-w-[180px]",
  icon: "h-11 px-6 text-base min-w-[140px]",
};

export function NativeLiquidButton({
  children,
  className,
  variant = "default",
  size = "default",
  progress = 0,
  loading = false,
  onClick,
  disabled,
  liquidVariant = "default",
  liquidColor,
  showPercentage = false,
  autoSimulate = false,
  success = false,
  error = false,
  ...props
}: NativeLiquidButtonProps) {
  const [internalProgress, setInternalProgress] = useState(progress);
  const [isSimulating, setIsSimulating] = useState(false);

  useEffect(() => {
    setInternalProgress(progress);
  }, [progress]);

  useEffect(() => {
    if (autoSimulate && isSimulating) {
      const interval = setInterval(() => {
        setInternalProgress((prev) => {
          if (prev >= 100) {
            clearInterval(interval);
            setIsSimulating(false);
            return 100;
          }
          return prev + Math.random() * 10;
        });
      }, 200);
      return () => clearInterval(interval);
    }
  }, [autoSimulate, isSimulating]);

  const handleClick = async () => {
    if (disabled || loading) return;

    if (autoSimulate) {
      setIsSimulating(true);
      setInternalProgress(0);
    }

    if (onClick) {
      await onClick();
    }
  };

  const getLiquidColor = () => {
    if (success) return "bg-green-500";
    if (error) return "bg-red-500";
    if (liquidColor) return liquidColor;

    switch (liquidVariant) {
      case "gradient":
        return "bg-gradient-to-r from-primary via-primary/80 to-primary";
      case "glow":
        return "bg-primary";
      default:
        return "bg-primary";
    }
  };

  const clampedProgress = Math.min(Math.max(internalProgress, 0), 100);

  return (
    <div className="relative inline-block">
      <NativeButton
        variant={variant}
        size={size}
        loading={false}
        disabled={disabled || loading}
        onClick={handleClick}
        className={cn(
          sizeVariants[size || "default"],
          "relative overflow-hidden font-semibold transition-all duration-300",
          "before:absolute before:inset-0 before:bg-background/20 before:pointer-events-none before:rounded-md",
          liquidVariant === "glow" &&
            !disabled &&
            "shadow-lg shadow-primary/30",
          className
        )}
        {...props}
      >
        {/* Liquid fill effect */}
        <motion.div
          className={cn("absolute inset-0 origin-left", getLiquidColor())}
          initial={{ scaleX: 0 }}
          animate={{ scaleX: clampedProgress / 100 }}
          transition={{
            type: "spring",
            stiffness: 100,
            damping: 20,
          }}
        >
          {/* Bubble effects */}
          {liquidVariant === "default" && clampedProgress > 0 && (
            <>
              {[...Array(3)].map((_, i) => (
                <motion.div
                  key={i}
                  className="absolute bottom-0 w-2 h-2 bg-white/30 rounded-md"
                  style={{
                    left: `${20 + i * 25}%`,
                  }}
                  animate={{
                    y: [-10, -50, -10],
                    opacity: [0, 1, 0],
                    scale: [0.5, 1, 0.5],
                  }}
                  transition={{
                    duration: 2,
                    repeat: Number.POSITIVE_INFINITY,
                    delay: i * 0.4,
                    ease: "easeInOut",
                  }}
                />
              ))}
            </>
          )}

          {/* Shimmer effect for gradient */}
          {liquidVariant === "gradient" && clampedProgress > 0 && (
            <motion.div
              className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent"
              animate={{
                x: ["-100%", "200%"],
              }}
              transition={{
                duration: 1.5,
                repeat: Number.POSITIVE_INFINITY,
                ease: "linear",
              }}
            />
          )}
        </motion.div>

        {/* Button content */}
        <span className="relative z-10 flex items-center justify-center gap-2">
          <AnimatePresence mode="wait">
            {loading && (
              <motion.div
                key="loading"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
              >
                <Loader2 className="w-4 h-4 animate-spin" />
              </motion.div>
            )}
          </AnimatePresence>

          <motion.span
            animate={loading ? { opacity: [1, 0.5, 1] } : { opacity: 1 }}
            transition={
              loading
                ? {
                    duration: 1.5,
                    repeat: Number.POSITIVE_INFINITY,
                    ease: "easeInOut",
                  }
                : { duration: 0.2 }
            }
          >
            {children}
          </motion.span>

          {showPercentage && (
            <motion.span
              key={clampedProgress}
              initial={{ scale: 1.2, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              className="text-xs font-mono"
            >
              {Math.round(clampedProgress)}%
            </motion.span>
          )}
        </span>

        {/* Glow effect */}
        {liquidVariant === "glow" && !disabled && clampedProgress > 0 && (
          <motion.div
            className="absolute inset-0 bg-primary/20 blur-xl"
            animate={{
              opacity: [0.3, 0.6, 0.3],
            }}
            transition={{
              duration: 2,
              repeat: Number.POSITIVE_INFINITY,
              ease: "easeInOut",
            }}
          />
        )}
      </NativeButton>
    </div>
  );
}

Installation

npx shadcn@latest add @uitripled/native-liquid-button

Usage

import { NativeLiquidButton } from "@/components/native-liquid-button"
<NativeLiquidButton />