skills/awwwards-animations/references/geometric-puzzles.md

24 KiB

Geometric Puzzles & Dissections

Animated geometric puzzles: Dudeney dissections, tangrams, tessellations, Penrose tiles, and more. SVG + GSAP for precise animated transformations.

Table of Contents


Dudeney Dissections

The famous equilateral triangle → square dissection with 4 hinged pieces.

'use client'
import { useRef, useState } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'

// Triangle-to-square: 4 pieces with precise SVG paths
// Coordinates based on Dudeney's original 4-piece hinged dissection
const TRIANGLE_PIECES = [
  { id: 'A', tri: 'M 0,173.2 L 50,86.6 L 100,173.2 Z', sq: 'M 0,0 L 100,0 L 100,86.6 L 0,86.6 Z' },
  { id: 'B', tri: 'M 50,86.6 L 100,0 L 150,86.6 Z', sq: 'M 100,0 L 200,0 L 200,86.6 L 100,86.6 Z' },
  { id: 'C', tri: 'M 100,173.2 L 150,86.6 L 200,173.2 Z', sq: 'M 0,86.6 L 100,86.6 L 100,173.2 L 0,173.2 Z' },
  { id: 'D', tri: 'M 50,86.6 L 100,173.2 L 150,86.6 L 100,0 Z', sq: 'M 100,86.6 L 200,86.6 L 200,173.2 L 100,173.2 Z' },
]

const COLORS = ['#f43f5e', '#8b5cf6', '#06b6d4', '#f59e0b']

export function DudeneyDissection() {
  const svgRef = useRef<SVGSVGElement>(null)
  const [isSquare, setIsSquare] = useState(false)

  useGSAP(() => {
    // Initial setup — start as triangle
    TRIANGLE_PIECES.forEach((piece, i) => {
      const el = svgRef.current!.querySelector(`#piece-${piece.id}`)
      if (el) {
        gsap.set(el, { attr: { d: piece.tri } })
      }
    })
  }, { scope: svgRef })

  const morph = () => {
    const target = !isSquare
    TRIANGLE_PIECES.forEach((piece, i) => {
      const el = svgRef.current!.querySelector(`#piece-${piece.id}`)
      if (el) {
        gsap.to(el, {
          attr: { d: target ? piece.sq : piece.tri },
          duration: 1.5,
          ease: 'power2.inOut',
          delay: i * 0.15,
        })
      }
    })
    setIsSquare(target)
  }

  return (
    <div className="flex flex-col items-center gap-6">
      <svg ref={svgRef} viewBox="-10 -10 220 200" className="w-80 h-80">
        {TRIANGLE_PIECES.map((piece, i) => (
          <path
            key={piece.id}
            id={`piece-${piece.id}`}
            d={piece.tri}
            fill={COLORS[i]}
            stroke="#000"
            strokeWidth="1.5"
            className="cursor-pointer"
          />
        ))}
      </svg>
      <button
        onClick={morph}
        className="px-6 py-3 bg-white text-black font-mono text-sm uppercase tracking-wider"
      >
        {isSquare ? 'To Triangle' : 'To Square'}
      </button>
    </div>
  )
}

Interactive Tangram

7 draggable pieces with snap-to-position.

'use client'
import { useRef, useState, useCallback } from 'react'
import { gsap } from '@/lib/gsap'

interface TangramPiece {
  id: string; name: string; points: string; color: string
  homeX: number; homeY: number; homeRotate: number
}

