CappyUI LogoCappyUI

Puzzle

An animated puzzle component with sequential piece animations, perfect for showcasing assembly or completion concepts with optimized performance.

Preview

Generate with AI

Want to create this component using AI? Copy the prompt below and paste it into any LLM (ChatGPT, Claude, etc.):

Installation

npx shadcn@latest add https://uiregistry.cappychat.com/registry/puzzle.json

Install dependencies

npm i framer-motion clsx tailwind-merge

Add util file

lib/utils.ts

import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Copy the source code

components/ui/puzzle.tsx

src/components/components/puzzle.tsx
"use client";

import React, { memo, useMemo } from "react";
import { motion, Transition, Variants } from "framer-motion";
import { cn } from "@/lib/utils";

interface PuzzleProps {
  className?: string;
}

// Animation constants - defined outside component to prevent recreation
const ANIMATE_IN_DURATION = 2.1;
const HOLD_DURATION = 3.0;
const ANIMATE_OUT_DURATION = 2.0;
const TOTAL_DURATION =
  ANIMATE_IN_DURATION + HOLD_DURATION + ANIMATE_OUT_DURATION;

// Shared transition config for better performance
const createTransition = (times: number[]): Transition => ({
  duration: TOTAL_DURATION,
  times,
  ease: [0.25, 0.1, 0.25, 1], // Custom cubic-bezier for smoother animation
  repeat: Infinity,
  repeatType: "loop" as const,
});

