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...

default.tsx
"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;