const PIECES: TangramPiece[] = [
  { id: 'lg1', name: 'Large Triangle 1', points: '0,0 200,0 100,100', color: '#ef4444', homeX: 0, homeY: 0, homeRotate: 0 },
  { id: 'lg2', name: 'Large Triangle 2', points: '0,0 100,100 0,200', color: '#f97316', homeX: 0, homeY: 0, homeRotate: 0 },
  { id: 'md', name: 'Medium Triangle', points: '0,0 100,0 50,50', color: '#eab308', homeX: 100, homeY: 100, homeRotate: 0 },
  { id: 'sm1', name: 'Small Triangle 1', points: '0,0 100,0 50,50', color: '#22c55e', homeX: 100, homeY: 0, homeRotate: 90 },
  { id: 'sm2', name: 'Small Triangle 2', points: '0,0 100,0 50,50', color: '#06b6d4', homeX: 50, homeY: 150, homeRotate: 180 },
  { id: 'sq', name: 'Square', points: '0,0 50,0 50,50 0,50', color: '#8b5cf6', homeX: 100, homeY: 50, homeRotate: 45 },
  { id: 'par', name: 'Parallelogram', points: '0,0 50,0 100,50 50,50', color: '#ec4899', homeX: 50, homeY: 50, homeRotate: 0 },
]

// Target shapes: each piece's target transform
const TARGETS = {
  house: { lg1: { x: 50, y: 100, r: 0 }, lg2: { x: 0, y: 200, r: -90 }, md: { x: 25, y: 50, r: 0 }, sm1: { x: 0, y: 100, r: 90 }, sm2: { x: 150, y: 100, r: 0 }, sq: { x: 75, y: 150, r: 0 }, par: { x: 100, y: 100, r: 0 } },
  cat: { lg1: { x: 30, y: 80, r: 45 }, lg2: { x: 30, y: 80, r: -45 }, md: { x: 80, y: 180, r: 180 }, sm1: { x: 0, y: 0, r: 0 }, sm2: { x: 120, y: 0, r: 90 }, sq: { x: 60, y: 30, r: 0 }, par: { x: 60, y: 130, r: 0 } },
}

export function Tangram() {
  const svgRef = useRef<SVGSVGElement>(null)
  const [activeTarget, setActiveTarget] = useState<keyof typeof TARGETS | null>(null)
  const dragState = useRef<{ id: string; startX: number; startY: number; offsetX: number; offsetY: number } | null>(null)

  const animateToTarget = useCallback((target: keyof typeof TARGETS) => {
    const t = TARGETS[target]
    PIECES.forEach((piece, i) => {
      const el = svgRef.current!.querySelector(`#tangram-${piece.id}`) as SVGGElement
      const data = t[piece.id as keyof typeof t]
      if (el && data) {
        gsap.to(el, {
          x: data.x,
          y: data.y,
          rotation: data.r,
          duration: 0.8,
          ease: 'back.out(1.2)',
          delay: i * 0.08,
          transformOrigin: 'center center',
        })
      }
    })
    setActiveTarget(target)
  }, [])

  const scatter = useCallback(() => {
    PIECES.forEach((piece, i) => {
      const el = svgRef.current!.querySelector(`#tangram-${piece.id}`) as SVGGElement
      if (el) {
        gsap.to(el, {
          x: Math.random() * 250,
          y: Math.random() * 250,
          rotation: Math.random() * 360,
          duration: 0.6,
          ease: 'power2.out',
          delay: i * 0.05,
        })
      }
    })
    setActiveTarget(null)
  }, [])

  const handlePointerDown = useCallback((e: React.PointerEvent, pieceId: string) => {
    const svg = svgRef.current!
    const pt = svg.createSVGPoint()
    pt.x = e.clientX
    pt.y = e.clientY
    const svgPt = pt.matrixTransform(svg.getScreenCTM()!.inverse())
    const el = svg.querySelector(`#tangram-${pieceId}`) as SVGGElement
    const transform = gsap.getProperty(el)
    dragState.current = {
      id: pieceId,
      startX: svgPt.x,
      startY: svgPt.y,
      offsetX: (transform('x') as number) || 0,
      offsetY: (transform('y') as number) || 0,
    }
    ;(e.target as Element).setPointerCapture(e.pointerId)
  }, [])

  const handlePointerMove = useCallback((e: React.PointerEvent) => {
    if (!dragState.current) return
    const svg = svgRef.current!
    const pt = svg.createSVGPoint()
    pt.x = e.clientX
    pt.y = e.clientY
    const svgPt = pt.matrixTransform(svg.getScreenCTM()!.inverse())
    const el = svg.querySelector(`#tangram-${dragState.current.id}`)
    if (el) {
      gsap.set(el, {
        x: dragState.current.offsetX + (svgPt.x - dragState.current.startX),
        y: dragState.current.offsetY + (svgPt.y - dragState.current.startY),
      })
    }
  }, [])

  const handlePointerUp = useCallback(() => {
    dragState.current = null
  }, [])

  return (
    <div className="flex flex-col items-center gap-4">
      <svg
        ref={svgRef}
        viewBox="0 0 400 400"
        className="w-96 h-96 bg-gray-950 rounded-lg touch-none"
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      >
        {PIECES.map(piece => (
          <g
            key={piece.id}
            id={`tangram-${piece.id}`}
            onPointerDown={e => handlePointerDown(e, piece.id)}
            className="cursor-grab active:cursor-grabbing"
          >
            <polygon
              points={piece.points}
              fill={piece.color}
              stroke="#000"
              strokeWidth="1.5"
              strokeLinejoin="round"
            />
          </g>
        ))}
      </svg>
      <div className="flex gap-3">
        {Object.keys(TARGETS).map(target => (
          <button
            key={target}
            onClick={() => animateToTarget(target as keyof typeof TARGETS)}
            className={`px-4 py-2 text-sm font-mono uppercase ${activeTarget === target ? 'bg-white text-black' : 'border border-white/30 text-white/70'}`}
          >
            {target}
          </button>
        ))}
        <button onClick={scatter} className="px-4 py-2 text-sm font-mono uppercase border border-white/30 text-white/70">
          Scatter
        </button>
      </div>
    </div>
  )
}

