Skip to content

Instantly share code, notes, and snippets.

@0xsommer
Last active February 27, 2025 15:39
Show Gist options
  • Save 0xsommer/3cb879fee4dadaa3dfe3e00e06a76a78 to your computer and use it in GitHub Desktop.
Save 0xsommer/3cb879fee4dadaa3dfe3e00e06a76a78 to your computer and use it in GitHub Desktop.
Exploding button
'use client'
import { useState, useRef, useEffect } from 'react'
import { motion, cubicBezier, useMotionValue, useTransform, AnimatePresence } from 'framer-motion';
import Particles from './particles';
import Explosion from './explosion';
import { PiCheckFatFill } from "react-icons/pi";
// Custom easing function
const customEase = cubicBezier(0.05, 0.3, 0.2, 1);
// Separate transition objects for pressing and unpressing
const pressTransition = {
duration: 1.5,
ease: customEase,
};
const unpressTransition = {
duration: 0.4, // Faster duration for unpressing
ease: customEase,
};
const TOTAL_ANIMATION_DURATION = 3;
export default function Button({ setLoadFinished, loadFinished }: { setLoadFinished: (value: boolean) => void, loadFinished: boolean }) {
const [isPressed, setIsPressed] = useState(false);
const circleScale = useMotionValue(0);
const opacity = useTransform(circleScale, [0, .4], [1, 0]);
const containerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const updateLoadFinished = (value: number) => {
if (value >= 1 && !loadFinished) {
setLoadFinished(true);
}
};
const unsubscribe = circleScale.on("change", updateLoadFinished);
return () => {
unsubscribe();
};
}, [circleScale, loadFinished]);
return (
<motion.button
className="relative flex items-center justify-center w-64 h-64 rounded-full bg-black text-white text-xl font-semibold overflow-visible"
onTapStart={() => setIsPressed(true)}
onTapCancel={() => setIsPressed(false)}
onTap={() => setIsPressed(false)}
whileTap={isPressed && !loadFinished ? { scale: 0.90, transition: pressTransition } : {}}
whileHover={isPressed && !loadFinished ? { scale: 1, transition: pressTransition } : {}}
ref={containerRef}
>
<motion.span style={{ opacity }} className="font-mono text-2xl">SEND</motion.span>
{!loadFinished && (
<Particles
isPressed={isPressed}
customTransition={isPressed ? pressTransition : unpressTransition}
totalDuration={TOTAL_ANIMATION_DURATION}
/>
)}
<motion.div
id="white-circle"
className="absolute bg-zinc-300 rounded-full z-20 flex center device-shadow"
initial={{ scale: 0 }}
animate={{ scale: isPressed || loadFinished ? 1 : 0 }}
transition={isPressed ? pressTransition : unpressTransition}
style={{
width: '100%',
height: '100%',
opacity: 1,
scale: circleScale,
}}
>
<AnimatePresence>
{loadFinished && <Explosion />}
</AnimatePresence>
{loadFinished && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1.5 }}
exit={{ opacity: 0, scale: 0 }}
style={{rotate: '-8deg'}}
transition={{ type: "spring", duration: 0.8, bounce: 0.5 }}
className="flex gap-4 items-center justify-center z-50">
<PiCheckFatFill size={64} className="text-zinc-700 absolute z-10"/>
<PiCheckFatFill size={64} className="absolute text-black -translate-x-1 -translate-y-1"/>
</motion.div>
)}
<motion.div
id="inner-circle"
className="absolute inset-0 shadow-[inset_-32px_0px_32px_10px_#ffffff] rounded-full"
initial={{ opacity: 0 }}
animate={{ opacity: isPressed || loadFinished ? 1 : 0.2 }}
transition={isPressed ? pressTransition : unpressTransition}
/>
</motion.div>
</motion.button>
)
}
import React from 'react';
import { motion } from 'framer-motion';
const particleCount = 80;
const colors = ['#FFFFFF', '#FFFFFF']; // White, Yellow, Blue
const generateParticles = () => {
return Array.from({ length: particleCount }).map((_, i) => {
const angle = (i / particleCount) * 360;
const radius = Math.random() * 200 + 100;
return {
x: Math.cos(angle * Math.PI / 180) * radius,
y: Math.sin(angle * Math.PI / 180) * radius,
rotation: Math.random() * 720 - 360,
scale: Math.random() * 1 + 0.5,
color: colors[Math.floor(Math.random() * colors.length)],
};
});
};
export default function Explosion() {
const particles = generateParticles();
return (
<motion.div
className="absolute inset-0 pointer-events-none z-50">
{particles.map((particle, index) => (
<motion.div
key={index}
className="absolute left-1/2 top-1/2 w-3 h-3 rounded-full"
initial={{ x: 0, y: 0, opacity: 1, scale: 0 }}
animate={{
x: particle.x,
y: particle.y,
opacity: 0,
scale: particle.scale,
rotate: particle.rotation,
}}
transition={{
duration: 1.2, // Increased from 1.2 to 1.5 for longer travel time
ease: "easeOut",
}}
style={{ backgroundColor: '#FFFFFF' }}
/>
))}
{/* Multiple shockwaves */}
{colors.map((color, index) => (
<motion.div
key={`shockwave-${index}`}
className="absolute left-1/2 top-1/2 rounded-full"
initial={{ width: 0, height: 0, borderWidth: 80, opacity: 1 }}
animate={{
width: '250%', // Increased from 300% to 400%
height: '250%', // Increased from 300% to 400%
borderWidth: 0,
opacity: 0
}}
transition={{
duration: 2,
delay: index * 0.2,
type: "spring",
}}
style={{
transform: 'translate(-50%, -50%)',
border: 'solid',
borderColor: `${color}`,
backgroundColor: 'transparent',
}}
/>
))}
</motion.div>
);
}
'use client'
import React, { useState, useEffect } from 'react';
import Skeleton from "@/app/kanon/components/skeleton";
import IntroText from "@/app/kanon/components/intro-text";
import FragmentFullWidth from "@/app/kanon/components/fragment-full-width";
import metaData from './meta.json';
import Button from "./components/button";
import { motion, useAnimationControls } from "framer-motion";
import IPhoneFrame from "@/app/kanon/components/IPhoneFrame";
const Page: React.FC = () => {
const introText = [
"This experiment showcases a smooth onboarding experience for a crypto wallet application.",
"The design focuses on simplicity and user-friendliness, guiding users through the initial setup process."
];
const [loadFinished, setLoadFinished] = useState(false);
const controls = useAnimationControls();
useEffect(() => {
if (loadFinished) {
controls.start({
scale: 0.8,
transition: { type: "spring", duration: 0.2 }
}).then(() => {
setTimeout(() => {
controls.start({
scale: 1,
transition: { type: "spring", stiffness: 200, damping: 10 }
});
}, 100);
});
}
}, [loadFinished, controls]);
return (
<Skeleton
title={metaData[0].title}
description={metaData[0].summary}
image={metaData[0].image}
slug={metaData[0].slug}
publishedAt={metaData[0].publishedAt}
>
<IntroText copy={introText} />
<IPhoneFrame className="scale-100">
<div className="w-full flex flex-col items-center gap-16 px-4 py-80 bg-zinc-800">
<motion.div
animate={controls}
initial={{ scale: 1 }}
onDoubleClick={() => setLoadFinished(false)}
>
<Button setLoadFinished={setLoadFinished} loadFinished={loadFinished}/>
</motion.div>
</div>
</IPhoneFrame>
</Skeleton>
);
};
export default Page;
import { motion, useAnimation } from 'framer-motion';
import { useEffect, useState } from 'react';
interface ParticlesProps {
isPressed: boolean;
customTransition: {
duration: number;
ease: any;
};
totalDuration: number; // New prop for total animation duration
}
// Customizable constants
const PARTICLE_COUNT = 150;
const MIN_PARTICLE_SIZE = 5;
const MAX_PARTICLE_SIZE = 10;
const MIN_PARTICLE_DISTANCE = 150;
const MAX_PARTICLE_DISTANCE = 250;
const MIN_ANIMATION_DURATION_RATIO = 0.2;
const MAX_ANIMATION_DURATION_RATIO = 0.4;
const MIN_FADE_OUT_POINT = 0.7;
const MAX_FADE_OUT_POINT = 0.9;
const FADE_IN_DURATION_RATIO = 0.05;
const getInitialPosition = () => {
const angle = Math.random() * 2 * Math.PI;
const radius = MIN_PARTICLE_DISTANCE + Math.random() * (MAX_PARTICLE_DISTANCE - MIN_PARTICLE_DISTANCE);
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
size: MIN_PARTICLE_SIZE + Math.random() * (MAX_PARTICLE_SIZE - MIN_PARTICLE_SIZE)
};
};
export default function Particles({ isPressed, customTransition, totalDuration }: ParticlesProps) {
const particlesControl = useAnimation();
const [particles, setParticles] = useState(() =>
Array.from({ length: PARTICLE_COUNT }, getInitialPosition)
);
useEffect(() => {
if (isPressed) {
particlesControl.start(i => {
const delayRatio = i / PARTICLE_COUNT;
const delay = delayRatio * totalDuration * 0.5; // Use half of total duration for delays
const durationRatio = MIN_ANIMATION_DURATION_RATIO + Math.random() * (MAX_ANIMATION_DURATION_RATIO - MIN_ANIMATION_DURATION_RATIO);
const duration = totalDuration * durationRatio;
const fadeOutPoint = MIN_FADE_OUT_POINT + Math.random() * (MAX_FADE_OUT_POINT - MIN_FADE_OUT_POINT);
return {
x: 0,
y: 0,
opacity: [0, 1, 1, 0],
transition: {
x: { ...customTransition, delay, duration },
y: { ...customTransition, delay, duration },
opacity: {
times: [0, FADE_IN_DURATION_RATIO, fadeOutPoint, 1],
duration: duration,
delay
}
},
};
});
} else {
particlesControl.start(i => ({
x: particles[i].x,
y: particles[i].y,
opacity: 0,
transition: { duration: totalDuration * 0.05 } // Quick fade out
}));
}
}, [isPressed, particlesControl, customTransition, particles, totalDuration]);
return (
<>
{particles.map((particle, i) => (
<motion.div
key={i}
className="absolute rounded-full bg-white"
style={{
width: particle.size,
height: particle.size,
}}
initial={{ x: particle.x, y: particle.y, opacity: 0 }}
animate={particlesControl}
custom={i}
/>
))}
</>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment