Color Pickers
An animated color picker where selected swatches burst into particles and fluidly morph into the preview panel inspired by Kumail Nanji.
Loading demo...
"use client";
import { motion, AnimatePresence } from "motion/react";
import { useState, useRef } from "react";
const COLORS = [
"var(--color-red-500)",
"var(--color-blue-500)",
"var(--color-green-500)",
"var(--color-yellow-500)",
"var(--color-pink-500)",
"var(--color-orange-500)",
];
interface Particle {
id: number;
color: string;
spawnX: number;
spawnY: number;
targetX: number;
targetY: number;
targetWidth: number;
targetHeight: number;
}
const ColorPicker = () => {
const [previewIndex, setPreviewIndex] = useState(0);
const [activeIndex, setActiveIndex] = useState(0);
const [particles, setParticles] = useState<Particle[]>([]);
const previewRef = useRef<HTMLDivElement>(null);
const handleColorClick = (
index: number,
e: React.MouseEvent<HTMLButtonElement>,
) => {
setActiveIndex(index);
const buttonRect = e.currentTarget.getBoundingClientRect();
const previewRect = previewRef.current?.getBoundingClientRect();
const containerRect = previewRef.current
?.closest(".relative")
?.getBoundingClientRect();
if (!previewRect || !containerRect) return;
const spawnX = buttonRect.left + buttonRect.width / 2 - containerRect.left;
const spawnY = buttonRect.top + buttonRect.height / 2 - containerRect.top;
const targetX =
previewRect.left + previewRect.width / 2 - containerRect.left;
const targetY =
previewRect.top + previewRect.height / 2 - containerRect.top;
const newParticle: Particle = {
id: Math.random(),
color: COLORS[index],
spawnX,
spawnY,
targetX,
targetY,
targetWidth: previewRect.width,
targetHeight: previewRect.height,
};
setParticles((prev) => [...prev, newParticle]);
setTimeout(() => {
setPreviewIndex(index);
setTimeout(() => {
setParticles((prev) => prev.filter((p) => p.id !== newParticle.id));
}, 500);
}, 500);
};
return (
<div className="size-full flex items-center justify-center overflow-hidden text-white">
<div className="flex flex-col items-center gap-10 relative mb-20 rounded-2xl">
<div className="relative ">
<motion.div
ref={previewRef}
animate={{
scale: [0.95, 1],
opacity: [0.9, 1],
backgroundColor: COLORS[previewIndex],
}}
className="w-100 h-50 rounded-2xl relative z-10"
transition={{
scale: { type: "spring", stiffness: 400, damping: 25 },
opacity: { duration: 0.2 },
backgroundColor: { delay: 0.5, duration: 0 },
}}
/>
</div>
<AnimatePresence>
{particles.map((particle) => {
const particleSize = 12;
return (
<motion.div
key={particle.id}
initial={{
x: particle.spawnX - particleSize / 2,
y: particle.spawnY - particleSize / 2,
width: particleSize,
height: particleSize,
borderRadius: particleSize / 2,
opacity: 1,
}}
animate={{
x: [
particle.spawnX - particleSize / 2,
particle.targetX - particleSize / 2,
particle.targetX - particle.targetWidth / 2,
],
y: [
particle.spawnY - particleSize / 2,
particle.targetY - particleSize / 2,
particle.targetY - particle.targetHeight / 2,
],
width: [particleSize, particleSize, particle.targetWidth],
height: [particleSize, particleSize, particle.targetHeight],
borderRadius: [
particleSize / 2,
particleSize / 2,
"calc(var(--radius) + 8px)",
],
opacity: [1, 1, 1],
}}
transition={{
duration: 0.5,
times: [0, 0.5, 1],
ease: ["easeOut", "linear"],
}}
className="absolute top-0 left-0 z-50 pointer-events-none"
style={{ backgroundColor: particle.color }}
/>
);
})}
</AnimatePresence>
</div>
<div className="flex gap-4 rounded-2xl absolute bottom-5 isolate">
{COLORS.map((color, index) => (
<button
key={index}
className="size-10 cursor-pointer relative"
onClick={(e) => handleColorClick(index, e)}
>
<div
style={{ backgroundColor: color }}
className="size-full absolute inset-0 rounded z-20"
/>
{activeIndex === index && (
<motion.div
layoutId="active-color"
transition={{
duration: 0.5,
type: "spring",
stiffness: 400,
damping: 50,
}}
className="size-12.5 border-2 rounded-md absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none -z-10"
/>
)}
</button>
))}
</div>
</div>
);
};
export default ColorPicker;