Tessellations

Regular, semi-regular, and Escher-style animated tessellations.

'use client'
import { useRef, useEffect } from 'react'

type TessType = 'triangular' | 'hexagonal' | 'cairo'

export function Tessellation({ type = 'hexagonal', animate = true }: {
  type?: TessType; animate?: boolean
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const animRef = useRef<number>(0)

  useEffect(() => {
    const canvas = canvasRef.current!
    const ctx = canvas.getContext('2d')!
    const w = canvas.offsetWidth
    const h = canvas.offsetHeight
    canvas.width = w * 2
    canvas.height = h * 2
    ctx.scale(2, 2)

    let time = 0

    const drawHexGrid = (t: number) => {
      const size = 30
      const hSpacing = size * Math.sqrt(3)
      const vSpacing = size * 1.5

      for (let row = -1; row < h / vSpacing + 1; row++) {
        for (let col = -1; col < w / hSpacing + 1; col++) {
          const x = col * hSpacing + (row % 2 ? hSpacing / 2 : 0)
          const y = row * vSpacing

          ctx.beginPath()
          for (let i = 0; i < 6; i++) {
            const angle = ((60 * i - 30) * Math.PI) / 180
            const px = x + size * Math.cos(angle + t * 0.3)
            const py = y + size * Math.sin(angle + t * 0.3)
            if (i === 0) ctx.moveTo(px, py)
            else ctx.lineTo(px, py)
          }
          ctx.closePath()

          const hue = ((x + y) * 0.5 + t * 50) % 360
          ctx.fillStyle = `hsla(${hue}, 60%, 50%, 0.7)`
          ctx.fill()
          ctx.strokeStyle = 'rgba(0,0,0,0.3)'
          ctx.lineWidth = 1
          ctx.stroke()
        }
      }
    }

    const drawTriGrid = (t: number) => {
      const size = 35
      const height = size * Math.sqrt(3) / 2

      for (let row = -1; row < h / height + 1; row++) {
        for (let col = -1; col < w / size + 1; col++) {
          const upward = (row + col) % 2 === 0
          const x = col * (size / 2)
          const y = row * height

          ctx.beginPath()
          if (upward) {
            ctx.moveTo(x, y + height)
            ctx.lineTo(x + size / 2, y)
            ctx.lineTo(x + size, y + height)
          } else {
            ctx.moveTo(x, y)
            ctx.lineTo(x + size, y)
            ctx.lineTo(x + size / 2, y + height)
          }
          ctx.closePath()

          const hue = ((row * 40 + col * 20) + t * 30) % 360
          ctx.fillStyle = `hsla(${hue}, 50%, 55%, 0.8)`
          ctx.fill()
          ctx.strokeStyle = 'rgba(0,0,0,0.2)'
          ctx.stroke()
        }
      }
    }

    const drawCairo = (t: number) => {
      const size = 40
      for (let row = -1; row < h / size + 2; row++) {
        for (let col = -1; col < w / size + 2; col++) {
          const x = col * size
          const y = row * size
          const wobble = Math.sin(t + col * 0.3 + row * 0.3) * 3

          // Cairo pentagon approximation
          ctx.beginPath()
          ctx.moveTo(x + wobble, y)
          ctx.lineTo(x + size * 0.7, y + wobble)
          ctx.lineTo(x + size, y + size * 0.3)
          ctx.lineTo(x + size * 0.5, y + size * 0.7 + wobble)
          ctx.lineTo(x, y + size * 0.4)
          ctx.closePath()

          const hue = ((col + row) * 30 + t * 20) % 360
          ctx.fillStyle = `hsla(${hue}, 45%, 55%, 0.8)`
          ctx.fill()
          ctx.strokeStyle = 'rgba(0,0,0,0.3)'
          ctx.stroke()
        }
      }
    }

    const render = () => {
      ctx.clearRect(0, 0, w, h)
      const t = animate ? time : 0

      switch (type) {
        case 'hexagonal': drawHexGrid(t); break
        case 'triangular': drawTriGrid(t); break
        case 'cairo': drawCairo(t); break
      }

      if (animate) {
        time += 0.01
        animRef.current = requestAnimationFrame(render)
      }
    }
    render()
    return () => cancelAnimationFrame(animRef.current)
  }, [type, animate])

  return <canvas ref={canvasRef} className="w-full h-full" />
}

Penrose Tiles

Kite and dart aperiodic tiling with deflation generation.

'use client'
import { useRef, useEffect } from 'react'

type PenroseTile = { type: 'kite' | 'dart'; vertices: [number, number][] }

const PHI = (1 + Math.sqrt(5)) / 2

function generatePenrose(depth: number, cx: number, cy: number, radius: number): PenroseTile[] {
  // Start with a sun (10 kites)
  let tiles: PenroseTile[] = []
  for (let i = 0; i < 10; i++) {
    const a1 = ((i * 36) * Math.PI) / 180
    const a2 = (((i + 1) * 36) * Math.PI) / 180
    const mid = (((i * 36 + 18)) * Math.PI) / 180
    tiles.push({
      type: 'kite',
      vertices: [
        [cx, cy],
        [cx + Math.cos(a1) * radius, cy + Math.sin(a1) * radius],
        [cx + Math.cos(mid) * radius / PHI, cy + Math.sin(mid) * radius / PHI],
        [cx + Math.cos(a2) * radius, cy + Math.sin(a2) * radius],
      ],
    })
  }

  // Subdivide
  for (let d = 0; d < depth; d++) {
    const newTiles: PenroseTile[] = []
    for (const tile of tiles) {
      const [A, B, C, D] = tile.vertices
      if (tile.type === 'kite') {
        const E: [number, number] = [A[0] + (B[0] - A[0]) / PHI, A[1] + (B[1] - A[1]) / PHI]
        const F: [number, number] = [A[0] + (D[0] - A[0]) / PHI, A[1] + (D[1] - A[1]) / PHI]
        newTiles.push({ type: 'kite', vertices: [A, E, C, F] })
        newTiles.push({ type: 'dart', vertices: [E, B, C, E] })
        newTiles.push({ type: 'dart', vertices: [F, C, D, F] })
      } else {
        const E: [number, number] = [B[0] + (A[0] - B[0]) / PHI, B[1] + (A[1] - B[1]) / PHI]
        newTiles.push({ type: 'kite', vertices: [E, B, C, E] })
        newTiles.push({ type: 'dart', vertices: [A, E, C, D] })
      }
    }
    tiles = newTiles
  }
  return tiles
}

export function PenroseTiling({ depth = 3 }: { depth?: number }) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const animRef = useRef<number>(0)

  useEffect(() => {
    const canvas = canvasRef.current!
    const ctx = canvas.getContext('2d')!
    const w = canvas.offsetWidth
    const h = canvas.offsetHeight
    canvas.width = w * 2
    canvas.height = h * 2
    ctx.scale(2, 2)

    const tiles = generatePenrose(depth, w / 2, h / 2, Math.min(w, h) * 0.45)
    let drawn = 0

    const animate = () => {
      if (drawn >= tiles.length) return

      const batch = Math.min(10, tiles.length - drawn)
      for (let i = 0; i < batch; i++) {
        const tile = tiles[drawn + i]
        ctx.beginPath()
        tile.vertices.forEach(([x, y], j) => {
          if (j === 0) ctx.moveTo(x, y)
          else ctx.lineTo(x, y)
        })
        ctx.closePath()

        ctx.fillStyle = tile.type === 'kite'
          ? `hsla(220, 60%, 50%, 0.7)`
          : `hsla(40, 70%, 55%, 0.7)`
        ctx.fill()
        ctx.strokeStyle = 'rgba(0,0,0,0.4)'
        ctx.lineWidth = 0.5
        ctx.stroke()
      }
      drawn += batch

      if (drawn < tiles.length) {
        animRef.current = requestAnimationFrame(animate)
      }
    }
    animate()
    return () => cancelAnimationFrame(animRef.current)
  }, [depth])

  return <canvas ref={canvasRef} className="w-full h-full bg-gray-950" />
}

