22 KiB
22 KiB
Geometric Shapes & Creative Coding
Patterns for creating geometric shapes, grids, and Tetris-style animations in React.
Table of Contents
- Decision Matrix
- SVG Programático
- Canvas 2D
- CSS Grid + GSAP
- Zdog (Pseudo-3D)
- p5.js Creative Coding
- Tetris-Style Patterns
Decision Matrix
| Necesidad | Herramienta | Por qué |
|---|---|---|
| Formas vectoriales simples | SVG en JSX | Nativo, animable con GSAP/Motion |
| Grids animados | CSS Grid + GSAP | Layout + animaciones |
| Dibujo programático | Canvas 2D | Performance, píxeles |
| Pseudo-3D estilizado | Zdog | Flat design 3D, ~2kb |
| Creative coding/arte | p5.js | Ecosistema, comunidad |
| Formas 3D reales | Three.js | WebGL completo |
SVG Programático
Formas Básicas en React
'use client'
// Todas las formas SVG son elementos JSX nativos
export function GeometricShapes() {
return (
<svg viewBox="0 0 400 400" className="w-full h-full">
{/* Rectángulo */}
<rect x="10" y="10" width="80" height="80" fill="#ff6b6b" />
{/* Cuadrado con bordes redondeados */}
<rect x="110" y="10" width="80" height="80" rx="10" fill="#4ecdc4" />
{/* Círculo */}
<circle cx="250" cy="50" r="40" fill="#45b7d1" />
{/* Elipse */}
<ellipse cx="350" cy="50" rx="40" ry="25" fill="#96ceb4" />
{/* Línea */}
<line x1="10" y1="120" x2="90" y2="180" stroke="#fff" strokeWidth="3" />
{/* Polilínea (líneas conectadas) */}
<polyline
points="110,180 150,120 190,180"
fill="none"
stroke="#ffeaa7"
strokeWidth="3"
/>
{/* Polígono (cerrado) - Triángulo */}
<polygon points="250,120 210,180 290,180" fill="#dfe6e9" />
{/* Polígono - Hexágono */}
<polygon
points="350,120 380,140 380,170 350,190 320,170 320,140"
fill="#a29bfe"
/>
{/* Path - Forma custom */}
<path
d="M10,220 L50,260 L10,300 L50,300 Z"
fill="#fd79a8"
/>
{/* Path - Curva Bézier */}
<path
d="M110,260 Q150,200 190,260"
fill="none"
stroke="#00cec9"
strokeWidth="3"
/>
</svg>
)
}
Generador de Polígonos
'use client'
function generatePolygonPoints(
sides: number,
radius: number,
centerX: number,
centerY: number
): string {
const points: string[] = []
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
const x = centerX + radius * Math.cos(angle)
const y = centerY + radius * Math.sin(angle)
points.push(`${x},${y}`)
}
return points.join(' ')
}
export function RegularPolygon({
sides,
radius,
cx,
cy,
fill,
}: {
sides: number
radius: number
cx: number
cy: number
fill: string
}) {
const points = generatePolygonPoints(sides, radius, cx, cy)
return <polygon points={points} fill={fill} />
}
// Uso
<svg viewBox="0 0 400 400">
<RegularPolygon sides={3} radius={40} cx={50} cy={50} fill="#ff6b6b" /> {/* Triángulo */}
<RegularPolygon sides={5} radius={40} cx={150} cy={50} fill="#4ecdc4" /> {/* Pentágono */}
<RegularPolygon sides={6} radius={40} cx={250} cy={50} fill="#45b7d1" /> {/* Hexágono */}
<RegularPolygon sides={8} radius={40} cx={350} cy={50} fill="#96ceb4" /> {/* Octágono */}
</svg>
SVG Animado con GSAP
'use client'
import { useRef } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'
export function AnimatedShapes() {
const svgRef = useRef<SVGSVGElement>(null)
useGSAP(() => {
// Rotar polígono
gsap.to('.rotating-shape', {
rotation: 360,
transformOrigin: 'center center',
duration: 4,
ease: 'none',
repeat: -1,
})
// Pulsar círculo
gsap.to('.pulsing-circle', {
scale: 1.2,
duration: 0.8,
ease: 'power2.inOut',
yoyo: true,
repeat: -1,
})
// Morph path
gsap.to('.morphing-rect', {
attr: { rx: 40 },
duration: 1,
ease: 'power2.inOut',
yoyo: true,
repeat: -1,
})
}, { scope: svgRef })
return (
<svg ref={svgRef} viewBox="0 0 400 200">
<polygon
className="rotating-shape"
points="50,20 80,80 20,80"
fill="#ff6b6b"
/>
<circle
className="pulsing-circle"
cx="150"
cy="50"
r="30"
fill="#4ecdc4"
/>
<rect
className="morphing-rect"
x="220"
y="20"
width="60"
height="60"
rx="0"
fill="#45b7d1"
/>
</svg>
)
}
SVG Animado con Motion
'use client'
import { motion } from 'motion/react'
export function MotionShapes() {
return (
<svg viewBox="0 0 400 200">
<motion.polygon
points="50,20 80,80 20,80"
fill="#ff6b6b"
animate={{ rotate: 360 }}
transition={{ duration: 4, repeat: Infinity, ease: 'linear' }}
style={{ transformOrigin: '50px 50px' }}
/>
<motion.circle
cx="150"
cy="50"
r="30"
fill="#4ecdc4"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 1.6, repeat: Infinity }}
/>
<motion.rect
x="220"
y="20"
width="60"
height="60"
fill="#45b7d1"
animate={{ rx: [0, 30, 0] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</svg>
)
}
Canvas 2D
Setup Básico en React
'use client'
import { useRef, useEffect } from 'react'
export function CanvasShapes() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
// Configurar tamaño
canvas.width = 400
canvas.height = 400
// Limpiar
ctx.fillStyle = '#1a1a2e'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Rectángulo
ctx.fillStyle = '#ff6b6b'
ctx.fillRect(10, 10, 80, 80)
// Rectángulo con borde
ctx.strokeStyle = '#4ecdc4'
ctx.lineWidth = 3
ctx.strokeRect(110, 10, 80, 80)
// Círculo
ctx.beginPath()
ctx.arc(250, 50, 40, 0, Math.PI * 2)
ctx.fillStyle = '#45b7d1'
ctx.fill()
// Triángulo
ctx.beginPath()
ctx.moveTo(350, 10)
ctx.lineTo(390, 90)
ctx.lineTo(310, 90)
ctx.closePath()
ctx.fillStyle = '#96ceb4'
ctx.fill()
// Polígono regular (hexágono)
drawPolygon(ctx, 50, 150, 40, 6, '#a29bfe')
}, [])
return <canvas ref={canvasRef} className="w-full h-full" />
}
function drawPolygon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
sides: number,
color: string
) {
ctx.beginPath()
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
const px = x + radius * Math.cos(angle)
const py = y + radius * Math.sin(angle)
if (i === 0) ctx.moveTo(px, py)
else ctx.lineTo(px, py)
}
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
}
Canvas Animado con requestAnimationFrame
'use client'
import { useRef, useEffect } from 'react'
interface Shape {
x: number
y: number
size: number
rotation: number
rotationSpeed: number
color: string
sides: number
}
export function AnimatedCanvasShapes() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
canvas.width = 600
canvas.height = 400
// Crear formas
const shapes: Shape[] = Array.from({ length: 10 }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: 20 + Math.random() * 40,
rotation: 0,
rotationSpeed: (Math.random() - 0.5) * 0.05,
color: `hsl(${Math.random() * 360}, 70%, 60%)`,
sides: 3 + Math.floor(Math.random() * 5),
}))
function drawPolygon(shape: Shape) {
ctx.save()
ctx.translate(shape.x, shape.y)
ctx.rotate(shape.rotation)
ctx.beginPath()
for (let i = 0; i < shape.sides; i++) {
const angle = (i * 2 * Math.PI) / shape.sides - Math.PI / 2
const px = shape.size * Math.cos(angle)
const py = shape.size * Math.sin(angle)
if (i === 0) ctx.moveTo(px, py)
else ctx.lineTo(px, py)
}
ctx.closePath()
ctx.fillStyle = shape.color
ctx.fill()
ctx.restore()
}
let animationId: number
function animate() {
ctx.fillStyle = 'rgba(26, 26, 46, 0.1)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
shapes.forEach((shape) => {
shape.rotation += shape.rotationSpeed
drawPolygon(shape)
})
animationId = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(animationId)
}, [])
return <canvas ref={canvasRef} className="w-full h-full" />
}
CSS Grid + GSAP
Grid de Bloques Animados (Tetris-style)
'use client'
import { useRef } from 'react'
import { gsap, useGSAP, stagger } from '@/lib/gsap'
export function AnimatedGrid() {
const gridRef = useRef<HTMLDivElement>(null)
useGSAP(() => {
gsap.from('.grid-block', {
scale: 0,
rotation: 180,
opacity: 0,
duration: 0.6,
stagger: {
amount: 1,
grid: [4, 4],
from: 'center',
},
ease: 'back.out(1.7)',
})
}, { scope: gridRef })
const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#a29bfe']
return (
<div
ref={gridRef}
className="grid grid-cols-4 gap-2 w-64 h-64"
>
{Array.from({ length: 16 }).map((_, i) => (
<div
key={i}
className="grid-block aspect-square rounded-lg"
style={{ backgroundColor: colors[i % colors.length] }}
/>
))}
</div>
)
}
Grid con Hover Interactivo
'use client'
import { useRef } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'
export function InteractiveGrid() {
const gridRef = useRef<HTMLDivElement>(null)
const { contextSafe } = useGSAP({ scope: gridRef })
const handleMouseEnter = contextSafe((e: React.MouseEvent<HTMLDivElement>) => {
gsap.to(e.currentTarget, {
scale: 1.1,
rotation: 5,
duration: 0.3,
ease: 'power2.out',
})
})
const handleMouseLeave = contextSafe((e: React.MouseEvent<HTMLDivElement>) => {
gsap.to(e.currentTarget, {
scale: 1,
rotation: 0,
duration: 0.5,
ease: 'elastic.out(1, 0.3)',
})
})
return (
<div ref={gridRef} className="grid grid-cols-5 gap-2">
{Array.from({ length: 25 }).map((_, i) => (
<div
key={i}
className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded cursor-pointer"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
))}
</div>
)
}
Zdog (Pseudo-3D)
Instalación
npm install zdog
# O para React
npm install react-zdog
Formas Básicas con Zdog
'use client'
import { useRef, useEffect } from 'react'
import Zdog from 'zdog'
export function ZdogShapes() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const illo = new Zdog.Illustration({
element: canvasRef.current!,
zoom: 2,
dragRotate: true,
})
// Cubo (usando Rect para cada cara)
const cube = new Zdog.Box({
addTo: illo,
width: 60,
height: 60,
depth: 60,
stroke: false,
color: '#ff6b6b',
leftFace: '#4ecdc4',
rightFace: '#45b7d1',
topFace: '#96ceb4',
bottomFace: '#ffeaa7',
})
// Cilindro
new Zdog.Cylinder({
addTo: illo,
diameter: 40,
length: 60,
stroke: false,
color: '#a29bfe',
backface: '#74b9ff',
translate: { x: 100 },
})
// Cono
new Zdog.Cone({
addTo: illo,
diameter: 50,
length: 70,
stroke: false,
color: '#fd79a8',
backface: '#e84393',
translate: { x: -100 },
})
// Hemisferio
new Zdog.Hemisphere({
addTo: illo,
diameter: 50,
stroke: false,
color: '#00cec9',
backface: '#81ecec',
translate: { y: 80 },
})
// Animación
function animate() {
illo.rotate.y += 0.01
illo.rotate.x += 0.005
illo.updateRenderGraph()
requestAnimationFrame(animate)
}
animate()
}, [])
return <canvas ref={canvasRef} width={400} height={400} />
}
Zdog Custom Shapes
// Forma L (estilo Tetris)
new Zdog.Shape({
addTo: illo,
path: [
{ x: 0, y: 0 },
{ x: 30, y: 0 },
{ x: 30, y: 30 },
{ x: 60, y: 30 },
{ x: 60, y: 60 },
{ x: 0, y: 60 },
],
closed: true,
stroke: 20,
color: '#ff6b6b',
})
// Forma T
new Zdog.Shape({
addTo: illo,
path: [
{ x: 0, y: 0 },
{ x: 90, y: 0 },
{ x: 90, y: 30 },
{ x: 60, y: 30 },
{ x: 60, y: 60 },
{ x: 30, y: 60 },
{ x: 30, y: 30 },
{ x: 0, y: 30 },
],
closed: true,
stroke: 20,
color: '#4ecdc4',
translate: { x: 100 },
})
p5.js Creative Coding
Instalación
npm install p5 @types/p5
npm install @p5-wrapper/react
Setup Básico con React
'use client'
import { useRef, useEffect } from 'react'
import p5 from 'p5'
export function P5Sketch() {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const sketch = (p: p5) => {
p.setup = () => {
p.createCanvas(400, 400)
p.background(26, 26, 46)
}
p.draw = () => {
p.background(26, 26, 46, 10)
// Formas geométricas
p.noStroke()
// Triángulo rotante
p.push()
p.translate(100, 100)
p.rotate(p.frameCount * 0.02)
p.fill(255, 107, 107)
p.triangle(0, -40, -35, 20, 35, 20)
p.pop()
// Hexágono
p.push()
p.translate(200, 200)
p.rotate(-p.frameCount * 0.01)
p.fill(78, 205, 196)
p.beginShape()
for (let i = 0; i < 6; i++) {
const angle = p.TWO_PI / 6 * i - p.HALF_PI
const x = p.cos(angle) * 50
const y = p.sin(angle) * 50
p.vertex(x, y)
}
p.endShape(p.CLOSE)
p.pop()
// Estrella
p.push()
p.translate(300, 300)
p.rotate(p.frameCount * 0.015)
p.fill(150, 206, 180)
drawStar(p, 0, 0, 20, 50, 5)
p.pop()
}
}
function drawStar(p: p5, x: number, y: number, r1: number, r2: number, points: number) {
const angle = p.TWO_PI / points
const halfAngle = angle / 2
p.beginShape()
for (let a = -p.HALF_PI; a < p.TWO_PI - p.HALF_PI; a += angle) {
let sx = x + p.cos(a) * r2
let sy = y + p.sin(a) * r2
p.vertex(sx, sy)
sx = x + p.cos(a + halfAngle) * r1
sy = y + p.sin(a + halfAngle) * r1
p.vertex(sx, sy)
}
p.endShape(p.CLOSE)
}
const p5Instance = new p5(sketch, containerRef.current!)
return () => p5Instance.remove()
}, [])
return <div ref={containerRef} />
}
p5.js con @p5-wrapper/react
'use client'
import dynamic from 'next/dynamic'
import type { P5CanvasInstance } from '@p5-wrapper/react'
// Importar dinámicamente para evitar SSR issues
const ReactP5Wrapper = dynamic(
() => import('@p5-wrapper/react').then((mod) => mod.ReactP5Wrapper),
{ ssr: false }
)
function sketch(p5: P5CanvasInstance) {
p5.setup = () => {
p5.createCanvas(400, 400)
}
p5.draw = () => {
p5.background(26, 26, 46)
p5.fill(255, 107, 107)
p5.noStroke()
// Polígonos que siguen al mouse
p5.push()
p5.translate(p5.mouseX, p5.mouseY)
p5.rotate(p5.frameCount * 0.02)
regularPolygon(p5, 0, 0, 40, 6)
p5.pop()
}
}
function regularPolygon(p5: P5CanvasInstance, x: number, y: number, r: number, sides: number) {
p5.beginShape()
for (let i = 0; i < sides; i++) {
const angle = p5.TWO_PI / sides * i - p5.HALF_PI
p5.vertex(x + p5.cos(angle) * r, y + p5.sin(angle) * r)
}
p5.endShape(p5.CLOSE)
}
export function P5WrapperDemo() {
return <ReactP5Wrapper sketch={sketch} />
}
Tetris-Style Patterns
Piezas de Tetris como Componentes
'use client'
import { motion } from 'motion/react'
type TetrisPiece = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L'
const PIECES: Record<TetrisPiece, { shape: number[][]; color: string }> = {
I: { shape: [[1, 1, 1, 1]], color: '#00f5ff' },
O: { shape: [[1, 1], [1, 1]], color: '#ffeb3b' },
T: { shape: [[0, 1, 0], [1, 1, 1]], color: '#9c27b0' },
S: { shape: [[0, 1, 1], [1, 1, 0]], color: '#4caf50' },
Z: { shape: [[1, 1, 0], [0, 1, 1]], color: '#f44336' },
J: { shape: [[1, 0, 0], [1, 1, 1]], color: '#2196f3' },
L: { shape: [[0, 0, 1], [1, 1, 1]], color: '#ff9800' },
}
export function TetrisPiece({
type,
size = 30,
rotation = 0,
}: {
type: TetrisPiece
size?: number
rotation?: number
}) {
const piece = PIECES[type]
return (
<motion.div
className="inline-grid gap-0.5"
style={{
gridTemplateColumns: `repeat(${piece.shape[0].length}, ${size}px)`,
}}
animate={{ rotate: rotation }}
transition={{ type: 'spring', stiffness: 200, damping: 15 }}
>
{piece.shape.flat().map((cell, i) => (
<motion.div
key={i}
className="rounded-sm"
style={{
width: size,
height: size,
backgroundColor: cell ? piece.color : 'transparent',
boxShadow: cell ? 'inset 2px 2px 4px rgba(255,255,255,0.3)' : 'none',
}}
initial={{ scale: 0 }}
animate={{ scale: cell ? 1 : 0 }}
transition={{ delay: i * 0.05 }}
/>
))}
</motion.div>
)
}
// Uso
export function TetrisDemo() {
return (
<div className="flex gap-8 flex-wrap">
<TetrisPiece type="I" />
<TetrisPiece type="O" />
<TetrisPiece type="T" />
<TetrisPiece type="S" />
<TetrisPiece type="Z" />
<TetrisPiece type="J" />
<TetrisPiece type="L" />
</div>
)
}
Tetris Falling Animation
'use client'
import { useRef, useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
const COLORS = ['#00f5ff', '#ffeb3b', '#9c27b0', '#4caf50', '#f44336', '#2196f3', '#ff9800']
interface FallingBlock {
id: number
x: number
color: string
}
export function TetrisFalling() {
const [blocks, setBlocks] = useState<FallingBlock[]>([])
const idRef = useRef(0)
useEffect(() => {
const interval = setInterval(() => {
setBlocks((prev) => [
...prev.slice(-20), // Mantener últimos 20
{
id: idRef.current++,
x: Math.random() * 80 + 10, // 10-90%
color: COLORS[Math.floor(Math.random() * COLORS.length)],
},
])
}, 500)
return () => clearInterval(interval)
}, [])
return (
<div className="relative w-full h-96 overflow-hidden bg-gray-900 rounded-lg">
<AnimatePresence>
{blocks.map((block) => (
<motion.div
key={block.id}
className="absolute w-8 h-8 rounded"
style={{
left: `${block.x}%`,
backgroundColor: block.color,
boxShadow: 'inset 2px 2px 4px rgba(255,255,255,0.3)',
}}
initial={{ y: -50, rotate: 0 }}
animate={{ y: 400, rotate: 180 }}
exit={{ opacity: 0 }}
transition={{
y: { duration: 2, ease: 'easeIn' },
rotate: { duration: 2, ease: 'linear' },
}}
/>
))}
</AnimatePresence>
</div>
)
}
Grid Board con GSAP
'use client'
import { useRef, useState } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'
const GRID_SIZE = 10
const CELL_SIZE = 30
export function TetrisBoard() {
const boardRef = useRef<HTMLDivElement>(null)
const [grid, setGrid] = useState<string[][]>(
Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(''))
)
const { contextSafe } = useGSAP({ scope: boardRef })
const handleCellClick = contextSafe((row: number, col: number) => {
const colors = ['#00f5ff', '#ffeb3b', '#9c27b0', '#4caf50', '#f44336']
const newColor = colors[Math.floor(Math.random() * colors.length)]
// Animar el click
gsap.fromTo(
`[data-cell="${row}-${col}"]`,
{ scale: 0, rotation: 180 },
{ scale: 1, rotation: 0, duration: 0.5, ease: 'back.out(1.7)' }
)
setGrid((prev) => {
const newGrid = [...prev]
newGrid[row] = [...newGrid[row]]
newGrid[row][col] = newGrid[row][col] ? '' : newColor
return newGrid
})
})
return (
<div
ref={boardRef}
className="inline-grid gap-1 p-2 bg-gray-900 rounded-lg"
style={{ gridTemplateColumns: `repeat(${GRID_SIZE}, ${CELL_SIZE}px)` }}
>
{grid.map((row, rowIndex) =>
row.map((cell, colIndex) => (
<div
key={`${rowIndex}-${colIndex}`}
data-cell={`${rowIndex}-${colIndex}`}
className="rounded cursor-pointer transition-colors"
style={{
width: CELL_SIZE,
height: CELL_SIZE,
backgroundColor: cell || '#2d2d2d',
boxShadow: cell ? 'inset 2px 2px 4px rgba(255,255,255,0.3)' : 'none',
}}
onClick={() => handleCellClick(rowIndex, colIndex)}
/>
))
)}
</div>
)
}
Recursos
- p5.js - Creative coding
- Zdog - Pseudo-3D engine
- @p5-wrapper/react - React wrapper
- SVG Tutorial MDN
- Canvas API MDN