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 { motion, useMotionValue, useSpring, useTransform, AnimatePresence } from 'framer-motion'
import { cn } from '@/lib/utils'
import React, { useRef, useState } from 'react'
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)

  // 1. 3D PERSPECTIVE PHYSICS
  const x = useMotionValue(0)
  const y = useMotionValue(0)

  // Smoother springs for a "heavy" premium feel
  const xSpring = useSpring(x, { stiffness: 100, damping: 20 })
  const ySpring = useSpring(y, { stiffness: 100, damping: 20 })

  // Tilt ranges: Adjust these numbers to make the tilt more or less intense
  const rotateX = useTransform(ySpring, [-0.5, 0.5], ['15deg', '-15deg'])
  const rotateY = useTransform(xSpring, [-0.5, 0.5], ['-15deg', '15deg'])

  const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (!ref.current || status !== 'idle') return
    const rect = ref.current.getBoundingClientRect()

    // Calculate mouse position relative to center of button (-0.5 to 0.5)
    const mouseX = (e.clientX - rect.left) / rect.width - 0.5
    const mouseY = (e.clientY - rect.top) / rect.height - 0.5

    x.set(mouseX)
    y.set(mouseY)
  }

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

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

    // Simulate API delay
    await new Promise((r) => setTimeout(r, 2000))
    setStatus('success')

    confetti({
      particleCount: 100,
      spread: 70,
      origin: { y: 0.7 },
      colors: ['#ffffff', '#444444', '#888888'],
    })

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

  return (
    <div style={{ perspective: '1000px' }} className="flex items-center justify-center p-4">
      <motion.button
        ref={ref}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        onClick={handleAction}
        style={{
          rotateX,
          rotateY,
          transformStyle: 'preserve-3d',
        }}
        whileTap={{ scale: 0.95, rotateX: 0, rotateY: 0 }}
        className={cn(
          'group relative inline-flex items-center justify-center min-w-[240px] px-12 py-5 rounded-2xl',
          'bg-neutral-100 dark:bg-[#050505] text-black dark:text-white overflow-hidden transition-colors duration-500',
          'border border-black/5 dark:border-white/10',
          status === 'success'
            ? 'border-green-500/50'
            : 'hover:border-black/20 dark:hover:border-white/20 hover:bg-neutral-200 dark:hover:bg-[#050505]',
          className,
        )}
        {...props}
      >
        {/* 2. DYNAMIC LIGHT STREAK (Moves with the tilt) */}
        <motion.div
          style={{
            transform: 'translateZ(10px)',
            translateX: useTransform(xSpring, [-0.5, 0.5], ['-30px', '30px']),
            translateY: useTransform(ySpring, [-0.5, 0.5], ['-15px', '15px']),
          }}
          className="absolute inset-0 z-10 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity bg-[radial-gradient(circle_at_center,rgba(0,0,0,0.06)_0%,transparent_70%)] dark:bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.06)_0%,transparent_70%)] blur-xl"
        />

        {/* 3. FLOAT CONTENT (Higher translateZ = more depth) */}
        <div
          className="relative z-20 pointer-events-none"
          style={{ transform: 'translateZ(50px)' }}
        >
          <AnimatePresence mode="wait">
            {status === 'idle' && (
              <motion.div
                key="idle"
                initial={{ opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -10 }}
                className="flex items-center gap-4 text-[11px] font-black uppercase tracking-[0.4em] text-neutral-600 group-hover:text-black dark:text-neutral-400 dark:group-hover:text-white transition-colors duration-500"
              >
                Execute
                <div className="w-1.5 h-1.5 rounded-full bg-black/30 dark:bg-white/40 animate-pulse" />
              </motion.div>
            )}

            {status === 'loading' && (
              <motion.div
                key="loading"
                initial={{ opacity: 0, scale: 0.8 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0 }}
                className="flex items-center justify-center gap-3 text-[11px] font-black uppercase tracking-[0.4em] text-neutral-600 dark:text-neutral-400"
              >
                <svg
                  className="animate-spin -ml-1 mr-2 h-4 w-4 text-black dark:text-white"
                  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="4"
                  />
                  <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"
                  />
                </svg>
                Processing
              </motion.div>
            )}

            {status === 'success' && (
              <motion.div
                key="success"
                initial={{ opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                className="flex items-center gap-3 text-[11px] font-black uppercase tracking-[0.4em] text-green-600 dark:text-green-400"
              >
                Authorized
                <svg
                  width="16"
                  height="16"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="4"
                >
                  <path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        {/* 4. REAR LIGHT BLOOM (For Success state) */}
        <AnimatePresence>
          {status === 'success' && (
            <motion.div
              initial={{ opacity: 0, scale: 0.8 }}
              animate={{ opacity: 0.3, scale: 1.2 }}
              exit={{ opacity: 0 }}
              className="absolute inset-0 bg-green-500 blur-[60px] z-0"
            />
          )}
        </AnimatePresence>

        {/* 5. TOP ETCHED EDGE */}
        <div className="absolute inset-0 z-0 rounded-2xl pointer-events-none border-t border-white/40 dark:border-border" />
      </motion.button>
    </div>
  )
}

// CRITICAL: Export default to prevent the "undefined" runtime error
export default PremiumSubmitButton

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.