Polyominoes

Pentomino puzzle with animated placement.

'use client'
import { useRef, useCallback } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'

// All 12 pentomino shapes (relative cell positions)
const PENTOMINOES: { name: string; cells: [number, number][]; color: string }[] = [
  { name: 'F', cells: [[0,1],[1,0],[1,1],[1,2],[2,2]], color: '#ef4444' },
  { name: 'I', cells: [[0,0],[0,1],[0,2],[0,3],[0,4]], color: '#f97316' },
  { name: 'L', cells: [[0,0],[1,0],[2,0],[3,0],[3,1]], color: '#eab308' },
  { name: 'N', cells: [[0,0],[1,0],[1,1],[2,1],[3,1]], color: '#22c55e' },
  { name: 'P', cells: [[0,0],[0,1],[1,0],[1,1],[2,0]], color: '#06b6d4' },
  { name: 'T', cells: [[0,0],[0,1],[0,2],[1,1],[2,1]], color: '#8b5cf6' },
  { name: 'U', cells: [[0,0],[0,2],[1,0],[1,1],[1,2]], color: '#ec4899' },
  { name: 'V', cells: [[0,0],[1,0],[2,0],[2,1],[2,2]], color: '#14b8a6' },
  { name: 'W', cells: [[0,0],[1,0],[1,1],[2,1],[2,2]], color: '#f43f5e' },
  { name: 'X', cells: [[0,1],[1,0],[1,1],[1,2],[2,1]], color: '#a855f7' },
  { name: 'Y', cells: [[0,0],[1,0],[1,1],[2,0],[3,0]], color: '#fb923c' },
  { name: 'Z', cells: [[0,0],[0,1],[1,1],[2,1],[2,2]], color: '#38bdf8' },
]