export const Puzzle = memo(({ className }: PuzzleProps) => {
  // Memoize animation variants to prevent recreation on each render
  const pieceVariants = useMemo(() => {
    const topLeft: Variants = {
      initial: { opacity: 0, x: -100, y: -100, rotate: -15 },
      animate: {
        opacity: [0, 1, 1, 1, 0],
        x: [-100, 0, 0, 0, -100],
        y: [-100, 0, 0, 0, -100],
        rotate: [-15, 0, 0, 0, -15],
      },
    };

    const topRight: Variants = {
      initial: { opacity: 0, x: 100, y: -100, rotate: 15 },
      animate: {
        opacity: [0, 0, 1, 1, 0],
        x: [100, 100, 0, 0, 100],
        y: [-100, -100, 0, 0, -100],
        rotate: [15, 15, 0, 0, 15],
      },
    };

    const bottomLeft: Variants = {
      initial: { opacity: 0, x: -100, y: 100, rotate: -15 },
      animate: {
        opacity: [0, 0, 1, 1, 0],
        x: [-100, -100, 0, 0, -100],
        y: [100, 100, 0, 0, 100],
        rotate: [-15, -15, 0, 0, -15],
      },
    };

    const bottomRight: Variants = {
      initial: { opacity: 0, x: 100, y: 100, rotate: 15 },
      animate: {
        opacity: [0, 0, 1, 1, 0],
        x: [100, 100, 0, 0, 100],
        y: [100, 100, 0, 0, 100],
        rotate: [15, 15, 0, 0, 15],
      },
    };

    return { topLeft, topRight, bottomLeft, bottomRight };
  }, []);

  // Memoize transitions to prevent recreation
  const transitions = useMemo(
    () => ({
      topLeft: createTransition([0, 0.1, 0.296, 0.718, 1]),
      topRight: createTransition([0, 0.15, 0.2, 0.718, 1]),
      bottomLeft: createTransition([0, 0.2, 0.25, 0.718, 1]),
      bottomRight: createTransition([0, 0.25, 0.296, 0.718, 1]),
    }),
    []
  );

  return (
    <div className={cn("flex items-center justify-center p-8", className)}>
      <div className="relative w-full max-w-xs aspect-square">
        {/* Puzzle piece container */}
        <div className="relative w-full h-full">
          {/* Top-left puzzle piece - First animation */}
          <motion.div
            variants={pieceVariants.topLeft}
            initial="initial"
            animate="animate"
            transition={transitions.topLeft}
            className="absolute top-0 left-0 w-1/2 h-1/2 bg-neutral-700
              z-20 rounded-tl-3xl border-t-2 border-l-2 border-neutral-500"
            style={{ willChange: "transform, opacity" }}
          >
            {/* Right connector (male) */}
            <div
              className="absolute -right-6 top-1/2 -translate-y-1/2 w-12 h-12 bg-neutral-500
             rounded-full z-30"
            />
            <div className="absolute -right-5 top-1/2 -translate-y-1/2 w-12 h-12 bg-neutral-700 rounded-full z-40" />
            {/* Bottom connector (male) */}
            <div
              className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 w-12 h-12 bg-fd-background
             rounded-full z-30"
            />
          </motion.div>

          {/* Top-right puzzle piece - Second animation */}
          <motion.div
            variants={pieceVariants.topRight}
            initial="initial"
            animate="animate"
            transition={transitions.topRight}
            className="absolute top-0 right-0 w-1/2 h-1/2 bg-neutral-600
             rounded-tr-3xl border-r-2 border-t-2 border-neutral-500"
            style={{ willChange: "transform, opacity" }}
          >
            {/* Left connector (female) */}
            <div className="absolute -left-6 top-1/2 -translate-y-1/2 w-12 h-12 bg-fd-background rounded-full z-10"></div>
            {/* Bottom connector (male) */}
            <div className="absolute -bottom-1 right-1/2 translate-x-1/2 translate-y-1/2 w-12 h-12 bg-fd-background rounded-full z-20" />
            <div className="absolute bottom-0 right-1/2 translate-x-1/2 translate-y-1/2 w-12 h-12 bg-neutral-500 rounded-full z-10" />
          </motion.div>

          {/* Bottom-left puzzle piece - Third animation */}
          <motion.div
            variants={pieceVariants.bottomLeft}
            initial="initial"
            animate="animate"
            transition={transitions.bottomLeft}
            className="absolute bottom-0 z-20 left-0 w-1/2 h-1/2 bg-neutral-600 rounded-bl-3xl border-l-2 border-b-2 border-neutral-500"
            style={{ willChange: "transform, opacity" }}
          >
            {/* Top connector (female) */}
            <div className="absolute -top-1 left-1/2 -translate-x-1/2 -translate-y-1/2 w-12 h-12 bg-neutral-500 rounded-full z-20" />

            <div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 w-12 h-12 bg-neutral-600 rounded-full z-20" />

            {/** Right connector (male) */}

            <div className="absolute -right-5 bottom-1/2 translate-y-1/2 w-12 h-12 bg-fd-background rounded-full z-40" />
          </motion.div>

          {/* Bottom-right puzzle piece - Fourth animation */}
          <motion.div
            variants={pieceVariants.bottomRight}
            initial="initial"
            animate="animate"
            transition={transitions.bottomRight}
            className="absolute bottom-0 z-30 right-0 w-1/2 h-1/2 bg-neutral-700 rounded-br-3xl border-r-2 border-b-2 border-neutral-500"
            style={{ willChange: "transform, opacity" }}
          >
            {/* Top connector (female) */}
            <div className="absolute top-1 right-1/2 translate-x-1/2 -translate-y-1/2 w-12 h-12 bg-neutral-700 rounded-full z-20" />

            {/** Left connector (female) */}
            <div className="absolute -left-6 bottom-1/2 translate-y-1/2 w-12 h-12 bg-neutral-700 rounded-full z-20" />

            <div className="absolute -left-7 bottom-1/2 translate-y-1/2 w-12 h-12 bg-neutral-500 rounded-full z-10" />
          </motion.div>
        </div>
      </div>
    </div>
  );
});

Puzzle.displayName = "Puzzle";

export default Puzzle;

Usage

import { Puzzle } from "@/components/ui/puzzle";

export default function MyComponent() {
  return (
    <div className="">
      <Puzzle />
    </div>
  );
}

Props

PropTypeDefaultDescription
classNamestring-Optional additional CSS classes