Back to library

buttons

Morphing Button

A bell-icon button that morphs into an email input on click. Uses Motion layout animations.

Inspired by watermelon.sh · View source

Preview

Copy for AI

Copy for Lovable is the fastest way to move this component into another Lovable project — it copies the full prompt with dependencies, files, and a usage example. Copy all files gives you the raw source if you want to paste it manually.

Installation

npx shadcn@latest add https://registry.watermelon.sh/r/morphing-button.json

Paste into a terminal — or paste into a Lovable chat and I'll run it for you.

Source files

components/ui/morphing-button.tsx
import React, { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import { FaBell } from "react-icons/fa6";

interface MorphingButtonProps {
  buttonText?: string;
  placeholder?: string;
  onSubmit?: (email: string) => void;
  className?: string;
}

export const MorphingButton: React.FC<MorphingButtonProps> = ({
  buttonText = "Notify Me",
  placeholder = "Email",
  onSubmit,
  className = "",
}) => {
  const [isExpanded, setIsExpanded] = useState(false);
  const [email, setEmail] = useState("");
  const containerRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (
        containerRef.current &&
        !containerRef.current.contains(event.target as Node)
      ) {
        setIsExpanded(false);
      }
    };
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  useEffect(() => {
    if (isExpanded && inputRef.current) {
      inputRef.current.focus();
    }
  }, [isExpanded]);

  const handleToggle = (e: React.MouseEvent) => {
    if (!isExpanded) {
      e.stopPropagation();
      setIsExpanded(true);
    } else if (email) {
      onSubmit?.(email);
      setIsExpanded(false);
      setEmail("");
    }
  };

  const springConfig = {
    type: "spring",
    stiffness: 240,
    damping: 18,
    mass: 1.1,
  } as const;

  return (
    <div className="flex w-full flex-col items-center justify-center gap-12 p-8 transition-colors duration-500">
      <div
        className={`flex items-center justify-center will-change-transform ${className}`}
      >
        <motion.div
          ref={containerRef}
          layout
          transition={springConfig}
          style={{ borderRadius: 32 }}
          className={`relative flex items-center overflow-hidden border-[1.1px] border-[#e7e6e6a6] transition-colors duration-300 dark:border-white/5 ${
            isExpanded
              ? "w-84 bg-[#F4F4F4] p-1 shadow-sm dark:bg-[#1C1C1E] dark:shadow-xl"
              : "w-auto bg-[#F4F4F4] p-0 dark:bg-[#1C1C1E]"
          }`}
        >
          <AnimatePresence mode="popLayout">
            {isExpanded && (
              <motion.div
                key="input-container"
                initial={{ opacity: 0, x: -10 }}
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0 }}
                transition={{ ...springConfig }}
                className="flex flex-1 items-center px-4"
              >
                <motion.input
                  ref={inputRef}
                  layout
                  type="email"
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                  placeholder={placeholder}
                  className="w-full bg-transparent text-xl font-semibold text-[#18181B] placeholder-[#A1A1AA] transition-colors outline-none dark:text-[#fefefe] dark:placeholder-[#B2B2B2]"
                  onKeyDown={(e) => {
                    if (e.key === "Enter" && email) {
                      onSubmit?.(email);
                      setIsExpanded(false);
                      setEmail("");
                    }
                  }}
                />
              </motion.div>
            )}
          </AnimatePresence>

          <motion.button
            layout
            onClick={handleToggle}
            transition={springConfig}
            className={`relative flex items-center justify-center gap-3 rounded-full font-bold whitespace-nowrap transition-colors duration-300 ${
              isExpanded
                ? "bg-[#FEFEFE] px-5 py-3 text-black shadow-sm hover:bg-[#fafafa] dark:bg-[#2C2C2E] dark:text-white dark:shadow-lg dark:hover:bg-[#3A3A3C]"
                : "bg-[#F4F4F4] px-6 py-4 text-black hover:bg-[#ebeaea] dark:bg-[#1C1C1E] dark:text-white dark:hover:bg-[#252529]"
            }`}
          >
            <AnimatePresence mode="popLayout" initial={false}>
              {!isExpanded && (
                <motion.span
                  key="bell-icon"
                  layout
                  className="origin-right"
                  initial={{ opacity: 0, scale: 0, filter: "blur(4px)" }}
                  animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                  exit={{ opacity: 0, scale: 0, filter: "blur(4px)" }}
                  transition={springConfig}
                >
                  <FaBell className="h-6 w-6 text-black/90 dark:text-[#fefefe]" />
                </motion.span>
              )}
            </AnimatePresence>
            <motion.span layout="position" className="text-xl tracking-tight">
              {buttonText}
            </motion.span>
          </motion.button>
        </motion.div>
      </div>
    </div>
  );
};

Dependencies

motionreact-icons