Buttons

Morph Button

A specialized button that performs a fluid width transition between its standard text state and a circular loading state.

Icon Integration

Demonstrates how icons are handled during the morphing animation. Icons smoothly exit the layout as the button compresses into the loading state, preventing visual clutter.

Async Form Submission

A real-world example simulating a newsletter subscription. This demo showcases how to manage the button's isLoading state during network requests and handle post-submission feedback with conditional text and icons.

Subscribe

Join our newsletter for weekly updates.

Variants

Explore the visual flexibility of the button across different themes. Whether using Primary, Secondary, or Ghost variants, the fluid width transition remains consistent and smooth.

Primary
Secondary
Ghost

Installation

npx shadcn@latest add https://satisui.xyz/r/morph-button.json

Manual

  1. Install the required dependencies:
npm install motion
# or
yarn add motion
# or
pnpm add motion
  1. Copy and paste the following code into components/satisui/morph-button.tsx:
'use client';

import * as React from 'react';
import {
  motion,
  AnimatePresence,
  MotionConfig,
  type Transition,
} from 'motion/react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';

/**
 * Props for the MorphButton component.
 */
interface MorphButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** The label text to display in the button. */
  text: string;
  /** If true, replaces text with a spinner and shrinks the button width. */
  isLoading?: boolean;
  /** Optional icon to display to the left of the text. */
  icon?: React.ReactNode;
  /** Visual style variant of the button. */
  variant?: 'primary' | 'secondary' | 'ghost';
}

/**
 * A specialized button that performs a fluid width transition between
 * its standard text state and a circular loading state.
 */
const MorphButton = React.forwardRef<HTMLButtonElement, MorphButtonProps>(
  (
    {
      text,
      isLoading = false,
      icon,
      variant = 'primary',
      className,
      onClick,
      ...props
    },
    ref
  ) => {
    // Physics: Low stiffness (150) + high damping (25) creates the signature
    // "fluid" feel with zero elastic jitter.
    const transition: Transition = {
      type: 'spring',
      stiffness: 150,
      damping: 25,
      mass: 1,
    };

    const variantStyles = {
      primary:
        'bg-primary text-primary-foreground border-primary hover:bg-primary/90 shadow-sm',
      secondary:
        'bg-background text-foreground border-input hover:bg-accent hover:text-accent-foreground shadow-sm',
      ghost:
        'bg-transparent text-foreground border-transparent hover:bg-accent hover:text-accent-foreground',
    };

    return (
      <MotionConfig transition={transition}>
        <motion.button
          ref={ref}
          layout
          className={cn(
            'relative flex h-12 items-center justify-center overflow-hidden rounded-full border font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
            isLoading ? 'px-0' : 'px-8',
            variantStyles[variant],
            (props.disabled || isLoading) &&
              'opacity-50 cursor-not-allowed pointer-events-none',
            className
          )}
          onClick={(e) => !isLoading && onClick?.(e)}
          whileTap={!isLoading ? { scale: 0.98 } : undefined}
          {...(props as any)}
        >
          {/* 
            mode='popLayout' ensures the exiting element is removed from the flow immediately,
            allowing the parent container to animate its width smoothly without layout jumps.
          */}
          <AnimatePresence mode='popLayout' initial={false}>
            {isLoading ? (
              <motion.div
                key='loader'
                className='flex items-center justify-center'
                style={{ width: '3rem' }}
                initial={{ opacity: 0, scale: 0.8, filter: 'blur(10px)' }}
                animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
                exit={{ opacity: 0, scale: 0.8, filter: 'blur(10px)' }}
              >
                <Loader2 className='h-5 w-5 animate-spin' />
              </motion.div>
            ) : (
              <motion.div
                key='content'
                className='flex items-center gap-2 whitespace-nowrap'
                initial={{ opacity: 0, y: 10, filter: 'blur(10px)' }}
                animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
                exit={{ opacity: 0, y: -10, filter: 'blur(10px)' }}
              >
                {icon && <motion.span layout>{icon}</motion.span>}
                <motion.span layout>{text}</motion.span>
              </motion.div>
            )}
          </AnimatePresence>
        </motion.button>
      </MotionConfig>
    );
  }
);

MorphButton.displayName = 'MorphButton';

export { MorphButton };

Usage

Use the MorphButton to provide immediate visual feedback for asynchronous actions.

import { useState } from 'react';
import { MorphButton } from '@/components/ui/morph-button';
import { Send } from 'lucide-react';

export default function MorphButtonDemo() {
  const [loading, setLoading] = useState(false);

  const handleClick = () => {
    setLoading(true);
    // Simulate an async operation
    setTimeout(() => setLoading(false), 2000);
  };

  return (
    <div className='flex flex-col items-center gap-4 p-8'>
      {/* Default Primary Variant */}
      <MorphButton
        text='Save Changes'
        isLoading={loading}
        onClick={handleClick}
      />

      {/* Secondary Variant with Icon */}
      <MorphButton
        text='Send Email'
        variant='secondary'
        icon={<Send className='h-4 w-4' />}
        isLoading={loading}
        onClick={handleClick}
      />
    </div>
  );
}

Props

PropTypeDefaultDescription
textstringRequiredThe label text to display in the button.
isLoadingbooleanfalseIf true, replaces text with a spinner and shrinks the button width.
iconReactNode-Optional icon to display to the left of the text.
variant'primary' | 'secondary' | 'ghost''primary'Visual style variant of the button.