# Morph Button

Six single-purpose morphing buttons — submit, recommend, subscribe, settings, delete, and share — each animating its icon on hover with framer-motion.

- Category: Buttons & Badges
- License: MIT
- Page: https://www.saasuji.com/components/morph-button
- Install: npx shadcn@latest add https://www.saasuji.com/r/morph-button.json

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| label | `string` | `"Submit" | "Recommend" | "Subscribe" | "Settings" | "Delete" | "Share"` | Button text, with a distinct default per export: MorphSubmitButton, ColorRecommendButton, RingSubscribeButton, RotateSettingsButton, ShakeDeleteButton, and MorphShareButton. |
| hideLabel | `boolean` | `false` | Hides the label text, leaving only the morphing icon. |
| className | `string` | — | Additional classes merged onto the underlying motion.button. |
| disabled | `boolean` | `false` | Disables the button; forwarded to the underlying motion.button. |

## Source

```tsx
"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Send from "lucide-react/dist/esm/icons/send";
import Check from "lucide-react/dist/esm/icons/check";
import ThumbsUp from "lucide-react/dist/esm/icons/thumbs-up";
import Bell from "lucide-react/dist/esm/icons/bell";
import BellRing from "lucide-react/dist/esm/icons/bell-ring";
import Settings from "lucide-react/dist/esm/icons/settings";
import Trash2 from "lucide-react/dist/esm/icons/trash-2";
import Link2 from "lucide-react/dist/esm/icons/link-2";

import { cn } from "@/lib/utils";

const baseClasses =
  "relative flex items-center justify-center h-[36px] px-6 rounded-[40px] border cursor-pointer transition-colors duration-150 text-[var(--ds-ink)] border-[var(--ds-hairline)] bg-[var(--ds-surface-soft)] hover:border-[var(--ds-ink-tertiary)]";

const labelClasses = "font-medium tracking-tight text-[13px] ml-2.5";

const iconBoxClasses = "relative w-[16px] h-[16px] flex items-center justify-center shrink-0";

const spring = { type: "tween", duration: 0.15, ease: "easeOut" } as const;

interface MorphButtonProps extends React.ComponentProps<typeof motion.button> {
  label: string;
  hideLabel?: boolean;
}

function useHover() {
  const [isHovered, setIsHovered] = useState(false);
  return {
    isHovered,
    handlers: {
      onMouseEnter: () => setIsHovered(true),
      onMouseLeave: () => setIsHovered(false),
    },
  };
}

export function MorphSubmitButton({ label = "Submit", hideLabel = false, className, ...props }: Partial<MorphButtonProps> & { label?: string }) {
  const { isHovered, handlers } = useHover();
  return (
    <motion.button
      {...handlers}
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.96 }}
      data-slot="morph-submit-button"
      className={cn(baseClasses, className)}
      {...props}
    >
      <span className={iconBoxClasses}>
        <AnimatePresence mode="popLayout" initial={false}>
          {!isHovered ? (
            <motion.span
              key="send"
              initial={{ scale: 0.5, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0.5, opacity: 0 }}
              transition={spring}
              className="absolute inset-0 flex items-center justify-center"
            >
              <Send className="w-4 h-4" />
            </motion.span>
          ) : (
            <motion.span
              key="check"
              initial={{ scale: 0.5, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0.5, opacity: 0 }}
              transition={spring}
              className="absolute inset-0 flex items-center justify-center"
            >
              <Check className="w-4 h-4" />
            </motion.span>
          )}
        </AnimatePresence>
      </span>
      {!hideLabel && <span className={labelClasses}>{label}</span>}
    </motion.button>
  );
}

export function ColorRecommendButton({ label = "Recommend", hideLabel = false, className, ...props }: Partial<MorphButtonProps> & { label?: string }) {
  const { isHovered, handlers } = useHover();
  return (
    <motion.button
      {...handlers}
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.96 }}
      data-slot="morph-recommend-button"
      className={cn(baseClasses, className)}
      {...props}
    >
      <span className={iconBoxClasses}>
        <ThumbsUp
          className={cn(
            "w-4 h-4 transition-colors duration-300",
            isHovered ? "text-blue-400 fill-blue-400" : "text-[var(--ds-ink-secondary)]"
          )}
        />
      </span>
      {!hideLabel && <span className={labelClasses}>{label}</span>}
    </motion.button>
  );
}

export function RingSubscribeButton({ label = "Subscribe", hideLabel = false, className, ...props }: Partial<MorphButtonProps> & { label?: string }) {
  const { isHovered, handlers } = useHover();
  return (
    <motion.button
      {...handlers}
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.96 }}
      data-slot="morph-subscribe-button"
      className={cn(baseClasses, className)}
      {...props}
    >
      <span className={iconBoxClasses}>
        <AnimatePresence mode="popLayout" initial={false}>
          {!isHovered ? (
            <motion.span
              key="bell"
              initial={{ rotate: -15, scale: 0.8, opacity: 0 }}
              animate={{ rotate: 0, scale: 1, opacity: 1 }}
              exit={{ rotate: 15, scale: 0.8, opacity: 0 }}
              transition={spring}
              className="absolute inset-0 flex items-center justify-center"
            >
              <Bell className="w-4 h-4" />
            </motion.span>
          ) : (
            <motion.span
              key="bell-ring"
              initial={{ rotate: -15, scale: 0.8, opacity: 0 }}
              animate={{ rotate: 0, scale: 1, opacity: 1 }}
              exit={{ rotate: 15, scale: 0.8, opacity: 0 }}
              transition={spring}
              className="absolute inset-0 flex items-center justify-center"
            >
              <BellRing className="w-4 h-4 text-white" />
              <motion.span
                initial={{ scale: 0.95 }}
                animate={{ scale: 1 }}
                transition={{ type: "spring", stiffness: 600, damping: 15, delay: 0.1 }}
                className="absolute top-0 right-0 w-1.5 h-1.5 bg-red-500 rounded-full"
              />
            </motion.span>
          )}
        </AnimatePresence>
      </span>
      {!hideLabel && <span className={labelClasses}>{label}</span>}
    </motion.button>
  );
}

export function RotateSettingsButton({ label = "Settings", hideLabel = false, className, ...props }: Partial<MorphButtonProps> & { label?: string }) {
  const { isHovered, handlers } = useHover();
  return (
    <motion.button
      {...handlers}
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.96 }}
      data-slot="morph-settings-button"
      className={cn(baseClasses, className)}
      {...props}
    >
      <span className={iconBoxClasses}>
        <motion.span
          animate={{ rotate: isHovered ? 180 : 0 }}
          transition={{ type: "spring", stiffness: 400, damping: 25 }}
          className="flex items-center justify-center"
        >
          <Settings className="w-4 h-4" />
        </motion.span>
      </span>
      {!hideLabel && <span className={labelClasses}>{label}</span>}
    </motion.button>
  );
}

export function ShakeDeleteButton({ label = "Delete", hideLabel = false, className, ...props }: Partial<MorphButtonProps> & { label?: string }) {
  const { isHovered, handlers } = useHover();
  return (
    <motion.button
      {...handlers}
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.96 }}
      data-slot="morph-delete-button"
      className={cn(baseClasses, className)}
      {...props}
    >
      <span className={iconBoxClasses}>
        <motion.span
          animate={{
            y: isHovered ? [0, -2, 0, -2, 0] : 0,
            rotate: isHovered ? [0, -10, 10, -10, 0] : 0,
          }}
          transition={{ duration: 0.4 }}
          className="flex items-center justify-center"
        >
          <Trash2 className="w-4 h-4 text-red-400" />
        </motion.span>
      </span>
      {!hideLabel && <span className={cn(labelClasses, "text-red-400")}>{label}</span>}
    </motion.button>
  );
}

export function MorphShareButton({ label = "Share", hideLabel = false, className, ...props }: Partial<MorphButtonProps> & { label?: string }) {
  const { isHovered, handlers } = useHover();
  return (
    <motion.button
      {...handlers}
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.96 }}
      data-slot="morph-share-button"
      className={cn(baseClasses, className)}
      {...props}
    >
      <span className={iconBoxClasses}>
        <AnimatePresence mode="popLayout" initial={false}>
          {!isHovered ? (
            <motion.span
              key="link"
              initial={{ scale: 0.5, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0.5, opacity: 0 }}
              transition={spring}
              className="absolute inset-0 flex items-center justify-center"
            >
              <Link2 className="w-4 h-4" />
            </motion.span>
          ) : (
            <motion.span
              key="send"
              initial={{ scale: 0.5, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0.5, opacity: 0 }}
              transition={spring}
              className="absolute inset-0 flex items-center justify-center"
            >
              <Send className="w-4 h-4" />
            </motion.span>
          )}
        </AnimatePresence>
      </span>
      {!hideLabel && <span className={labelClasses}>{label}</span>}
    </motion.button>
  );
}

```