const CELL_SIZE = 28

export function PentominoShowcase() {
  const svgRef = useRef<SVGSVGElement>(null)

  useGSAP(() => {
    const pieces = svgRef.current!.querySelectorAll('.pentomino-group')
    gsap.from(pieces, {
      scale: 0,
      rotation: 180,
      opacity: 0,
      duration: 0.6,
      stagger: 0.1,
      ease: 'back.out(1.7)',
      transformOrigin: 'center center',
    })
  }, { scope: svgRef })

  return (
    <svg ref={svgRef} viewBox="0 0 500 200" className="w-full max-w-2xl">
      {PENTOMINOES.map((piece, pi) => {
        const offsetX = (pi % 6) * 80 + 10
        const offsetY = Math.floor(pi / 6) * 100 + 10

        return (
          <g key={piece.name} className="pentomino-group">
            {piece.cells.map(([r, c], ci) => (
              <rect
                key={ci}
                x={offsetX + c * CELL_SIZE}
                y={offsetY + r * CELL_SIZE}
                width={CELL_SIZE - 2}
                height={CELL_SIZE - 2}
                rx={3}
                fill={piece.color}
                stroke="rgba(0,0,0,0.3)"
                strokeWidth={1}
              />
            ))}
            <text
              x={offsetX + 10}
              y={offsetY - 5}
              className="text-xs fill-white/50 font-mono"
            >
              {piece.name}
            </text>
          </g>
        )
      })}
    </svg>
  )
}

