Menu

Confetti Action Button

Theme‑aware confetti submit button with 3D tilt physics, success bloom, and animated status transitions. Built with Framer Motion and canvas‑confetti, adapts cleanly to light/dark via design tokens.

Preview

Live interactive preview.

Installation

Add this component to your project using the CLI:

terminal
npx -y vui-registry-cli-v1@latest add premium-button

Source Code

premium-button.tsx
'use client'

import React, { useRef, useState } from 'react'
import { motion, useMotionValue, useSpring, useTransform, AnimatePresence, useMotionTemplate } from 'framer-motion'
import { cn } from '@/lib/utils'
import confetti from 'canvas-confetti'

export function PremiumSubmitButton({
  className,
  children,
  ...props
}: React.ComponentPropsWithoutRef<typeof motion.button>) {
  const [status, setStatus] = useState<'idle' | 'loading' | 'success'>('idle')
  const ref = useRef<HTMLButtonElement>(null)

  // 3D Tilt Values
  const x = useMotionValue(0)
  const y = useMotionValue(0)
  const xSpring = useSpring(x, { stiffness: 200, damping: 25, mass: 0.5 })
  const ySpring = useSpring(y, { stiffness: 200, damping: 25, mass: 0.5 })
  const rotateX = useTransform(ySpring, [-0.5, 0.5], ['4deg', '-4deg'])
  const rotateY = useTransform(xSpring, [-0.5, 0.5], ['-4deg', '4deg'])

  // Spotlight Coordinates
  const mouseX = useMotionValue(0)
  const mouseY = useMotionValue(0)
  
  // FIX: Hook initialized at the top level to prevent render crashes
  const spotlightBackground = useMotionTemplate`radial-gradient(80px circle at ${mouseX}px ${mouseY}px, rgba(255,255,255,0.15), transparent 100%)`

  const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (!ref.current || status !== 'idle') return
    const rect = ref.current.getBoundingClientRect()
    
    // Tilt logic
    x.set((e.clientX - rect.left) / rect.width - 0.5)
    y.set((e.clientY - rect.top) / rect.height - 0.5)
    
    // Spotlight logic
    mouseX.set(e.clientX - rect.left)
    mouseY.set(e.clientY - rect.top)
  }

  const handleMouseLeave = () => {
    x.set(0)
    y.set(0)
  }

  const handleAction = async () => {
    if (status !== 'idle') return
    setStatus('loading')

    await new Promise((r) => setTimeout(r, 1500))
    setStatus('success')

    // Refined, monochromatic confetti burst
    const rect = ref.current?.getBoundingClientRect()
    const xPos = rect ? (rect.left + rect.width / 2) / window.innerWidth : 0.5
    const yPos = rect ? (rect.top + rect.height / 2) / window.innerHeight : 0.5

    confetti({
      particleCount: 50,
      spread: 60,
      startVelocity: 30,
      scalar: 0.6,
      ticks: 80,
      origin: { x: xPos, y: yPos },
      colors: ['#ffffff', '#a3a3a3', '#525252'],
      disableForReducedMotion: true,
    })

    setTimeout(() => setStatus('idle'), 2500)
  }

  return (
    <div style={{ perspective: '1000px' }} className="flex items-center justify-center p-4">
      <motion.button
        ref={ref}
        layout
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        onClick={handleAction}
        style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}
        whileTap={{ scale: 0.98, rotateX: 0, rotateY: 0 }}
        transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
        className={cn(
          'group relative inline-flex items-center justify-center',
          'min-w-[140px] px-6 py-2.5 rounded-xl',
          'bg-foreground text-background font-medium text-sm',
          'shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_20px_rgba(255,255,255,0.05)]',
          'border border-transparent ring-1 ring-inset ring-background/10',
          'overflow-hidden transition-all duration-300',
          status === 'success' && 'bg-emerald-500 text-white ring-emerald-400/50 shadow-emerald-500/20',
          className
        )}
        {...props}
      >
        {/* Dynamic Hover Spotlight */}
        {status === 'idle' && (
            <motion.div
                className="absolute inset-0 z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"
                style={{ background: spotlightBackground }}
            />
        )}

        {/* Content Layer */}
        <div className="relative z-10 flex items-center justify-center" style={{ transform: 'translateZ(10px)' }}>
          <AnimatePresence mode="wait" initial={false}>
            {status === 'idle' && (
              <motion.span
                key="idle"
                initial={{ opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -10 }}
                transition={{ type: "spring", bounce: 0, duration: 0.3 }}
                className="flex items-center gap-2 tracking-wide"
              >
                {children || 'Submit'}
              </motion.span>
            )}

            {status === 'loading' && (
              <motion.span
                key="loading"
                initial={{ opacity: 0, scale: 0.8 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.8 }}
                transition={{ type: "spring", bounce: 0, duration: 0.3 }}
                className="flex items-center justify-center"
              >
                <svg className="animate-spin h-4 w-4 text-background/70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                  <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3"></circle>
                  <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                </svg>
              </motion.span>
            )}

            {status === 'success' && (
              <motion.span
                key="success"
                initial={{ opacity: 0, scale: 0.5 }}
                animate={{ opacity: 1, scale: 1 }}
                transition={{ type: "spring", bounce: 0.4, duration: 0.4 }}
                className="flex items-center justify-center text-white"
              >
                <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                  <motion.path 
                    initial={{ pathLength: 0 }} 
                    animate={{ pathLength: 1 }} 
                    transition={{ duration: 0.4, ease: "easeOut" }}
                    d="M5 13l4 4L19 7" 
                  />
                </svg>
              </motion.span>
            )}
          </AnimatePresence>
        </div>
      </motion.button>
    </div>
  )
}

Dependencies

  • framer-motion: latest
  • canvas-confetti: latest

Props

Component property reference.

NameTypeDefaultDescription
classNamestring-Custom class names for the button.
motionPropsReact.ComponentPropsWithoutRef<typeof motion.button>-Pass any Framer Motion button props (onClick, whileTap, etc.).
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.