Velocity UI
Loading…
Menu

Morphing Status Button

A button that seamlessly morphs between idle, loading, success, and error states using layout animations.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add morphing-status-button

Source Code

morphing-status-button.tsx
'use client'

import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Loader2, Check, X, ArrowRight, Save, Send } from 'lucide-react'
import { cn } from '@/lib/utils'

type ButtonStatus = 'idle' | 'loading' | 'success' | 'error'

interface MorphingStatusButtonProps
  extends React.ComponentPropsWithoutRef<typeof motion.button> {
  status: ButtonStatus
  label?: string
  successLabel?: string
  errorLabel?: string
  loadingLabel?: string
  icon?: React.ReactNode
  successIcon?: React.ReactNode
  errorIcon?: React.ReactNode
  onStatusChange?: (status: ButtonStatus) => void
}

export function MorphingStatusButton({
  className,
  status = 'idle',
  label = 'Submit',
  successLabel = 'Done',
  errorLabel = 'Error',
  loadingLabel,
  icon,
  successIcon,
  errorIcon,
  children,
  onStatusChange,
  ...props
}: MorphingStatusButtonProps) {
  // Shake animation for error state
  const shakeAnimation = {
    x: [0, -10, 10, -10, 10, 0],
    transition: { duration: 0.4 },
  }

  return (
    <motion.button
      layout
      disabled={status === 'loading'}
      animate={status === 'error' ? shakeAnimation : {}}
      className={cn(
        'group relative flex items-center justify-center gap-2 rounded-full px-6 py-3 font-medium transition-colors',
        'bg-zinc-900 text-white shadow-lg hover:bg-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500',
        'disabled:cursor-not-allowed disabled:opacity-80',
        status === 'success' && 'bg-green-600 hover:bg-green-700',
        status === 'error' && 'bg-red-600 hover:bg-red-700',
        className
      )}
      {...props}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {status === 'idle' && (
          <motion.span
            key="idle"
            initial={{ opacity: 0, y: -15 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 15 }}
            transition={{ type: 'spring', stiffness: 500, damping: 30 }}
            className="flex items-center gap-2"
          >
            {children || (
              <>
                {label}
                {icon}
              </>
            )}
          </motion.span>
        )}

        {status === 'loading' && (
          <motion.span
            key="loading"
            initial={{ opacity: 0, scale: 0.5 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.5 }}
            transition={{ type: 'spring', stiffness: 500, damping: 30 }}
            className="flex items-center justify-center"
          >
            <Loader2 className="h-5 w-5 animate-spin" />
            {loadingLabel && <span className="ml-2">{loadingLabel}</span>}
          </motion.span>
        )}

        {status === 'success' && (
          <motion.span
            key="success"
            initial={{ opacity: 0, y: 15 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -15 }}
            transition={{ type: 'spring', stiffness: 500, damping: 30 }}
            className="flex items-center gap-2"
          >
            {successIcon || <Check className="h-5 w-5" />}
            {successLabel}
          </motion.span>
        )}

        {status === 'error' && (
          <motion.span
            key="error"
            initial={{ opacity: 0, y: 15 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -15 }}
            transition={{ type: 'spring', stiffness: 500, damping: 30 }}
            className="flex items-center gap-2"
          >
            {errorIcon || <X className="h-5 w-5" />}
            {errorLabel}
          </motion.span>
        )}
      </AnimatePresence>
    </motion.button>
  )
}

// Demo Component for the Registry Preview
export default function MorphingStatusButtonDemo() {
  const [status1, setStatus1] = useState<ButtonStatus>('idle')
  const [status2, setStatus2] = useState<ButtonStatus>('idle')
  const [status3, setStatus3] = useState<ButtonStatus>('idle')

  const handleSubmit = (
    set: (s: ButtonStatus) => void,
    result: 'success' | 'error' = 'success'
  ) => {
    set('loading')
    setTimeout(() => {
      set(result)
      setTimeout(() => set('idle'), 2000)
    }, 1500)
  }

  return (
    <div className="flex flex-col items-center justify-center gap-8 p-12 min-h-[400px] bg-background w-full">
      <div className="flex flex-col gap-2 text-center mb-8">
        <h2 className="text-xl font-bold text-foreground">Morphing Status Buttons</h2>
        <p className="text-muted-foreground text-sm">
          Smooth state transitions with layout morphing
        </p>
      </div>

      <div className="flex flex-wrap items-center justify-center gap-8">
        {/* Variant 1: Simple Success */}
        <MorphingStatusButton
          status={status1}
          onClick={() => handleSubmit(setStatus1, 'success')}
          label="Save Changes"
          icon={<Save className="h-4 w-4" />}
          successLabel="Saved!"
          className="min-w-[140px]"
        />

        {/* Variant 2: Error State */}
        <MorphingStatusButton
          status={status2}
          onClick={() => handleSubmit(setStatus2, 'error')}
          label="Delete Item"
          className="bg-zinc-800 hover:bg-zinc-700 min-w-[140px]"
          errorLabel="Failed"
        />

        {/* Variant 3: Icon Only Morph */}
        <MorphingStatusButton
          status={status3}
          onClick={() => handleSubmit(setStatus3, 'success')}
          label="Send"
          icon={<Send className="h-4 w-4" />}
          successLabel="Sent"
          successIcon={<Check className="h-4 w-4" />}
          className="rounded-xl"
        />
      </div>
    </div>
  )
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
status'idle' | 'loading' | 'success' | 'error'-Current state of the button.
labelstring-Text to display in idle state.
successLabelstring-Text to display in success state.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.