Geometric Transformations

Animated rotation, reflection, dilation, and composition.

'use client'
import { useRef, useState } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'

type TransformType = 'rotate' | 'reflect' | 'dilate' | 'compose'

export function GeometricTransform({ type = 'rotate' }: { type?: TransformType }) {
  const svgRef = useRef<SVGSVGElement>(null)
  const [playing, setPlaying] = useState(false)

  const animate = () => {
    if (playing) return
    setPlaying(true)
    const shape = svgRef.current!.querySelector('#transform-shape')!
    const ghost = svgRef.current!.querySelector('#transform-ghost')!

    gsap.set(ghost, { opacity: 0.3 })

    const tl = gsap.timeline({ onComplete: () => setPlaying(false) })

    switch (type) {
      case 'rotate':
        tl.to(shape, { rotation: 90, duration: 1.5, ease: 'power2.inOut', transformOrigin: '200 200' })
          .to(shape, { rotation: 0, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 })
        break
      case 'reflect':
        tl.to(shape, { scaleX: -1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200' })
          .to(shape, { scaleX: 1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 })
        break
      case 'dilate':
        tl.to(shape, { scale: 1.8, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200' })
          .to(shape, { scale: 1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 })
        break
      case 'compose':
        tl.to(shape, { rotation: 45, scale: 1.3, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200' })
          .to(shape, { scaleX: -1, duration: 0.8, ease: 'power2.inOut', transformOrigin: '200 200' })
          .to(shape, { rotation: 0, scale: 1, scaleX: 1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 })
        break
    }
  }

  return (
    <div className="flex flex-col items-center gap-4">
      <svg ref={svgRef} viewBox="0 0 400 400" className="w-80 h-80">
        {/* Axes */}
        <line x1="200" y1="0" x2="200" y2="400" stroke="rgba(255,255,255,0.1)" strokeDasharray="4" />
        <line x1="0" y1="200" x2="400" y2="200" stroke="rgba(255,255,255,0.1)" strokeDasharray="4" />
        {/* Ghost (original position) */}
        <polygon
          id="transform-ghost"
          points="160,140 240,140 260,200 240,260 160,260 140,200"
          fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="1" strokeDasharray="4"
          opacity="0"
        />
        {/* Shape */}
        <polygon
          id="transform-shape"
          points="160,140 240,140 260,200 240,260 160,260 140,200"
          fill="rgba(139, 92, 246, 0.6)" stroke="#8b5cf6" strokeWidth="2"
        />
        {/* Center marker */}
        <circle cx="200" cy="200" r="3" fill="#fff" />
      </svg>
      <button
        onClick={animate}
        disabled={playing}
        className="px-6 py-3 bg-white text-black font-mono text-sm uppercase tracking-wider disabled:opacity-50"
      >
        {type}
      </button>
    </div>
  )
}

Dissection Puzzle Framework

Reusable framework for any SVG-based dissection puzzle.

'use client'
import { useRef, useCallback } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'

interface PuzzlePiece {
  id: string
  path: string          // SVG path data
  color: string
  states: Record<string, { x: number; y: number; rotation: number; scale?: number }>
}

interface DissectionPuzzleProps {
  pieces: PuzzlePiece[]
  viewBox?: string
  initialState: string
  className?: string
}

export function DissectionPuzzle({ pieces, viewBox = '0 0 400 400', initialState, className }: DissectionPuzzleProps) {
  const svgRef = useRef<SVGSVGElement>(null)
  const currentState = useRef(initialState)

  useGSAP(() => {
    pieces.forEach(piece => {
      const el = svgRef.current!.querySelector(`#dp-${piece.id}`)
      const state = piece.states[initialState]
      if (el && state) {
        gsap.set(el, {
          x: state.x,
          y: state.y,
          rotation: state.rotation,
          scale: state.scale ?? 1,
          transformOrigin: 'center center',
        })
      }
    })
  }, { scope: svgRef })

  const transitionTo = useCallback((targetState: string) => {
    if (currentState.current === targetState) return
    pieces.forEach((piece, i) => {
      const el = svgRef.current!.querySelector(`#dp-${piece.id}`)
      const state = piece.states[targetState]
      if (el && state) {
        gsap.to(el, {
          x: state.x,
          y: state.y,
          rotation: state.rotation,
          scale: state.scale ?? 1,
          duration: 1.2,
          ease: 'power2.inOut',
          delay: i * 0.1,
          transformOrigin: 'center center',
        })
      }
    })
    currentState.current = targetState
  }, [pieces])

  const availableStates = pieces[0] ? Object.keys(pieces[0].states) : []

  return (
    <div className={`flex flex-col items-center gap-4 ${className ?? ''}`}>
      <svg ref={svgRef} viewBox={viewBox} className="w-80 h-80">
        {pieces.map(piece => (
          <path
            key={piece.id}
            id={`dp-${piece.id}`}
            d={piece.path}
            fill={piece.color}
            stroke="#000"
            strokeWidth="1.5"
            strokeLinejoin="round"
          />
        ))}
      </svg>
      <div className="flex gap-2">
        {availableStates.map(state => (
          <button
            key={state}
            onClick={() => transitionTo(state)}
            className="px-4 py-2 text-sm font-mono uppercase border border-white/30 hover:bg-white hover:text-black transition-colors"
          >
            {state}
          </button>
        ))}
      </div>
    </div>
  )
}

// Example usage:
// <DissectionPuzzle
//   initialState="triangle"
//   pieces={[
//     {
//       id: 'p1',
//       path: 'M 0 0 L 50 0 L 25 43 Z',
//       color: '#f43f5e',
//       states: {
//         triangle: { x: 100, y: 100, rotation: 0 },
//         square: { x: 150, y: 50, rotation: 45 },
//       },
//     },
//     // ... more pieces
//   ]}
// />

Choosing the Right Puzzle

Puzzle Best For Complexity
Dudeney Math demonstrations, educational Medium
Tangram Interactive play, creativity Low-Medium
Tessellation Backgrounds, patterns Low
Penrose Mathematical beauty, wow factor High
Polyominoes Game-like interactions Medium
Transformations Educational, geometric concepts Low