
Awwwards Animations
- 1.8k installs
- 10 repo stars
- Updated February 9, 2026
- devmartinese/awwwards-animations-skill
How to create production-ready, Awwwards-quality animations in React using GSAP, Motion, Anime.js, and Lenis with proper cleanup, 60fps performance, and accessibility.
About
Professional React animation framework for creating Awwwards/FWA-quality animations. Covers scroll-driven effects (GSAP ScrollTrigger + Lenis), page transitions, text animations, parallax, magnetic cursors, and generative art. React-first with proper hooks cleanup, 60fps performance non-negotiable. Includes decision matrix for library selection, integrated Lenis smoothing, pre-built patterns (magnetic button, character reveal, glitch effects, fractal trees), and design philosophy (brutalist, minimalist, neo-brutalism). Detailed references for advanced topics: algorithmic art, geometric puzzles, audio-reactive, physics 2D, Three.js integration.
- GSAP + useGSAP + Lenis integration for 60fps scroll animations
- Decision matrix: ScrollTrigger for scroll-driven, Motion for React-native, Anime.js for lightweight
- Pre-built patterns: magnetic cursor/button, parallax hero, text reveal, glitch, fractals, geometric dissection
- Generative art support: fractals, L-systems, flow fields, strange attractors, noise, sacred geometry
- Performance rules, accessibility (prefers-reduced-motion), testing checklist, common pitfalls
Awwwards Animations by the numbers
- 1,849 all-time installs (skills.sh)
- +45 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #260 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/devmartinese/awwwards-animations-skill --skill awwwards-animationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.8k |
|---|---|
| repo stars | ★ 10 |
| Security audit | 2 / 3 scanners passed |
| Last updated | February 9, 2026 |
| Repository | devmartinese/awwwards-animations-skill ↗ |
What it does
Build award-worthy React animations (scroll, parallax, morphing, text effects) at 60fps using GSAP, Motion, Anime.js.
Who is it for?
React developers building premium web experiences, design-focused projects, portfolio work, award-submission websites, interactive storytelling, generative/algorithmic art sites.
Skip if: Static sites, accessibility-first minimalist UX, mobile-only apps (limited scroll performance), projects prohibiting external animation libraries, beginners new to React hooks.
When should I use this skill?
Request mentions: Awwwards, smooth scroll, ScrollTrigger, magnetic effects, reveal animations, parallax, page transitions, glitch effects, kinetic typography, fractals, L-systems, flow fields, geometric puzzles, brutalis
What you get
Developer can deliver 60fps Awwwards/FWA-level animations with confidence, using decision matrix to select tools, leveraging pre-built React patterns, implementing scroll-driven effects safely, and avoiding common pitfal
- Copy-paste React animation components
- Animation hooks with cleanup
By the numbers
- Covers 10+ animation libraries
- Documents 60fps performance patterns
Files
Awwwards Animations
Create premium web animations at Awwwards/FWA quality level. React-first approach. 60fps non-negotiable.
Decision Matrix
| Task | Library | Why |
|---|---|---|
| Scroll-driven animations | GSAP + ScrollTrigger + useGSAP | Industry standard, best control |
| Smooth scroll | Lenis + ReactLenis | Best performance, works with ScrollTrigger |
| React-native animations | Motion (Framer Motion) | Native React, useScroll/useTransform |
| Simple/lightweight effects | Anime.js 4.0 | Small footprint, clean API |
| Complex timelines | GSAP | Unmatched timeline control |
| SVG morphing | GSAP MorphSVG or Anime.js | Both excellent |
| 3D + animation | Three.js + GSAP | GSAP controls Three.js objects |
| Page transitions | AnimatePresence or GSAP | Motion for React, GSAP for complex |
| Geometric shapes (vector) | SVG + GSAP/Motion | Native, animable |
| Geometric shapes (canvas) | Canvas 2D API | Programmatic, performant |
| Pseudo-3D shapes | Zdog | Flat design 3D, ~2kb |
| Creative coding/generative | p5.js | Rich ecosystem |
| Audio reactive | Tone.js | Web Audio, synths, effects |
| Physics 2D | Matter.js | Gravity, collisions, constraints |
| Algorithmic/generative art | Canvas 2D + p5.js | Math-driven visuals |
| Fractals/L-systems | Canvas 2D recursivo | Recursive rendering |
| Tessellations/geometric puzzles | SVG + GSAP | Precise animated transforms |
| Kinetic typography advanced | GSAP SplitText + Canvas | Per-char control |
| Glitch effects | CSS + GSAP | Layered RGB split, clip-path |
| Brutalist animation | CSS raw + Motion | Hard cuts, no easing |
| Minimalist animation | Motion springs | Subtle, purposeful motion |
Installation (Latest Stable - 2025)
# GSAP + React hook (v3.14.1)
npm install gsap @gsap/react
# Lenis (v1.3.17) - includes React components
npm install lenis
# Motion (Framer Motion)
npm install motion
# Anime.js (v4.0.0)
npm install animejsReact Setup
1. GSAP Configuration (app-wide)
// lib/gsap.ts
'use client' // Next.js App Router
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useGSAP } from '@gsap/react'
// Register plugins once
gsap.registerPlugin(ScrollTrigger, useGSAP)
export { gsap, ScrollTrigger, useGSAP }2. Lenis + GSAP ScrollTrigger Integration (Critical)
// components/SmoothScroll.tsx
'use client'
import { ReactLenis, useLenis } from 'lenis/react'
import { useEffect } from 'react'
import { gsap, ScrollTrigger } from '@/lib/gsap'
export function SmoothScroll({ children }: { children: React.ReactNode }) {
const lenis = useLenis()
useEffect(() => {
if (!lenis) return
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)
return () => { gsap.ticker.remove(lenis?.raf) }
}, [lenis])
return (
<ReactLenis root options={{ lerp: 0.1, duration: 1.2, smoothWheel: true }}>
{children}
</ReactLenis>
)
}
// Wrap in layout: <SmoothScroll>{children}</SmoothScroll>Core Patterns (React)
Detailed implementations in references:
- GSAP + useGSAP: See references/gsap-react.md
- Motion (Framer Motion): See references/motion-patterns.md
- Anime.js 4.0: See references/animejs-react.md
- Lenis React: See references/lenis-react.md
- Geometric Shapes: See references/geometric-shapes.md (SVG, Canvas, Zdog, p5.js, Tetris-style)
- Audio Reactive: See references/audio-reactive.md (Tone.js, Web Audio, scroll audio)
- Physics 2D: See references/physics-2d.md (Matter.js, collisions, constraints)
- Advanced (Three.js, WebGL): See references/advanced-patterns.md
- Algorithmic & Generative Art: See references/algorithmic-art.md (fractals, L-systems, flow fields, attractors, noise, sacred geometry)
- Advanced Text Effects: See references/text-effects.md (glitch, kinetic typography, morphing, explosion, circular text, scramble)
- Geometric Puzzles: See references/geometric-puzzles.md (Dudeney, tangram, tessellations, Penrose, polyominoes)
- Design Philosophy: See references/design-philosophy.md (brutalist, minimalist, abstract, mixing styles, palettes)
- Performance: See references/performance.md
Quick Patterns (React)
1. Magnetic Cursor (GSAP + useGSAP)
'use client'
import { useRef, useEffect } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'
export function MagneticCursor() {
const cursorRef = useRef<HTMLDivElement>(null)
const pos = useRef({ x: 0, y: 0, cx: 0, cy: 0 })
useEffect(() => {
const h = (e: MouseEvent) => { pos.current.x = e.clientX; pos.current.y = e.clientY }
window.addEventListener('mousemove', h)
return () => window.removeEventListener('mousemove', h)
}, [])
useGSAP(() => {
gsap.ticker.add(() => {
const p = pos.current
p.cx += (p.x - p.cx) * 0.15; p.cy += (p.y - p.cy) * 0.15
gsap.set(cursorRef.current, { x: p.cx, y: p.cy })
})
})
return <div ref={cursorRef} className="fixed w-10 h-10 border border-white rounded-full pointer-events-none mix-blend-difference z-[9999] -translate-x-1/2 -translate-y-1/2" />
}2. Magnetic Button (Motion)
'use client'
import { useRef, useState } from 'react'
import { motion } from 'motion/react'
export function MagneticButton({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLButtonElement>(null)
const [pos, setPos] = useState({ x: 0, y: 0 })
const onMove = (e: React.MouseEvent) => {
const { left, top, width, height } = ref.current!.getBoundingClientRect()
setPos({ x: (e.clientX - left - width / 2) * 0.3, y: (e.clientY - top - height / 2) * 0.3 })
}
return (
<motion.button ref={ref} onMouseMove={onMove} onMouseLeave={() => setPos({ x: 0, y: 0 })}
animate={pos} transition={{ type: 'spring', stiffness: 150, damping: 15 }}
className="px-8 py-4 bg-white text-black rounded-full">{children}</motion.button>
)
}3. Parallax Hero (GSAP + useGSAP)
'use client'
import { useRef } from 'react'
import { gsap, ScrollTrigger, useGSAP } from '@/lib/gsap'
export function ParallaxHero() {
const containerRef = useRef<HTMLDivElement>(null)
useGSAP(() => {
gsap.to('.parallax-bg', {
yPercent: 50,
ease: 'none',
scrollTrigger: {
trigger: containerRef.current,
start: 'top top',
end: 'bottom top',
scrub: true,
},
})
gsap.to('.hero-title', {
yPercent: 100,
opacity: 0,
scrollTrigger: {
trigger: containerRef.current,
start: 'top top',
end: '50% top',
scrub: true,
},
})
}, { scope: containerRef })
return (
<div ref={containerRef} className="relative h-screen overflow-hidden">
<div className="parallax-bg absolute inset-0 bg-cover bg-center" />
<h1 className="hero-title absolute inset-0 flex items-center justify-center text-6xl">
Hero Title
</h1>
</div>
)
}4. Text Character Reveal (Motion)
'use client'
import { motion } from 'motion/react'
const container = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.02 },
},
}
const child = {
hidden: { opacity: 0, y: 50, rotateX: -90 },
visible: {
opacity: 1,
y: 0,
rotateX: 0,
transition: { type: 'spring', damping: 12 },
},
}
export function TextReveal({ text }: { text: string }) {
return (
<motion.span
variants={container}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="inline-block"
>
{text.split('').map((char, i) => (
<motion.span key={i} variants={child} className="inline-block">
{char === ' ' ? '\u00A0' : char}
</motion.span>
))}
</motion.span>
)
}5. Image Reveal (GSAP)
'use client'
import { useRef } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'
export function ImageReveal({ src, alt }: { src: string; alt: string }) {
const containerRef = useRef<HTMLDivElement>(null)
useGSAP(() => {
gsap.from(containerRef.current, {
clipPath: 'inset(100% 0% 0% 0%)',
duration: 1.2,
ease: 'power4.inOut',
scrollTrigger: {
trigger: containerRef.current,
start: 'top 80%',
},
})
gsap.from('.reveal-img', {
scale: 1.3,
duration: 1.5,
ease: 'power2.out',
scrollTrigger: {
trigger: containerRef.current,
start: 'top 80%',
},
})
}, { scope: containerRef })
return (
<div ref={containerRef} className="overflow-hidden">
<img src={src} alt={alt} className="reveal-img w-full h-full object-cover" />
</div>
)
}6. Glitch Text Effect (CSS + GSAP)
'use client'
import { useRef, useEffect } from 'react'
import { gsap } from '@/lib/gsap'
export function GlitchText({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const layers = ref.current!.querySelectorAll('.g-layer')
const tl = gsap.timeline({ repeat: -1, repeatDelay: 3 })
tl.to(layers[0], { x: -5, duration: 0.05, ease: 'none' }, 0)
.to(layers[0], { x: 5, duration: 0.05 }, 0.05)
.to(layers[0], { x: 0, duration: 0.05 }, 0.1)
.to(layers[1], { x: 5, duration: 0.05 }, 0.02)
.to(layers[1], { x: -5, duration: 0.05 }, 0.07)
.to(layers[1], { x: 0, duration: 0.05 }, 0.12)
return () => { tl.kill() }
}, [])
return (
<div ref={ref} className="relative font-mono text-5xl font-black">
<span className="relative z-10">{text}</span>
<span className="g-layer absolute inset-0 text-cyan-400 mix-blend-multiply" aria-hidden>{text}</span>
<span className="g-layer absolute inset-0 text-red-400 mix-blend-multiply" aria-hidden>{text}</span>
</div>
)
}7. Fractal Tree (Canvas 2D)
'use client'
import { useRef, useEffect } from 'react'
export function FractalTree({ depth = 10, angle = 25 }: { depth?: number; angle?: number }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
canvas.width = canvas.offsetWidth * 2; canvas.height = canvas.offsetHeight * 2; ctx.scale(2, 2)
let progress = 0, raf = 0
function branch(x: number, y: number, len: number, a: number, d: number) {
if (d > depth || len < 2) return
const dp = Math.max(0, Math.min(1, progress * depth - d))
if (dp <= 0) return
const ex = x + Math.cos(a * Math.PI / 180) * len * dp
const ey = y - Math.sin(a * Math.PI / 180) * len * dp
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(ex, ey)
ctx.strokeStyle = `hsl(${120 + d * 15}, 60%, ${30 + d * 5}%)`
ctx.lineWidth = Math.max(1, (depth - d) * 1.5); ctx.stroke()
branch(ex, ey, len * 0.72, a + angle, d + 1)
branch(ex, ey, len * 0.72, a - angle, d + 1)
}
const animate = () => {
progress = Math.min(1, progress + 0.008)
ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight)
branch(canvas.offsetWidth / 2, canvas.offsetHeight, canvas.offsetHeight * 0.28, 90, 0)
if (progress < 1) raf = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(raf)
}, [depth, angle])
return <canvas ref={canvasRef} className="w-full h-full bg-gray-950" />
}See references/algorithmic-art.md for L-systems, flow fields, attractors, noise, sacred geometry.
8. Geometric Dissection (SVG + GSAP)
'use client'
import { useRef, useState } from 'react'
import { gsap } from '@/lib/gsap'
const P = [
{ id: 'A', tri: 'M 0,173 L 50,87 L 100,173 Z', sq: 'M 0,0 L 100,0 L 100,87 L 0,87 Z', c: '#f43f5e' },
{ id: 'B', tri: 'M 50,87 L 100,0 L 150,87 Z', sq: 'M 100,0 L 200,0 L 200,87 L 100,87 Z', c: '#8b5cf6' },
{ id: 'C', tri: 'M 100,173 L 150,87 L 200,173 Z', sq: 'M 0,87 L 100,87 L 100,173 L 0,173 Z', c: '#06b6d4' },
{ id: 'D', tri: 'M 50,87 L 100,173 L 150,87 L 100,0 Z', sq: 'M 100,87 L 200,87 L 200,173 L 100,173 Z', c: '#f59e0b' },
]
export function GeometricDissection() {
const svg = useRef<SVGSVGElement>(null)
const [isSq, setSq] = useState(false)
const morph = () => {
const t = !isSq
P.forEach((p, i) => {
const el = svg.current!.querySelector(`#d-${p.id}`)
if (el) gsap.to(el, { attr: { d: t ? p.sq : p.tri }, duration: 1.5, ease: 'power2.inOut', delay: i * 0.15 })
}); setSq(t)
}
return (
<div className="flex flex-col items-center gap-4">
<svg ref={svg} viewBox="-10 -10 220 200" className="w-64 h-64">
{P.map(p => <path key={p.id} id={`d-${p.id}`} d={p.tri} fill={p.c} stroke="#000" strokeWidth="1.5" />)}
</svg>
<button onClick={morph} className="px-6 py-2 bg-white text-black font-mono text-sm">{isSq ? '△' : '□'}</button>
</div>
)
}See references/geometric-puzzles.md for tangram, tessellations, Penrose tiles, polyominoes.
9. Brutalist Grid (Motion)
'use client'
import { motion } from 'motion/react'
export function BrutalistGrid({ items }: { items: string[] }) {
return (
<div className="grid grid-cols-3 border-2 border-black">
{items.map((item, i) => (
<motion.div key={i}
className="border-2 border-black p-6 font-mono font-black uppercase text-2xl"
style={{ mixBlendMode: i % 2 === 0 ? 'normal' : 'difference' }}
initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }}
transition={{ duration: 0, delay: i * 0.1 }}
whileHover={{ backgroundColor: '#000', color: '#BAFF39', transition: { duration: 0 } }}
>{item}</motion.div>
))}
</div>
)
}Design Philosophy (Quick Reference)
| Style | Motion Feel | Easing | Typography | Key Trait |
|---|---|---|---|---|
| Brutalist | Hard, instant, jarring | none / steps() | Mono, 15-30vw | Raw honesty |
| Minimalist | Smooth, subtle, slow | power2.out | Sans-serif light | Purposeful restraint |
| Abstract | Noise-driven, parametric | Organic/sine | Varies | Mathematical beauty |
| Neo-Brutalist | Bold but controlled | power1.out | Mono + color | Brutalism + restraint |
See references/design-philosophy.md for full guide with color palettes and mixing strategies.
Easing Reference
| Feel | GSAP | Motion |
|---|---|---|
| Smooth | power2.out | [0.16, 1, 0.3, 1] |
| Snappy | power4.out | [0.87, 0, 0.13, 1] |
| Bouncy | back.out(1.7) | { type: 'spring', stiffness: 300, damping: 20 } |
| Dramatic | power4.inOut | [0.76, 0, 0.24, 1] |
Timing
- Micro-interactions: 150-300ms
- UI transitions: 300-500ms
- Page transitions: 500-800ms
- Stagger: 0.02-0.1s per item
Accessibility
// Motion: useReducedMotion() → conditionally disable/reduce animations
import { useReducedMotion } from 'motion/react'
const reduced = useReducedMotion() // true if prefers-reduced-motion: reduce@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}Performance Rules
1. Only animate transform and opacity 2. Use will-change sparingly 3. Always cleanup: useGSAP handles it automatically 4. Scope GSAP selectors to container refs 5. Use contextSafe() for event handlers with GSAP 6. Memoize Motion variants objects
Common Pitfalls
1. Not integrating Lenis with ScrollTrigger 2. Missing scope in useGSAP 3. Not using contextSafe() for click handlers 4. React 18 Strict Mode calling effects twice 5. Forgetting 'use client' in Next.js App Router 6. Not calling ScrollTrigger.refresh() after dynamic content
Testing Checklist
- [ ] 60fps on scroll (Chrome DevTools Performance)
- [ ] Keyboard navigation works
- [ ] Respects prefers-reduced-motion
- [ ] No layout shifts (CLS)
- [ ] Mobile touch works
- [ ] ScrollTrigger markers removed in prod
- [ ] No memory leaks on unmount
Inspiration
Active Theory, Studio Freight, Locomotive, Resn, Aristide Benoist, Immersive Garden
.DS_Store
*.skill
Awwwards Animations
A comprehensive Claude Code skill for creating Awwwards/FWA-level web animations in React. Production-ready patterns for scroll experiences, physics, audio-reactive visuals, and more.
Features
- React-first approach with proper hooks, cleanup, and TypeScript
- 10+ animation libraries covered with best practices
- 60fps guaranteed patterns and performance optimization
- Copy-paste ready components and hooks
What's Included
| Category | Libraries | Patterns |
|---|---|---|
| Scroll Animations | GSAP + ScrollTrigger, Motion | Parallax, pin sections, horizontal scroll |
| Smooth Scroll | Lenis + ReactLenis | GSAP integration, scroll-linked effects |
| React Animations | Motion (Framer Motion) | useScroll, useTransform, AnimatePresence |
| Lightweight Effects | Anime.js 4.0 | Timelines, stagger, SVG morphing |
| Geometric Shapes | SVG, Canvas, Zdog, p5.js | Tetris-style, creative coding |
| Audio Reactive | Tone.js, Web Audio API | Scroll audio, UI sounds, visualizers |
| Physics 2D | Matter.js | Gravity, collisions, constraints |
| 3D & WebGL | Three.js + GSAP | Shaders, canvas effects |
Installation
Using npx (skills.sh)
npx skills add YOUR_USERNAME/awwwards-animationsManual Installation
Copy the skill folder to your Claude Code skills directory:
# Global installation
cp -r awwwards-animations ~/.claude/.agents/skills/
# Or project-level
cp -r awwwards-animations .claude/skills/Usage
Once installed, simply ask Claude to help you with animations:
"Create a hero section with parallax and smooth scroll"
"Make a grid of blocks that fall with physics and play sounds on collision"
"Build a magnetic cursor with blend mode difference"
Claude will use this skill automatically when you request animation-related help.
Project Setup
When starting a new project, install the required dependencies:
# Core animation libraries
npm install gsap @gsap/react lenis motion animejs
# Optional: Physics & Audio
npm install matter-js tone
npm install --save-dev @types/matter-jsSkill Structure
awwwards-animations/
├── SKILL.md # Core patterns & quick reference
└── references/
├── gsap-react.md # useGSAP, ScrollTrigger, timelines
├── motion-patterns.md # Framer Motion patterns
├── animejs-react.md # Anime.js 4.0 patterns
├── lenis-react.md # Smooth scroll integration
├── geometric-shapes.md # SVG, Canvas, Zdog, p5.js
├── audio-reactive.md # Tone.js, Web Audio
├── physics-2d.md # Matter.js physics
├── advanced-patterns.md # Three.js, WebGL, shaders
└── performance.md # 60fps optimizationKey Patterns
Lenis + GSAP ScrollTrigger (Critical Setup)
import { ReactLenis, useLenis } from 'lenis/react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
// Connect Lenis to ScrollTrigger
const lenis = useLenis()
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)Magnetic Button (Motion)
<motion.button
onMouseMove={handleMouse}
onMouseLeave={() => setPosition({ x: 0, y: 0 })}
animate={position}
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
>
{children}
</motion.button>Physics + Audio Collision
Matter.Events.on(engine, 'collisionStart', async () => {
await Tone.start()
synth.triggerAttackRelease('C4', '16n')
})Libraries & Versions
| Library | Version | Purpose |
|---|---|---|
| GSAP | 3.12+ | Scroll animations, timelines |
| @gsap/react | latest | useGSAP hook |
| Lenis | 1.1+ | Smooth scroll |
| Motion | latest | React animations |
| Anime.js | 4.0+ | Lightweight animations |
| Matter.js | 0.19+ | 2D physics |
| Tone.js | 14+ | Audio synthesis |
Inspiration
This skill is designed to help create websites at the level of:
Contributing
Contributions are welcome! Feel free to:
1. Fork the repository 2. Add new patterns or improve existing ones 3. Submit a pull request
License
MIT License - feel free to use in personal and commercial projects.
---
Made for the creative developer community
Advanced Animation Patterns
Three.js integration, WebGL, Canvas effects, and advanced SVG animations.
Table of Contents
1. Three.js + GSAP 2. WebGL Shaders 3. Canvas Effects 4. Image Sequences 5. SVG Advanced 6. View Transitions API
Three.js + GSAP
Setup
npm install three @types/three @react-three/fiber @react-three/dreiBasic Integration
'use client'
import { Canvas, useFrame, useThree } from '@react-three/fiber'
import { useRef, useEffect } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import * as THREE from 'three'
gsap.registerPlugin(ScrollTrigger)
function AnimatedMesh() {
const meshRef = useRef<THREE.Mesh>(null)
useEffect(() => {
if (!meshRef.current) return
// GSAP can animate Three.js objects directly
gsap.to(meshRef.current.rotation, {
x: Math.PI * 2,
y: Math.PI * 2,
scrollTrigger: {
trigger: '#canvas-container',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
}
})
gsap.to(meshRef.current.position, {
z: 2,
scrollTrigger: {
trigger: '#canvas-container',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
}
})
}, [])
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
)
}
export function ThreeScene() {
return (
<div id="canvas-container" className="h-[300vh]">
<div className="fixed inset-0">
<Canvas camera={{ position: [0, 0, 5] }}>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} />
<AnimatedMesh />
</Canvas>
</div>
</div>
)
}Material Animation
function AnimatedMaterial() {
const materialRef = useRef<THREE.MeshStandardMaterial>(null)
useEffect(() => {
if (!materialRef.current) return
gsap.to(materialRef.current, {
opacity: 0.5,
metalness: 1,
roughness: 0,
scrollTrigger: {
trigger: '#scene',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
}
})
}, [])
return (
<meshStandardMaterial
ref={materialRef}
color="#ffffff"
transparent
/>
)
}Camera Animation
function CameraRig() {
const { camera } = useThree()
useEffect(() => {
gsap.to(camera.position, {
x: 5,
y: 2,
z: 3,
scrollTrigger: {
trigger: '#scene',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
onUpdate: () => camera.lookAt(0, 0, 0)
}
})
}, [camera])
return null
}Scroll-Linked Animation with useFrame
function ScrollLinkedMesh() {
const meshRef = useRef<THREE.Mesh>(null)
const scrollProgress = useRef(0)
useEffect(() => {
ScrollTrigger.create({
trigger: '#canvas-container',
start: 'top top',
end: 'bottom bottom',
onUpdate: (self) => {
scrollProgress.current = self.progress
}
})
}, [])
useFrame(() => {
if (meshRef.current) {
meshRef.current.rotation.y = scrollProgress.current * Math.PI * 2
meshRef.current.position.y = Math.sin(scrollProgress.current * Math.PI) * 2
}
})
return (
<mesh ref={meshRef}>
<torusKnotGeometry args={[1, 0.3, 128, 16]} />
<meshNormalMaterial />
</mesh>
)
}WebGL Shaders
Custom Shader Material with GSAP
'use client'
import { useRef, useEffect } from 'react'
import { Canvas, useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
const vertexShader = `
varying vec2 vUv;
uniform float uTime;
uniform float uProgress;
void main() {
vUv = uv;
vec3 pos = position;
pos.z += sin(pos.x * 10.0 + uTime) * 0.1 * uProgress;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`
const fragmentShader = `
varying vec2 vUv;
uniform float uProgress;
void main() {
vec3 color = mix(vec3(0.0), vec3(1.0, 0.5, 0.0), uProgress);
gl_FragColor = vec4(color, 1.0);
}
`
function ShaderPlane() {
const materialRef = useRef<THREE.ShaderMaterial>(null)
const uniforms = useRef({
uTime: { value: 0 },
uProgress: { value: 0 }
})
useEffect(() => {
gsap.to(uniforms.current.uProgress, {
value: 1,
scrollTrigger: {
trigger: '#shader-scene',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
}
})
}, [])
useFrame(({ clock }) => {
if (materialRef.current) {
materialRef.current.uniforms.uTime.value = clock.getElapsedTime()
}
})
return (
<mesh>
<planeGeometry args={[4, 4, 32, 32]} />
<shaderMaterial
ref={materialRef}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={uniforms.current}
/>
</mesh>
)
}Image Distortion Shader
const distortionFragment = `
uniform sampler2D uTexture;
uniform float uProgress;
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
// Distortion based on progress
float distortion = sin(uv.y * 10.0 + uTime) * 0.1 * uProgress;
uv.x += distortion;
vec4 color = texture2D(uTexture, uv);
gl_FragColor = color;
}
`
function DistortedImage({ src }: { src: string }) {
const materialRef = useRef<THREE.ShaderMaterial>(null)
const texture = useLoader(TextureLoader, src)
const uniforms = useRef({
uTexture: { value: texture },
uProgress: { value: 0 },
uTime: { value: 0 }
})
useEffect(() => {
gsap.to(uniforms.current.uProgress, {
value: 1,
duration: 1,
ease: 'power2.out'
})
}, [])
useFrame(({ clock }) => {
if (materialRef.current) {
materialRef.current.uniforms.uTime.value = clock.getElapsedTime()
}
})
return (
<mesh>
<planeGeometry args={[2, 2]} />
<shaderMaterial
ref={materialRef}
uniforms={uniforms.current}
fragmentShader={distortionFragment}
vertexShader={`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`}
/>
</mesh>
)
}Canvas Effects
Particle System on Scroll
'use client'
import { useRef, useEffect } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
interface Particle {
x: number
y: number
vx: number
vy: number
size: number
}
export function ParticleCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const particles = useRef<Particle[]>([])
const scrollProgress = useRef(0)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
// Setup canvas
const resize = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resize()
window.addEventListener('resize', resize)
// Create particles
for (let i = 0; i < 100; i++) {
particles.current.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
size: Math.random() * 3 + 1
})
}
// ScrollTrigger
ScrollTrigger.create({
trigger: '#particle-section',
start: 'top top',
end: 'bottom bottom',
onUpdate: (self) => {
scrollProgress.current = self.progress
}
})
// Animation loop
const animate = () => {
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
particles.current.forEach(p => {
// Move particles faster based on scroll
p.x += p.vx * (1 + scrollProgress.current * 5)
p.y += p.vy * (1 + scrollProgress.current * 5)
// Wrap around
if (p.x < 0) p.x = canvas.width
if (p.x > canvas.width) p.x = 0
if (p.y < 0) p.y = canvas.height
if (p.y > canvas.height) p.y = 0
// Draw
ctx.fillStyle = `rgba(255, 255, 255, ${0.5 + scrollProgress.current * 0.5})`
ctx.beginPath()
ctx.arc(p.x, p.y, p.size * (1 + scrollProgress.current), 0, Math.PI * 2)
ctx.fill()
})
requestAnimationFrame(animate)
}
animate()
return () => window.removeEventListener('resize', resize)
}, [])
return (
<div id="particle-section" className="h-[300vh]">
<canvas ref={canvasRef} className="fixed inset-0" />
</div>
)
}Image Sequences
Scroll-Driven Image Sequence
'use client'
import { useRef, useEffect, useState } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
export function ImageSequence({ frameCount = 120, basePath }: {
frameCount?: number
basePath: string
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const images = useRef<HTMLImageElement[]>([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
// Preload images
let loadedCount = 0
for (let i = 0; i < frameCount; i++) {
const img = new Image()
img.src = `${basePath}/frame_${i.toString().padStart(4, '0')}.jpg`
img.onload = () => {
loadedCount++
if (loadedCount === frameCount) setLoaded(true)
}
images.current.push(img)
}
}, [frameCount, basePath])
useEffect(() => {
if (!loaded) return
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
const container = containerRef.current!
// Set canvas size
canvas.width = images.current[0].width
canvas.height = images.current[0].height
// Draw first frame
ctx.drawImage(images.current[0], 0, 0)
// Animate frame
const frameObj = { frame: 0 }
gsap.to(frameObj, {
frame: frameCount - 1,
snap: 'frame',
ease: 'none',
scrollTrigger: {
trigger: container,
start: 'top top',
end: 'bottom bottom',
scrub: 0.5,
pin: true,
},
onUpdate: () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(images.current[Math.round(frameObj.frame)], 0, 0)
}
})
}, [loaded, frameCount])
return (
<div ref={containerRef} className="h-[500vh]">
<div className="fixed inset-0 flex items-center justify-center">
{!loaded && <div>Loading frames...</div>}
<canvas
ref={canvasRef}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
)
}SVG Advanced
Path Morphing with Anime.js
'use client'
import { useRef, useEffect } from 'react'
import anime from 'animejs'
export function MorphingSVG() {
const pathRef = useRef<SVGPathElement>(null)
useEffect(() => {
anime({
targets: pathRef.current,
d: [
{ value: 'M50,10 L90,90 L10,90 Z' }, // Triangle
{ value: 'M50,10 A40,40 0 1,1 50,90 A40,40 0 1,1 50,10' }, // Circle
{ value: 'M10,10 L90,10 L90,90 L10,90 Z' }, // Square
],
duration: 3000,
easing: 'easeInOutQuad',
loop: true,
direction: 'alternate',
})
}, [])
return (
<svg viewBox="0 0 100 100" className="w-64 h-64">
<path
ref={pathRef}
d="M50,10 L90,90 L10,90 Z"
fill="none"
stroke="white"
strokeWidth="2"
/>
</svg>
)
}Motion Path Animation
import { MotionPathPlugin } from 'gsap/MotionPathPlugin'
gsap.registerPlugin(MotionPathPlugin)
function MotionPathAnimation() {
const ballRef = useRef<HTMLDivElement>(null)
useGSAP(() => {
gsap.to(ballRef.current, {
motionPath: {
path: '#motion-path',
align: '#motion-path',
alignOrigin: [0.5, 0.5],
autoRotate: true,
},
duration: 5,
ease: 'none',
scrollTrigger: {
trigger: '#motion-container',
start: 'top center',
end: 'bottom center',
scrub: 1,
}
})
})
return (
<div id="motion-container" className="relative h-[200vh]">
<svg className="fixed top-0 left-0 w-full h-screen">
<path
id="motion-path"
d="M100,300 Q400,50 700,300 T1300,300"
fill="none"
stroke="rgba(255,255,255,0.2)"
/>
</svg>
<div
ref={ballRef}
className="fixed w-10 h-10 bg-white rounded-full"
/>
</div>
)
}View Transitions API
Native Page Transitions (Chrome)
// For simple transitions without libraries
'use client'
import { useRouter } from 'next/navigation'
export function TransitionLink({ href, children }: {
href: string
children: React.ReactNode
}) {
const router = useRouter()
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault()
if (!document.startViewTransition) {
router.push(href)
return
}
document.startViewTransition(() => {
router.push(href)
})
}
return (
<a href={href} onClick={handleClick}>
{children}
</a>
)
}/* View Transition CSS */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.5s;
}
::view-transition-old(root) {
animation: fade-out 0.5s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.5s ease-in;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}Named View Transitions
// Give elements view-transition-name for morphing
<div style={{ viewTransitionName: 'hero-image' }}>
<img src="/hero.jpg" />
</div>::view-transition-old(hero-image),
::view-transition-new(hero-image) {
animation-duration: 0.3s;
}Algorithmic & Generative Art
React patterns for mathematical art, fractals, flow fields, and generative visuals using Canvas 2D and p5.js.
Table of Contents
- Fractal Trees
- L-Systems
- Mathematical Curves
- Flow Fields
- Strange Attractors
- Reaction-Diffusion
- Cellular Automata
- Noise Patterns
- Sacred Geometry
---
Fractal Trees
Recursive branching with animated growth.
'use client'
import { useRef, useEffect, useCallback } from 'react'
interface BranchParams {
x: number; y: number; length: number; angle: number; depth: number
maxDepth: number; progress: number
}
export function FractalTree({ maxDepth = 10, branchAngle = 25 }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animRef = useRef<number>(0)
const progressRef = useRef(0)
const drawBranch = useCallback((ctx: CanvasRenderingContext2D, params: BranchParams) => {
const { x, y, length, angle, depth, maxDepth, progress } = params
if (depth > maxDepth || length < 2) return
const depthProgress = Math.max(0, Math.min(1, progress * maxDepth - depth))
if (depthProgress <= 0) return
const endX = x + Math.cos((angle * Math.PI) / 180) * length * depthProgress
const endY = y - Math.sin((angle * Math.PI) / 180) * length * depthProgress
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(endX, endY)
ctx.strokeStyle = `hsl(${120 + depth * 15}, 60%, ${30 + depth * 5}%)`
ctx.lineWidth = Math.max(1, (maxDepth - depth) * 1.5)
ctx.stroke()
const newLength = length * 0.72
const spread = branchAngle + Math.sin(depth * 0.5) * 5
drawBranch(ctx, { x: endX, y: endY, length: newLength, angle: angle + spread, depth: depth + 1, maxDepth, progress })
drawBranch(ctx, { x: endX, y: endY, length: newLength, angle: angle - spread, depth: depth + 1, maxDepth, progress })
}, [branchAngle])
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
canvas.width = canvas.offsetWidth * 2
canvas.height = canvas.offsetHeight * 2
ctx.scale(2, 2)
const animate = () => {
progressRef.current = Math.min(1, progressRef.current + 0.008)
ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight)
drawBranch(ctx, {
x: canvas.offsetWidth / 2, y: canvas.offsetHeight,
length: canvas.offsetHeight * 0.28, angle: 90,
depth: 0, maxDepth, progress: progressRef.current,
})
if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(animRef.current)
}, [maxDepth, drawBranch])
return <canvas ref={canvasRef} className="w-full h-full" />
}L-Systems
Lindenmayer systems with turtle graphics.
'use client'
import { useRef, useEffect } from 'react'
interface LSystemRule { [key: string]: string }
function generateLSystem(axiom: string, rules: LSystemRule, iterations: number): string {
let current = axiom
for (let i = 0; i < iterations; i++) {
current = current.split('').map(c => rules[c] || c).join('')
}
return current
}
interface TurtleState { x: number; y: number; angle: number }
function drawLSystem(
ctx: CanvasRenderingContext2D,
instructions: string,
startX: number, startY: number,
stepLength: number, turnAngle: number
) {
const stack: TurtleState[] = []
let state: TurtleState = { x: startX, y: startY, angle: -90 }
ctx.beginPath()
ctx.moveTo(state.x, state.y)
for (const char of instructions) {
switch (char) {
case 'F': case 'G':
state.x += Math.cos((state.angle * Math.PI) / 180) * stepLength
state.y += Math.sin((state.angle * Math.PI) / 180) * stepLength
ctx.lineTo(state.x, state.y)
break
case '+': state.angle += turnAngle; break
case '-': state.angle -= turnAngle; break
case '[': stack.push({ ...state }); break
case ']':
state = stack.pop()!
ctx.moveTo(state.x, state.y)
break
}
}
ctx.stroke()
}
// Presets
const L_SYSTEM_PRESETS = {
kochSnowflake: { axiom: 'F--F--F', rules: { F: 'F+F--F+F' }, angle: 60, iterations: 4 },
sierpinski: { axiom: 'F-G-G', rules: { F: 'F-G+F+G-F', G: 'GG' }, angle: 120, iterations: 6 },
dragonCurve: { axiom: 'FX', rules: { X: 'X+YF+', Y: '-FX-Y' }, angle: 90, iterations: 12 },
plant: { axiom: 'X', rules: { X: 'F+[[X]-X]-F[-FX]+X', F: 'FF' }, angle: 25, iterations: 6 },
hilbert: { axiom: 'A', rules: { A: '-BF+AFA+FB-', B: '+AF-BFB-FA+' }, angle: 90, iterations: 5 },
} as const
export function LSystemCanvas({ preset = 'plant' }: { preset?: keyof typeof L_SYSTEM_PRESETS }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
canvas.width = canvas.offsetWidth * 2
canvas.height = canvas.offsetHeight * 2
ctx.scale(2, 2)
const { axiom, rules, angle, iterations } = L_SYSTEM_PRESETS[preset]
const instructions = generateLSystem(axiom, rules, iterations)
ctx.strokeStyle = '#4ade80'
ctx.lineWidth = 0.5
const step = preset === 'plant' ? 4 : preset === 'hilbert' ? canvas.offsetWidth / Math.pow(2, iterations) : 3
const startX = preset === 'plant' ? canvas.offsetWidth / 2 : 20
const startY = preset === 'plant' ? canvas.offsetHeight : canvas.offsetHeight - 20
drawLSystem(ctx, instructions, startX, startY, step, angle)
}, [preset])
return <canvas ref={canvasRef} className="w-full h-full bg-gray-950" />
}Mathematical Curves
Parametric curves: Lissajous, polar roses, spirals, superformula.
'use client'
import { useRef, useEffect } from 'react'
type CurveType = 'lissajous' | 'rose' | 'spiral' | 'superformula'
interface CurveParams {
type: CurveType
a?: number; b?: number // Lissajous frequencies / rose petals
m?: number; n1?: number; n2?: number; n3?: number // Superformula
}
function getCurvePoint(t: number, params: CurveParams, scale: number): [number, number] {
const { type, a = 3, b = 4, m = 6, n1 = 1, n2 = 1, n3 = 1 } = params
switch (type) {
case 'lissajous':
return [Math.sin(a * t) * scale, Math.sin(b * t + Math.PI / 4) * scale]
case 'rose': {
const r = Math.cos(a * t) * scale
return [r * Math.cos(t), r * Math.sin(t)]
}
case 'spiral': {
const r = t * scale * 0.02
return [r * Math.cos(t), r * Math.sin(t)]
}
case 'superformula': {
const phi = t
const r1 = Math.pow(Math.abs(Math.cos(m * phi / 4) / 1), n2)
const r2 = Math.pow(Math.abs(Math.sin(m * phi / 4) / 1), n3)
const r = Math.pow(r1 + r2, -1 / n1) * scale
return [r * Math.cos(phi), r * Math.sin(phi)]
}
}
}
export function MathCurve({ type = 'lissajous', ...params }: CurveParams) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animRef = useRef<number>(0)
const tRef = useRef(0)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
canvas.width = canvas.offsetWidth * 2
canvas.height = canvas.offsetHeight * 2
ctx.scale(2, 2)
const cx = canvas.offsetWidth / 2
const cy = canvas.offsetHeight / 2
const scale = Math.min(cx, cy) * 0.7
const animate = () => {
tRef.current += 0.03
const maxT = tRef.current
ctx.fillStyle = 'rgba(0, 0, 0, 0.03)'
ctx.fillRect(0, 0, canvas.offsetWidth, canvas.offsetHeight)
ctx.beginPath()
for (let t = 0; t < Math.min(maxT, Math.PI * 20); t += 0.01) {
const [x, y] = getCurvePoint(t, { type, ...params }, scale)
if (t === 0) ctx.moveTo(cx + x, cy + y)
else ctx.lineTo(cx + x, cy + y)
}
ctx.strokeStyle = `hsl(${(tRef.current * 20) % 360}, 70%, 60%)`
ctx.lineWidth = 1.5
ctx.stroke()
animRef.current = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(animRef.current)
}, [type, params])
return <canvas ref={canvasRef} className="w-full h-full bg-black" />
}Flow Fields
Perlin noise–driven particle system.
'use client'
import { useRef, useEffect } from 'react'
// Simplified Perlin-like noise (use `simplex-noise` package for production)
function noise2D(x: number, y: number): number {
const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
return (n - Math.floor(n)) * 2 - 1
}
function smoothNoise(x: number, y: number, scale: number): number {
const sx = x / scale
const sy = y / scale
const ix = Math.floor(sx)
const iy = Math.floor(sy)
const fx = sx - ix
const fy = sy - iy
const a = noise2D(ix, iy)
const b = noise2D(ix + 1, iy)
const c = noise2D(ix, iy + 1)
const d = noise2D(ix + 1, iy + 1)
const ux = fx * fx * (3 - 2 * fx)
const uy = fy * fy * (3 - 2 * fy)
return a + ux * (b - a) + uy * (c - a) + ux * uy * (a - b - c + d)
}
interface Particle { x: number; y: number; vx: number; vy: number; life: number }
export function FlowField({ particleCount = 2000, noiseScale = 120 }) {
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 particles: Particle[] = Array.from({ length: particleCount }, () => ({
x: Math.random() * w, y: Math.random() * h,
vx: 0, vy: 0, life: Math.random() * 100,
}))
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, w, h)
const animate = () => {
ctx.fillStyle = 'rgba(0, 0, 0, 0.01)'
ctx.fillRect(0, 0, w, h)
time += 0.002
particles.forEach(p => {
const angle = smoothNoise(p.x + time * 50, p.y, noiseScale) * Math.PI * 4
p.vx = Math.cos(angle) * 1.5
p.vy = Math.sin(angle) * 1.5
p.x += p.vx
p.y += p.vy
p.life--
if (p.x < 0 || p.x > w || p.y < 0 || p.y > h || p.life <= 0) {
p.x = Math.random() * w
p.y = Math.random() * h
p.life = 50 + Math.random() * 100
}
const hue = (smoothNoise(p.x, p.y, noiseScale * 2) + 1) * 180
ctx.fillStyle = `hsla(${hue}, 70%, 60%, 0.6)`
ctx.fillRect(p.x, p.y, 1.5, 1.5)
})
animRef.current = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(animRef.current)
}, [particleCount, noiseScale])
return <canvas ref={canvasRef} className="w-full h-full" />
}Strange Attractors
Lorenz and Rössler systems rendered in Canvas.
'use client'
import { useRef, useEffect } from 'react'
type AttractorType = 'lorenz' | 'rossler'
function step(type: AttractorType, x: number, y: number, z: number, dt: number): [number, number, number] {
if (type === 'lorenz') {
const sigma = 10, rho = 28, beta = 8 / 3
return [
x + (sigma * (y - x)) * dt,
y + (x * (rho - z) - y) * dt,
z + (x * y - beta * z) * dt,
]
}
// Rössler
const a = 0.2, b = 0.2, c = 5.7
return [
x + (-y - z) * dt,
y + (x + a * y) * dt,
z + (b + z * (x - c)) * dt,
]
}
export function StrangeAttractor({ type = 'lorenz' }: { type?: AttractorType }) {
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 x = 0.1, y = 0, z = 0
const dt = 0.005
const points: [number, number, number][] = []
const maxPoints = 8000
let frame = 0
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, w, h)
const animate = () => {
for (let i = 0; i < 20; i++) {
;[x, y, z] = step(type, x, y, z, dt)
points.push([x, y, z])
if (points.length > maxPoints) points.shift()
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.02)'
ctx.fillRect(0, 0, w, h)
const rot = frame * 0.003
const scale = type === 'lorenz' ? 6 : 15
const cx = w / 2
const cy = h / 2
ctx.beginPath()
points.forEach(([px, py, pz], i) => {
const rx = px * Math.cos(rot) - pz * Math.sin(rot)
const ry = py
const sx = cx + rx * scale
const sy = cy + ry * scale * (type === 'lorenz' ? -1 : 1)
if (i === 0) ctx.moveTo(sx, sy)
else ctx.lineTo(sx, sy)
})
ctx.strokeStyle = `hsla(${frame % 360}, 80%, 60%, 0.3)`
ctx.lineWidth = 0.5
ctx.stroke()
frame++
animRef.current = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(animRef.current)
}, [type])
return <canvas ref={canvasRef} className="w-full h-full" />
}Reaction-Diffusion
Gray-Scott model for organic patterns.
'use client'
import { useRef, useEffect } from 'react'
export function ReactionDiffusion({ width = 200, height = 200, feed = 0.055, kill = 0.062 }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animRef = useRef<number>(0)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
canvas.width = width
canvas.height = height
// Two chemical concentrations
const gridA = new Float32Array(width * height).fill(1)
const gridB = new Float32Array(width * height).fill(0)
const nextA = new Float32Array(width * height)
const nextB = new Float32Array(width * height)
// Seed center with chemical B
for (let y = height / 2 - 10; y < height / 2 + 10; y++) {
for (let x = width / 2 - 10; x < width / 2 + 10; x++) {
gridB[y * width + x] = 1
}
}
const dA = 1.0, dB = 0.5
const imageData = ctx.createImageData(width, height)
function laplacian(grid: Float32Array, x: number, y: number): number {
const i = y * width + x
let sum = -grid[i]
sum += grid[((y - 1 + height) % height) * width + x] * 0.2
sum += grid[((y + 1) % height) * width + x] * 0.2
sum += grid[y * width + (x - 1 + width) % width] * 0.2
sum += grid[y * width + (x + 1) % width] * 0.2
sum += grid[((y - 1 + height) % height) * width + (x - 1 + width) % width] * 0.05
sum += grid[((y - 1 + height) % height) * width + (x + 1) % width] * 0.05
sum += grid[((y + 1) % height) * width + (x - 1 + width) % width] * 0.05
sum += grid[((y + 1) % height) * width + (x + 1) % width] * 0.05
return sum
}
const animate = () => {
for (let step = 0; step < 5; step++) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x
const a = gridA[i], b = gridB[i]
const abb = a * b * b
nextA[i] = a + (dA * laplacian(gridA, x, y) - abb + feed * (1 - a))
nextB[i] = b + (dB * laplacian(gridB, x, y) + abb - (kill + feed) * b)
nextA[i] = Math.max(0, Math.min(1, nextA[i]))
nextB[i] = Math.max(0, Math.min(1, nextB[i]))
}
}
gridA.set(nextA)
gridB.set(nextB)
}
for (let i = 0; i < width * height; i++) {
const val = Math.floor((1 - gridB[i]) * 255)
const idx = i * 4
imageData.data[idx] = val * 0.2
imageData.data[idx + 1] = val * 0.5
imageData.data[idx + 2] = val
imageData.data[idx + 3] = 255
}
ctx.putImageData(imageData, 0, 0)
animRef.current = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(animRef.current)
}, [width, height, feed, kill])
return (
<canvas
ref={canvasRef}
className="w-full h-full"
style={{ imageRendering: 'pixelated' }}
/>
)
}Cellular Automata
Game of Life and elementary automata as visual patterns.
'use client'
import { useRef, useEffect, useCallback } from 'react'
type AutomatonType = 'gameOfLife' | 'elementary'
export function CellularAutomaton({ type = 'gameOfLife', rule = 110, cellSize = 4 }: {
type?: AutomatonType; rule?: number; cellSize?: number
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animRef = useRef<number>(0)
const stepGameOfLife = useCallback((grid: Uint8Array, cols: number, rows: number) => {
const next = new Uint8Array(grid.length)
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
let neighbors = 0
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue
const nx = (x + dx + cols) % cols
const ny = (y + dy + rows) % rows
neighbors += grid[ny * cols + nx]
}
}
const alive = grid[y * cols + x]
next[y * cols + x] = alive
? (neighbors === 2 || neighbors === 3 ? 1 : 0)
: (neighbors === 3 ? 1 : 0)
}
}
return next
}, [])
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 cols = Math.floor(w / cellSize)
const rows = Math.floor(h / cellSize)
if (type === 'gameOfLife') {
let grid = new Uint8Array(cols * rows)
// Random init
for (let i = 0; i < grid.length; i++) grid[i] = Math.random() > 0.7 ? 1 : 0
const animate = () => {
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, w, h)
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (grid[y * cols + x]) {
ctx.fillStyle = `hsl(${(x + y) * 3}, 70%, 60%)`
ctx.fillRect(x * cellSize, y * cellSize, cellSize - 1, cellSize - 1)
}
}
}
grid = stepGameOfLife(grid, cols, rows)
animRef.current = requestAnimationFrame(animate)
}
animate()
} else {
// Elementary automaton (1D evolving downward)
let row = new Uint8Array(cols)
row[Math.floor(cols / 2)] = 1
let currentRow = 0
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, w, h)
const animate = () => {
if (currentRow >= rows) {
ctx.drawImage(canvas, 0, cellSize * 2, w * 2, h * 2, 0, 0, w, h)
currentRow = rows - 1
}
for (let x = 0; x < cols; x++) {
if (row[x]) {
ctx.fillStyle = `hsl(${currentRow * 2}, 70%, 60%)`
ctx.fillRect(x * cellSize, currentRow * cellSize, cellSize - 1, cellSize - 1)
}
}
const newRow = new Uint8Array(cols)
for (let x = 0; x < cols; x++) {
const left = row[(x - 1 + cols) % cols]
const center = row[x]
const right = row[(x + 1) % cols]
const pattern = (left << 2) | (center << 1) | right
newRow[x] = (rule >> pattern) & 1
}
row = newRow
currentRow++
animRef.current = requestAnimationFrame(animate)
}
animate()
}
return () => cancelAnimationFrame(animRef.current)
}, [type, rule, cellSize, stepGameOfLife])
return <canvas ref={canvasRef} className="w-full h-full" />
}Noise Patterns
Perlin/Simplex noise for generative textures. For production use the simplex-noise package.
'use client'
import { useRef, useEffect } from 'react'
// Install: npm install simplex-noise
import { createNoise3D } from 'simplex-noise'
export function NoiseTexture({ scale = 100, speed = 0.5, colorMode = 'gradient' }: {
scale?: number; speed?: number; colorMode?: 'gradient' | 'contour' | 'domain-warp'
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animRef = useRef<number>(0)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
const w = 300, h = 300
canvas.width = w
canvas.height = h
const noise3D = createNoise3D()
const imageData = ctx.createImageData(w, h)
let t = 0
const animate = () => {
t += speed * 0.01
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let val: number
if (colorMode === 'domain-warp') {
const warpX = noise3D(x / scale, y / scale, t) * 50
const warpY = noise3D(x / scale + 100, y / scale + 100, t) * 50
val = (noise3D((x + warpX) / scale, (y + warpY) / scale, t) + 1) / 2
} else {
val = (noise3D(x / scale, y / scale, t) + 1) / 2
}
const idx = (y * w + x) * 4
if (colorMode === 'contour') {
const line = Math.abs(Math.sin(val * Math.PI * 8)) > 0.95 ? 255 : 0
imageData.data[idx] = line
imageData.data[idx + 1] = line
imageData.data[idx + 2] = line
} else {
const hue = val * 360
// HSL to RGB approximate
const c = 0.6, m = 0.2
imageData.data[idx] = (val * 0.3 + 0.1) * 255
imageData.data[idx + 1] = val * 200
imageData.data[idx + 2] = (1 - val * 0.5) * 255
}
imageData.data[idx + 3] = 255
}
}
ctx.putImageData(imageData, 0, 0)
animRef.current = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(animRef.current)
}, [scale, speed, colorMode])
return (
<canvas
ref={canvasRef}
className="w-full h-full"
style={{ imageRendering: 'pixelated' }}
/>
)
}Sacred Geometry
Golden spiral, Flower of Life, and Metatron's Cube.
'use client'
import { useRef, useEffect } from 'react'
type SacredType = 'golden-spiral' | 'flower-of-life' | 'metatron'
export function SacredGeometry({ type = 'flower-of-life' }: { type?: SacredType }) {
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 cx = w / 2, cy = h / 2
let progress = 0
const drawFlowerOfLife = (p: number) => {
ctx.clearRect(0, 0, w, h)
ctx.strokeStyle = '#c084fc'
ctx.lineWidth = 1
const r = Math.min(w, h) * 0.12
const rings = [
[[0, 0]],
Array.from({ length: 6 }, (_, i) => {
const a = (i * 60 * Math.PI) / 180
return [Math.cos(a) * r, Math.sin(a) * r]
}),
Array.from({ length: 6 }, (_, i) => {
const a = ((i * 60 + 30) * Math.PI) / 180
return [Math.cos(a) * r * Math.sqrt(3), Math.sin(a) * r * Math.sqrt(3)]
}),
]
const allCenters = rings.flat()
const visibleCount = Math.floor(p * allCenters.length)
allCenters.slice(0, visibleCount).forEach(([ox, oy], i) => {
ctx.globalAlpha = Math.min(1, (p * allCenters.length - i) * 0.5)
ctx.beginPath()
ctx.arc(cx + ox, cy + oy, r, 0, Math.PI * 2)
ctx.stroke()
})
ctx.globalAlpha = 1
}
const drawGoldenSpiral = (p: number) => {
ctx.clearRect(0, 0, w, h)
const phi = (1 + Math.sqrt(5)) / 2
const maxAngle = p * Math.PI * 10
ctx.beginPath()
ctx.strokeStyle = '#fbbf24'
ctx.lineWidth = 2
for (let a = 0; a < maxAngle; a += 0.02) {
const r = Math.pow(phi, (a * 2) / Math.PI) * 2
const x = cx + r * Math.cos(a)
const y = cy + r * Math.sin(a)
if (a === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
if (r > Math.max(w, h)) break
}
ctx.stroke()
// Draw golden rectangles
ctx.strokeStyle = 'rgba(251, 191, 36, 0.3)'
let size = 2
let rx = cx, ry = cy
for (let i = 0; i < Math.floor(p * 12); i++) {
ctx.strokeRect(rx - size / 2, ry - size / 2, size, size)
size *= phi
}
}
const drawMetatron = (p: number) => {
ctx.clearRect(0, 0, w, h)
const r = Math.min(w, h) * 0.3
// 13 circles of Metatron's Cube
const centers: [number, number][] = [[0, 0]]
for (let ring = 1; ring <= 2; ring++) {
const count = 6
const dist = r * ring * 0.5
for (let i = 0; i < count; i++) {
const a = ((i * 60 + (ring === 2 ? 30 : 0)) * Math.PI) / 180
centers.push([Math.cos(a) * dist, Math.sin(a) * dist])
}
}
const circleCount = Math.floor(p * centers.length)
// Draw connecting lines
ctx.strokeStyle = 'rgba(96, 165, 250, 0.3)'
ctx.lineWidth = 0.5
const lineProgress = Math.max(0, (p - 0.3) / 0.7)
for (let i = 0; i < centers.length; i++) {
for (let j = i + 1; j < centers.length; j++) {
if (Math.random() < lineProgress) {
ctx.beginPath()
ctx.moveTo(cx + centers[i][0], cy + centers[i][1])
ctx.lineTo(cx + centers[j][0], cy + centers[j][1])
ctx.stroke()
}
}
}
// Draw circles
ctx.strokeStyle = '#60a5fa'
ctx.lineWidth = 1.5
centers.slice(0, circleCount).forEach(([ox, oy]) => {
ctx.beginPath()
ctx.arc(cx + ox, cy + oy, r * 0.25, 0, Math.PI * 2)
ctx.stroke()
})
}
const animate = () => {
progress = Math.min(1, progress + 0.005)
switch (type) {
case 'flower-of-life': drawFlowerOfLife(progress); break
case 'golden-spiral': drawGoldenSpiral(progress); break
case 'metatron': drawMetatron(progress); break
}
if (progress < 1) animRef.current = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(animRef.current)
}, [type])
return <canvas ref={canvasRef} className="w-full h-full bg-gray-950" />
}Performance Tips
- Canvas resolution: Use
devicePixelRatiofor retina, but cap at 2x for performance - Particle count: Keep under 5000 for 60fps, use web workers for heavy computation
- RequestAnimationFrame: Always clean up with
cancelAnimationFrameon unmount - OffscreenCanvas: Use for heavy rendering in web workers
- Float32Array: Use typed arrays for grid-based simulations (reaction-diffusion, automata)
- Batch draw calls: Minimize
beginPath/strokecalls per frame
Anime.js 4.0 React Patterns
Complete Anime.js 4.0 patterns for React. Lightweight alternative for simple animations.
Table of Contents
1. Installation & Setup 2. React Integration 3. Basic Animations 4. Timeline 5. Stagger 6. Scroll Animations 7. Text Animations 8. SVG Animations 9. Draggable 10. v3 to v4 Migration
Installation & Setup
npm install animejsImports (v4 Syntax)
// Named imports (v4)
import {
animate,
createTimeline,
createScope,
createSpring,
createDraggable,
stagger,
svg,
utils,
engine
} from 'animejs'React Integration
Basic Pattern with createScope
'use client'
import { useEffect, useRef } from 'react'
import { animate, createScope } from 'animejs'
export function AnimatedBox() {
const rootRef = useRef<HTMLDivElement>(null)
const scopeRef = useRef<ReturnType<typeof createScope> | null>(null)
useEffect(() => {
// Create scope bound to container
scopeRef.current = createScope({ root: rootRef.current }).add(() => {
// All animations here are scoped to rootRef
animate('.box', {
translateX: 250,
rotate: '1turn',
duration: 800,
ease: 'out(3)',
})
})
// Cleanup on unmount
return () => scopeRef.current?.revert()
}, [])
return (
<div ref={rootRef}>
<div className="box">Animated</div>
</div>
)
}Registering Methods for External Control
'use client'
import { useEffect, useRef } from 'react'
import { animate, createScope } from 'animejs'
export function ControlledAnimation() {
const rootRef = useRef<HTMLDivElement>(null)
const scopeRef = useRef<ReturnType<typeof createScope> | null>(null)
useEffect(() => {
scopeRef.current = createScope({ root: rootRef.current }).add((self) => {
// Register method accessible outside useEffect
self.add('animateBox', () => {
animate('.box', {
scale: [1, 1.2, 1],
duration: 400,
ease: 'out(2)',
})
})
})
return () => scopeRef.current?.revert()
}, [])
const handleClick = () => {
// Call registered method
scopeRef.current?.methods.animateBox()
}
return (
<div ref={rootRef}>
<button onClick={handleClick}>Animate</button>
<div className="box">Click to animate</div>
</div>
)
}Basic Animations
Simple Animation
// v4 syntax: animate(targets, { properties })
animate('.element', {
translateX: 250,
translateY: 100,
rotate: '1turn',
scale: 1.5,
opacity: 0.5,
duration: 1000,
ease: 'out(3)',
})From/To Values
animate('.element', {
translateX: [0, 250], // from 0 to 250
opacity: [0, 1], // from 0 to 1
scale: [0.5, 1], // from 0.5 to 1
duration: 800,
})Keyframes
animate('.element', {
keyframes: [
{ translateX: 0, scale: 1 },
{ translateX: 100, scale: 1.2 },
{ translateX: 200, scale: 1 },
{ translateX: 250, scale: 0.8 },
],
duration: 2000,
ease: 'inOut(2)',
})Property-Specific Parameters
animate('.element', {
translateX: {
to: 250,
duration: 1000,
ease: 'out(4)',
},
rotate: {
to: '1turn',
duration: 1500,
ease: 'inOut(2)',
},
scale: {
to: 1.5,
duration: 800,
delay: 200,
},
})Callbacks
animate('.element', {
translateX: 250,
duration: 1000,
onBegin: () => console.log('Started'),
onUpdate: (anim) => console.log(anim.progress),
onComplete: () => console.log('Done'),
onLoop: () => console.log('Loop'),
}).then(() => {
console.log('Promise resolved')
})Playback Controls
const animation = animate('.element', {
translateX: 250,
autoplay: false,
})
animation.play() // Play forward
animation.pause() // Pause
animation.resume() // Resume in current direction
animation.reverse() // Play backward
animation.restart() // Restart from beginning
animation.seek(500) // Seek to 500ms
animation.reset() // Reset to initial stateTimeline
Basic Timeline
import { createTimeline } from 'animejs'
const tl = createTimeline({
defaults: {
duration: 500,
ease: 'out(3)',
}
})
tl.add('.box-1', { translateX: 250 })
.add('.box-2', { translateX: 250 }, '-=200') // 200ms before previous ends
.add('.box-3', { translateX: 250 }, '+=100') // 100ms after previous endsTimeline in React
'use client'
import { useEffect, useRef } from 'react'
import { createTimeline, createScope } from 'animejs'
export function TimelineAnimation() {
const rootRef = useRef<HTMLDivElement>(null)
const scopeRef = useRef<ReturnType<typeof createScope> | null>(null)
useEffect(() => {
scopeRef.current = createScope({ root: rootRef.current }).add(() => {
const tl = createTimeline({
defaults: { duration: 600, ease: 'out(3)' }
})
tl.add('.title', { opacity: [0, 1], translateY: [30, 0] })
.add('.subtitle', { opacity: [0, 1], translateY: [20, 0] }, '-=400')
.add('.cta', { opacity: [0, 1], scale: [0.9, 1] }, '-=300')
})
return () => scopeRef.current?.revert()
}, [])
return (
<div ref={rootRef}>
<h1 className="title">Title</h1>
<p className="subtitle">Subtitle</p>
<button className="cta">CTA</button>
</div>
)
}Stagger
Basic Stagger
import { stagger } from 'animejs'
animate('.grid-item', {
opacity: [0, 1],
translateY: [50, 0],
delay: stagger(100), // 100ms between each
duration: 600,
})Stagger with Start Value
animate('.item', {
translateX: 250,
delay: stagger(100, { start: 500 }), // Start at 500ms, then +100ms each
})Grid Stagger
animate('.grid-item', {
scale: [0, 1],
delay: stagger(50, {
grid: [4, 4], // 4x4 grid
from: 'center', // Animate from center outward
}),
})Stagger from Index
animate('.item', {
opacity: [0, 1],
delay: stagger(100, {
from: 'first', // 'first', 'last', 'center', or index number
}),
})Value Stagger
animate('.bar', {
scaleY: stagger([0.5, 1]), // Scale from 0.5 to 1 distributed
duration: 800,
})Scroll Animations
ScrollObserver
'use client'
import { useEffect, useRef } from 'react'
import { animate, createScope } from 'animejs'
export function ScrollReveal() {
const rootRef = useRef<HTMLDivElement>(null)
const scopeRef = useRef<ReturnType<typeof createScope> | null>(null)
useEffect(() => {
scopeRef.current = createScope({ root: rootRef.current }).add(() => {
// Using Intersection Observer pattern
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
animate(entry.target, {
opacity: [0, 1],
translateY: [50, 0],
duration: 800,
ease: 'out(3)',
})
observer.unobserve(entry.target)
}
})
},
{ threshold: 0.2 }
)
document.querySelectorAll('.reveal-item').forEach((el) => {
observer.observe(el)
})
})
return () => scopeRef.current?.revert()
}, [])
return (
<div ref={rootRef}>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="reveal-item opacity-0">
Item {i}
</div>
))}
</div>
)
}Text Animations
Character Split Animation
'use client'
import { useEffect, useRef } from 'react'
import { animate, createScope, stagger } from 'animejs'
export function TextReveal({ text }: { text: string }) {
const rootRef = useRef<HTMLDivElement>(null)
const scopeRef = useRef<ReturnType<typeof createScope> | null>(null)
useEffect(() => {
scopeRef.current = createScope({ root: rootRef.current }).add(() => {
animate('.char', {
opacity: [0, 1],
translateY: [50, 0],
rotateX: [-90, 0],
delay: stagger(30),
duration: 600,
ease: 'out(3)',
})
})
return () => scopeRef.current?.revert()
}, [])
return (
<div ref={rootRef} className="overflow-hidden">
{text.split('').map((char, i) => (
<span key={i} className="char inline-block opacity-0">
{char === ' ' ? '\u00A0' : char}
</span>
))}
</div>
)
}Word Animation
export function WordReveal({ text }: { text: string }) {
const rootRef = useRef<HTMLDivElement>(null)
const scopeRef = useRef<ReturnType<typeof createScope> | null>(null)
useEffect(() => {
scopeRef.current = createScope({ root: rootRef.current }).add(() => {
animate('.word', {
opacity: [0, 1],
translateY: ['100%', '0%'],
delay: stagger(80),
duration: 800,
ease: 'out(4)',
})
})
return () => scopeRef.current?.revert()
}, [])
return (
<div ref={rootRef}>
{text.split(' ').map((word, i) => (
<span key={i} className="inline-block overflow-hidden mr-2">
<span className="word inline-block opacity-0">{word}</span>
</span>
))}
</div>
)
}SVG Animations
Path Drawing
import { svg } from 'animejs'
export function DrawSVG() {
const svgRef = useRef<SVGSVGElement>(null)
const scopeRef = useRef<ReturnType<typeof createScope> | null>(null)
useEffect(() => {
scopeRef.current = createScope({ root: svgRef.current }).add(() => {
const drawable = svg.createDrawable('.draw-path')
animate(drawable, {
draw: ['0 0', '0 1'], // From 0% to 100%
duration: 2000,
ease: 'inOut(2)',
})
})
return () => scopeRef.current?.revert()
}, [])
return (
<svg ref={svgRef} viewBox="0 0 100 100">
<path
className="draw-path"
d="M10,50 Q50,10 90,50 T90,90"
fill="none"
stroke="white"
strokeWidth="2"
/>
</svg>
)
}SVG Morphing
import { svg } from 'animejs'
export function MorphSVG() {
const pathRef = useRef<SVGPathElement>(null)
useEffect(() => {
const morph = svg.createMorph(pathRef.current)
animate(morph, {
to: 'M50,10 A40,40 0 1,1 50,90 A40,40 0 1,1 50,10', // Circle
duration: 1000,
ease: 'inOut(2)',
})
}, [])
return (
<svg viewBox="0 0 100 100">
<path ref={pathRef} d="M50,10 L90,90 L10,90 Z" fill="white" />
</svg>
)
}Motion Path
import { svg } from 'animejs'
export function MotionPath() {
const elementRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const motionPath = svg.createMotionPath('#motion-path')
animate(elementRef.current, {
translateX: motionPath.x,
translateY: motionPath.y,
rotate: motionPath.angle,
duration: 3000,
ease: 'linear',
loop: true,
})
}, [])
return (
<>
<svg className="absolute">
<path id="motion-path" d="M0,100 Q250,0 500,100" fill="none" />
</svg>
<div ref={elementRef} className="w-4 h-4 bg-white rounded-full" />
</>
)
}Draggable
Basic Draggable
import { createDraggable } from 'animejs'
export function DraggableBox() {
const boxRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const draggable = createDraggable(boxRef.current, {
trigger: boxRef.current,
releaseEase: 'out(3)',
releaseStiffness: 50,
})
return () => draggable.revert()
}, [])
return (
<div ref={boxRef} className="w-20 h-20 bg-white cursor-grab">
Drag me
</div>
)
}Draggable with Constraints
createDraggable(element, {
container: containerRef.current, // Constrain to container
x: { min: 0, max: 500 }, // X bounds
y: { min: 0, max: 300 }, // Y bounds
snap: { x: 50, y: 50 }, // Snap to grid
})Easing Reference (v4)
Built-in Easings
// v4 easing syntax (no 'ease' prefix)
ease: 'linear'
ease: 'in(2)' // Power in
ease: 'out(2)' // Power out (default)
ease: 'inOut(2)' // Power in-out
ease: 'out(4)' // Stronger ease outSpring Easing
import { createSpring } from 'animejs'
const spring = createSpring({
mass: 1,
stiffness: 100,
damping: 10,
velocity: 0,
})
animate('.element', {
translateX: 250,
ease: spring,
})Custom Easing
// Cubic bezier
ease: 'cubicBezier(0.76, 0, 0.24, 1)'
// Custom function
ease: (t) => t * t // Quadraticv3 to v4 Migration
| v3 | v4 |
|---|---|
anime({ targets, ...props }) | animate(targets, { ...props }) |
easing: 'easeOutQuad' | ease: 'out(2)' |
easing: 'easeInOutCubic' | ease: 'inOut(3)' |
endDelay | loopDelay |
direction: 'reverse' | reversed: true |
direction: 'alternate' | alternate: true |
update callback | onUpdate callback |
begin callback | onBegin callback |
complete callback | onComplete callback |
.finished.then() | .then() |
anime.timeline() | createTimeline() |
anime.stagger() | stagger() |
When to Use Anime.js vs GSAP
| Use Anime.js | Use GSAP |
|---|---|
| Simple animations | Complex scroll-driven |
| Lightweight needs (~17kb) | ScrollTrigger required |
| Quick prototypes | Production timelines |
| SVG morphing | SplitText, MorphSVG |
| Draggable elements | Pin sections |
Sources
Audio Reactive Animations
Tone.js y Web Audio API para experiencias sonoras interactivas estilo Awwwards.
Table of Contents
1. Decision Matrix 2. Tone.js Setup 3. React Integration 4. Synths & Sounds 5. Audio Reactive Visuals 6. Scroll Audio 7. Hover & Click Sounds 8. Web Audio API Native
Decision Matrix
| Necesidad | Herramienta | Por qué |
|---|---|---|
| Sintetizadores/música | Tone.js | API musical completa |
| Efectos simples (clicks) | Web Audio API nativo | Sin dependencias |
| Audio reactivo al scroll | Tone.js + ScrollTrigger | Sync con animaciones |
| Visualizador de audio | Web Audio Analyzer | FFT data |
| Samples/loops | Tone.Sampler | Fácil de usar |
Tone.js Setup
Instalación
npm install toneImportación
import * as Tone from 'tone'
// O importar módulos específicos
import { Synth, FMSynth, Sampler, Transport, Destination } from 'tone'React Integration
Audio Context Requirement
IMPORTANTE: El audio web requiere interacción del usuario para iniciar.
'use client'
import { useState, useCallback } from 'react'
import * as Tone from 'tone'
export function AudioProvider({ children }: { children: React.ReactNode }) {
const [isAudioReady, setIsAudioReady] = useState(false)
const initAudio = useCallback(async () => {
await Tone.start()
setIsAudioReady(true)
console.log('Audio context started')
}, [])
return (
<>
{!isAudioReady && (
<button
onClick={initAudio}
className="fixed bottom-4 right-4 z-50 px-4 py-2 bg-white text-black rounded-full"
>
Enable Sound
</button>
)}
{children}
</>
)
}Custom Hook: useTone
'use client'
import { useRef, useEffect, useCallback, useState } from 'react'
import * as Tone from 'tone'
export function useTone() {
const [isReady, setIsReady] = useState(false)
const start = useCallback(async () => {
if (Tone.context.state !== 'running') {
await Tone.start()
}
setIsReady(true)
}, [])
return { isReady, start, Tone }
}Custom Hook: useSynth
'use client'
import { useRef, useEffect, useMemo } from 'react'
import * as Tone from 'tone'
export function useSynth(type: 'synth' | 'fm' | 'am' | 'membrane' = 'synth') {
const synthRef = useRef<Tone.Synth | Tone.FMSynth | Tone.AMSynth | Tone.MembraneSynth | null>(null)
useEffect(() => {
// Crear synth según tipo
switch (type) {
case 'fm':
synthRef.current = new Tone.FMSynth().toDestination()
break
case 'am':
synthRef.current = new Tone.AMSynth().toDestination()
break
case 'membrane':
synthRef.current = new Tone.MembraneSynth().toDestination()
break
default:
synthRef.current = new Tone.Synth().toDestination()
}
return () => {
synthRef.current?.dispose()
}
}, [type])
const play = (note: string = 'C4', duration: string = '8n') => {
if (Tone.context.state === 'running') {
synthRef.current?.triggerAttackRelease(note, duration)
}
}
return { play, synth: synthRef }
}Synths & Sounds
Tipos de Sintetizadores
'use client'
import { useEffect, useRef } from 'react'
import * as Tone from 'tone'
export function SynthDemo() {
const synthsRef = useRef<{
basic: Tone.Synth | null
fm: Tone.FMSynth | null
am: Tone.AMSynth | null
membrane: Tone.MembraneSynth | null
pluck: Tone.PluckSynth | null
metal: Tone.MetalSynth | null
}>({
basic: null,
fm: null,
am: null,
membrane: null,
pluck: null,
metal: null,
})
useEffect(() => {
// Synth básico (saw/sine/square wave)
synthsRef.current.basic = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: { attack: 0.01, decay: 0.2, sustain: 0.5, release: 0.8 },
}).toDestination()
// FM Synth (metallic, bells)
synthsRef.current.fm = new Tone.FMSynth({
modulationIndex: 10,
harmonicity: 3,
}).toDestination()
// AM Synth (tremolo effect)
synthsRef.current.am = new Tone.AMSynth().toDestination()
// Membrane Synth (kicks, drums)
synthsRef.current.membrane = new Tone.MembraneSynth({
pitchDecay: 0.05,
octaves: 4,
}).toDestination()
// Pluck Synth (guitar-like)
synthsRef.current.pluck = new Tone.PluckSynth().toDestination()
// Metal Synth (hi-hats, cymbals)
synthsRef.current.metal = new Tone.MetalSynth({
frequency: 200,
envelope: { attack: 0.001, decay: 0.1, release: 0.1 },
harmonicity: 5.1,
modulationIndex: 32,
resonance: 4000,
octaves: 1.5,
}).toDestination()
return () => {
Object.values(synthsRef.current).forEach((s) => s?.dispose())
}
}, [])
const playNote = async (type: keyof typeof synthsRef.current, note = 'C4') => {
await Tone.start()
synthsRef.current[type]?.triggerAttackRelease(note, '8n')
}
return (
<div className="flex gap-2">
<button onClick={() => playNote('basic')}>Basic</button>
<button onClick={() => playNote('fm')}>FM</button>
<button onClick={() => playNote('am')}>AM</button>
<button onClick={() => playNote('membrane', 'C2')}>Kick</button>
<button onClick={() => playNote('pluck')}>Pluck</button>
<button onClick={() => playNote('metal')}>Metal</button>
</div>
)
}Efectos de Audio
// Crear cadena de efectos
const reverb = new Tone.Reverb({ decay: 2, wet: 0.5 }).toDestination()
const delay = new Tone.FeedbackDelay('8n', 0.5).connect(reverb)
const distortion = new Tone.Distortion(0.4).connect(delay)
const synth = new Tone.Synth().connect(distortion)
// O conectar en serie
synth.chain(distortion, delay, reverb, Tone.Destination)Sampler (Samples de Audio)
'use client'
import { useEffect, useRef } from 'react'
import * as Tone from 'tone'
export function useSampler(samples: Record<string, string>) {
const samplerRef = useRef<Tone.Sampler | null>(null)
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
samplerRef.current = new Tone.Sampler({
urls: samples,
onload: () => setIsLoaded(true),
}).toDestination()
return () => samplerRef.current?.dispose()
}, [samples])
const play = (note: string, duration?: string) => {
if (isLoaded && Tone.context.state === 'running') {
samplerRef.current?.triggerAttackRelease(note, duration || '8n')
}
}
return { play, isLoaded }
}
// Uso
const { play, isLoaded } = useSampler({
C4: '/sounds/piano-c4.mp3',
E4: '/sounds/piano-e4.mp3',
G4: '/sounds/piano-g4.mp3',
})Audio Reactive Visuals
Analyzer + Canvas
'use client'
import { useRef, useEffect } from 'react'
import * as Tone from 'tone'
export function AudioVisualizer() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const analyzerRef = useRef<Tone.Analyser | null>(null)
const playerRef = useRef<Tone.Player | null>(null)
useEffect(() => {
// Crear analyzer
analyzerRef.current = new Tone.Analyser('waveform', 256)
// Player conectado al analyzer
playerRef.current = new Tone.Player({
url: '/audio/track.mp3',
loop: true,
}).connect(analyzerRef.current)
analyzerRef.current.toDestination()
// Animation loop
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
function draw() {
const values = analyzerRef.current?.getValue() as Float32Array
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.lineWidth = 2
ctx.strokeStyle = '#00f5ff'
ctx.beginPath()
const sliceWidth = canvas.width / values.length
let x = 0
for (let i = 0; i < values.length; i++) {
const v = (values[i] + 1) / 2 // Normalize -1 to 1 → 0 to 1
const y = v * canvas.height
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
x += sliceWidth
}
ctx.stroke()
requestAnimationFrame(draw)
}
draw()
return () => {
playerRef.current?.dispose()
analyzerRef.current?.dispose()
}
}, [])
const togglePlay = async () => {
await Tone.start()
if (playerRef.current?.state === 'started') {
playerRef.current.stop()
} else {
playerRef.current?.start()
}
}
return (
<div>
<canvas ref={canvasRef} width={600} height={200} className="bg-black" />
<button onClick={togglePlay}>Play/Pause</button>
</div>
)
}FFT Bars Visualizer
'use client'
import { useRef, useEffect } from 'react'
import * as Tone from 'tone'
export function FFTVisualizer() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const fftRef = useRef<Tone.FFT | null>(null)
useEffect(() => {
fftRef.current = new Tone.FFT(64)
// Conectar micrófono o audio
const mic = new Tone.UserMedia().connect(fftRef.current)
mic.open()
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
const barCount = 64
function draw() {
const values = fftRef.current?.getValue() as Float32Array
ctx.fillStyle = '#0a0a0a'
ctx.fillRect(0, 0, canvas.width, canvas.height)
const barWidth = canvas.width / barCount
const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4']
for (let i = 0; i < barCount; i++) {
// FFT values are in dB, normalize to 0-1
const value = (values[i] + 140) / 140
const barHeight = value * canvas.height
ctx.fillStyle = colors[i % colors.length]
ctx.fillRect(
i * barWidth,
canvas.height - barHeight,
barWidth - 2,
barHeight
)
}
requestAnimationFrame(draw)
}
draw()
return () => {
mic.close()
fftRef.current?.dispose()
}
}, [])
return <canvas ref={canvasRef} width={600} height={300} className="bg-black" />
}Scroll Audio
Audio Reactivo al Scroll con GSAP
'use client'
import { useRef, useEffect } from 'react'
import * as Tone from 'tone'
import { gsap, ScrollTrigger, useGSAP } from '@/lib/gsap'
export function ScrollAudio() {
const containerRef = useRef<HTMLDivElement>(null)
const synthRef = useRef<Tone.Synth | null>(null)
const filterRef = useRef<Tone.Filter | null>(null)
useEffect(() => {
filterRef.current = new Tone.Filter(200, 'lowpass').toDestination()
synthRef.current = new Tone.Synth({
oscillator: { type: 'sawtooth' },
}).connect(filterRef.current)
return () => {
synthRef.current?.dispose()
filterRef.current?.dispose()
}
}, [])
useGSAP(() => {
// Cambiar frecuencia del filtro con scroll
ScrollTrigger.create({
trigger: containerRef.current,
start: 'top top',
end: 'bottom bottom',
onUpdate: (self) => {
// Mapear progreso a frecuencia (200Hz - 5000Hz)
const freq = 200 + self.progress * 4800
filterRef.current?.frequency.rampTo(freq, 0.1)
},
})
// Trigger notas en secciones específicas
const sections = gsap.utils.toArray<HTMLElement>('.audio-section')
const notes = ['C4', 'E4', 'G4', 'B4']
sections.forEach((section, i) => {
ScrollTrigger.create({
trigger: section,
start: 'top center',
onEnter: async () => {
await Tone.start()
synthRef.current?.triggerAttackRelease(notes[i % notes.length], '8n')
},
})
})
}, { scope: containerRef })
return (
<div ref={containerRef} className="h-[400vh]">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="audio-section h-screen flex items-center justify-center">
Section {i}
</div>
))}
</div>
)
}Pitch Basado en Scroll Progress
'use client'
import { useRef, useEffect } from 'react'
import * as Tone from 'tone'
export function ScrollPitch() {
const oscillatorRef = useRef<Tone.Oscillator | null>(null)
const isPlayingRef = useRef(false)
useEffect(() => {
oscillatorRef.current = new Tone.Oscillator({
frequency: 220,
type: 'sine',
}).toDestination()
const handleScroll = () => {
const scrollPercent = window.scrollY / (document.body.scrollHeight - window.innerHeight)
// Mapear scroll a frecuencia (110Hz - 880Hz = 2 octavas)
const freq = 110 * Math.pow(2, scrollPercent * 2)
oscillatorRef.current?.frequency.rampTo(freq, 0.05)
}
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
oscillatorRef.current?.dispose()
}
}, [])
const toggleSound = async () => {
await Tone.start()
if (isPlayingRef.current) {
oscillatorRef.current?.stop()
} else {
oscillatorRef.current?.start()
}
isPlayingRef.current = !isPlayingRef.current
}
return (
<button onClick={toggleSound} className="fixed bottom-4 right-4">
Toggle Scroll Sound
</button>
)
}Hover & Click Sounds
Hook para UI Sounds
'use client'
import { useRef, useEffect, useCallback } from 'react'
import * as Tone from 'tone'
const UI_SOUNDS = {
hover: { note: 'G5', duration: '32n', synth: 'pluck' },
click: { note: 'C4', duration: '16n', synth: 'membrane' },
success: { note: 'C5', duration: '8n', synth: 'fm' },
error: { note: 'A2', duration: '4n', synth: 'membrane' },
}
export function useUISound() {
const synthsRef = useRef<{
pluck: Tone.PluckSynth | null
membrane: Tone.MembraneSynth | null
fm: Tone.FMSynth | null
}>({ pluck: null, membrane: null, fm: null })
useEffect(() => {
synthsRef.current.pluck = new Tone.PluckSynth().toDestination()
synthsRef.current.membrane = new Tone.MembraneSynth().toDestination()
synthsRef.current.fm = new Tone.FMSynth().toDestination()
// Reducir volumen para UI sounds
Object.values(synthsRef.current).forEach((s) => {
if (s) s.volume.value = -12
})
return () => {
Object.values(synthsRef.current).forEach((s) => s?.dispose())
}
}, [])
const play = useCallback(async (type: keyof typeof UI_SOUNDS) => {
if (Tone.context.state !== 'running') return
const config = UI_SOUNDS[type]
const synth = synthsRef.current[config.synth as keyof typeof synthsRef.current]
synth?.triggerAttackRelease(config.note, config.duration)
}, [])
return { play }
}
// Uso en componente
export function SoundButton({ children }: { children: React.ReactNode }) {
const { play } = useUISound()
return (
<button
onMouseEnter={() => play('hover')}
onClick={() => play('click')}
className="px-4 py-2 bg-white text-black rounded"
>
{children}
</button>
)
}Magnetic Button con Sonido
'use client'
import { useRef, useState } from 'react'
import { motion } from 'motion/react'
import * as Tone from 'tone'
export function MagneticSoundButton({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLButtonElement>(null)
const [position, setPosition] = useState({ x: 0, y: 0 })
const synthRef = useRef<Tone.PluckSynth | null>(null)
// Inicializar synth
useState(() => {
synthRef.current = new Tone.PluckSynth().toDestination()
synthRef.current.volume.value = -15
})
const handleMouse = async (e: React.MouseEvent) => {
const { left, top, width, height } = ref.current!.getBoundingClientRect()
const x = (e.clientX - left - width / 2) * 0.3
const y = (e.clientY - top - height / 2) * 0.3
setPosition({ x, y })
// Pitch basado en posición
await Tone.start()
const note = Math.round(60 + (x / 50) * 12) // MIDI note
const freq = Tone.Frequency(note, 'midi').toFrequency()
synthRef.current?.triggerAttackRelease(freq, '32n')
}
return (
<motion.button
ref={ref}
onMouseMove={handleMouse}
onMouseLeave={() => setPosition({ x: 0, y: 0 })}
animate={position}
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
className="px-8 py-4 bg-white text-black rounded-full"
>
{children}
</motion.button>
)
}Web Audio API Native
Sin Tone.js (Lightweight)
'use client'
import { useRef, useCallback } from 'react'
export function useNativeAudio() {
const contextRef = useRef<AudioContext | null>(null)
const getContext = useCallback(() => {
if (!contextRef.current) {
contextRef.current = new AudioContext()
}
return contextRef.current
}, [])
const playTone = useCallback((frequency: number, duration: number = 0.1) => {
const ctx = getContext()
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
oscillator.frequency.value = frequency
oscillator.type = 'sine'
gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
oscillator.start(ctx.currentTime)
oscillator.stop(ctx.currentTime + duration)
}, [getContext])
const playClick = useCallback(() => playTone(800, 0.05), [playTone])
const playHover = useCallback(() => playTone(1200, 0.03), [playTone])
return { playTone, playClick, playHover }
}Best Practices
1. Siempre Esperar Interacción del Usuario
// El audio NO funciona sin interacción
document.addEventListener('click', async () => {
await Tone.start()
}, { once: true })2. Dispose de Recursos
useEffect(() => {
const synth = new Tone.Synth().toDestination()
return () => synth.dispose() // Importante!
}, [])3. Volumen Apropiado para UI
synth.volume.value = -12 // -12dB para efectos sutiles4. Respetar Preferencias del Usuario
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
const isMuted = localStorage.getItem('audio-muted') === 'true'Recursos
- Tone.js
- Tone.js GitHub Wiki
- Web Audio API MDN
- Reactronica - React + Tone.js
Design Philosophy
Guidelines for brutalist, minimalist, and abstract design styles — and how to mix them.
Table of Contents
---
Digital Brutalism
Principles: Raw, honest, no polish. Exposed structure. Function as aesthetic. Intentional discomfort.
Motion Patterns
- Hard cuts: No easing,
ease: 'none'orduration: 0. Instant state changes. - Jarring transitions:
steps()easing, abrupt position jumps, deliberate jank. - No smooth scroll: Native scroll only. Disable Lenis for brutalist sections.
- Aggressive stagger: Large stagger delays (0.3-0.5s), non-uniform timing.
- Oversized motion: Elements moving 200-400% of viewport. No subtlety.
Typography
- Monospace only:
font-family: monospaceor specific like'JetBrains Mono','Space Mono' - Giant sizes: 15-30vw for hero text
- Leading crushed:
line-height: 0.8to0.9 - Overlap: Negative margins, elements bleeding into each other
- Mixed scales: 12px next to 200px in the same view
- ALL CAPS or extreme case mixing
Layout
- Broken grids: Asymmetric, overlapping, bleeding off-screen
- Visible borders:
border: 2px solideverywhere - Raw backgrounds: Solid black, white, or single accent
- No border-radius: Sharp corners only
Animation Code Pattern
// Brutalist: instant, hard, no easing
gsap.to(el, { x: 500, duration: 0, ease: 'none' }) // teleport
gsap.to(el, { rotation: 90, duration: 0.1, ease: 'steps(3)' }) // stepped
gsap.to(el, { scale: 3, duration: 0.05 }) // jarring snap---
Minimalism
Principles: Less is more. Every element serves a purpose. Motion communicates, never decorates.
Motion Patterns
- Ease-out smooth:
power2.outor[0.16, 1, 0.3, 1]. Never linear, never bouncy. - Subtle scale: Max 1.02-1.05 scale changes. Barely perceptible.
- Opacity only: Many transitions need nothing more than
opacity: 0 → 1. - Long durations: 800ms-1500ms for primary, 300-500ms for secondary.
- Single property: Animate one property at a time. Never x + y + scale + opacity simultaneously.
Typography
- Sans-serif: Clean, geometric.
'Inter','Helvetica Neue','Neue Haas Grotesk' - Light weights: 200-400 for body, 500-600 for emphasis (never 900)
- Generous tracking:
letter-spacing: 0.1-0.3emfor headings - Generous leading:
line-height: 1.6-1.8 - Restrained sizes: Max 3-4 sizes in the entire page
Layout
- Abundant whitespace: 40-60% of viewport should be empty
- Strict grid: 12-column or 8-column, always aligned
- No decorative elements: If it doesn't inform, remove it
- Monochromatic or 2-color max
Animation Code Pattern
// Minimal: smooth, purposeful, restrained
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
/>
// Subtle hover
<motion.a whileHover={{ opacity: 0.6 }} transition={{ duration: 0.3 }} />---
Abstract / Generative
Principles: Non-representational beauty. Mathematics as aesthetics. Organic through algorithmic means.
Motion Patterns
- Noise-driven: Perlin/Simplex noise for position, scale, rotation. Never predictable.
- Parametric motion: Sine/cosine combinations for organic loops.
- Infinite loops:
repeat: -1. Art that never settles. - Emergent behavior: Simple rules → complex visuals (flocking, cellular automata).
- Slow evolution: Changes happen over 10-60 seconds. Patience rewarded.
Visual Language
- Shapes: Circles, flowing curves, particle systems. Avoid rectangles.
- Color: Gradients, hue rotation, noise-mapped palettes.
- Texture: Grain, noise overlays, displacement maps.
- Scale: Micro patterns that reveal macro structures.
Animation Code Pattern
// Abstract: noise-driven, parametric, continuous
const animate = () => {
time += 0.005
elements.forEach((el, i) => {
const nx = noise3D(i * 0.1, 0, time) * amplitude
const ny = noise3D(0, i * 0.1, time) * amplitude
gsap.set(el, { x: nx, y: ny, rotation: nx * 0.5 })
})
requestAnimationFrame(animate)
}---
Mixing Styles
Neo-Brutalism (Brutalist + Minimal)
The currently trending style. Takes brutalist rawness and pairs with minimal restraint.
- What to keep from Brutalism: Bold typography, visible borders, monospace, high contrast
- What to keep from Minimal: Purposeful whitespace, smooth-ish easing (
power1.out), restraint in motion - How it looks: Large mono headings, clean grid with thick borders, black + white + one bright accent, subtle animations on bold elements
- Motion recipe: Short durations (200-400ms),
power1.outeasing, opacity transitions, no bounce
// Neo-brutalist card
<motion.div
className="border-2 border-black bg-[#BAFF39] p-6 font-mono"
whileHover={{ y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<h3 className="text-2xl font-black uppercase">Title</h3>
</motion.div>Generative Minimal (Abstract + Minimal)
Subtle algorithmic beauty with minimalist discipline.
- What to keep from Abstract: Noise-driven motion, particle systems, parametric shapes
- What to keep from Minimal: Restraint, monochrome, negative space, slow transitions
- How it looks: Single-color generative backgrounds, subtle noise displacement, barely-there particles
- Motion recipe: Very slow (15-60s cycles), low amplitude, opacity < 0.3
Controlled Chaos (Brutalist + Abstract)
Algorithmic art with raw presentation.
- What to keep from Brutalism: Hard edges, high contrast, aggressive scale, monospace labels
- What to keep from Abstract: Generative geometry, noise, mathematical patterns
- How it looks: Full-screen fractal with mono text overlaid, glitch effects on generative backgrounds, data visualization with brutalist typography
- Motion recipe: Mix of instant and slow — background evolves slowly, UI snaps
---
Color Palettes
Brutalist
| Name | Hex | Use |
|---|---|---|
| Pure Black | #000000 | Background, text |
| Pure White | #FFFFFF | Background, text |
| Acid Green | #BAFF39 | Accent |
| Electric Blue | #0000FF | Accent alternative |
| Warning Red | #FF0000 | Accent alternative |
Minimalist
| Name | Hex | Use |
|---|---|---|
| Off White | #FAFAF9 | Background |
| Warm Gray | #A8A29E | Secondary text |
| Charcoal | #1C1917 | Primary text |
| Stone | #E7E5E4 | Borders, dividers |
| Subtle Accent | #D4D4D8 | Hover states |
Abstract / Generative
| Name | Hex | Use |
|---|---|---|
| Deep Space | #0A0A0F | Background |
| Nebula Purple | #7C3AED | Primary |
| Plasma Cyan | #06B6D4 | Secondary |
| Solar Gold | #F59E0B | Accent |
| Void Gray | #1E1E2E | Surfaces |
Neo-Brutalist
| Name | Hex | Use |
|---|---|---|
| Black | #000000 | Borders, text |
| Cream | #FEF3C7 | Background |
| Lime | #BAFF39 | Primary accent |
| Peach | #FECACA | Secondary accent |
| Lavender | #DDD6FE | Tertiary accent |
Retro Fantastical (Wonka / Oz / PS2 / MTV)
Saturated, whimsical, nostalgic. Inspired by 1971 Willy Wonka's candy psychedelia, Wizard of Oz technicolor, PS2-era warm gradients, and MTV Y2K maximalism.
| Name | Hex | Use |
|---|---|---|
| Wonka Purple | #7B2D8E | Primary, headers, hero backgrounds |
| Chocolate | #5C3317 | Rich warm backgrounds, borders |
| Emerald City | #2D8E57 | Accents, CTAs, success states |
| Ruby Slipper | #C62828 | Highlights, hover states |
| Yellow Brick | #E8A317 | Gold accents, badges, links |
| Candy Pink | #E84393 | Secondary accent, MTV energy |
| MTV Lime | #A3E635 | Neon pop, interactive elements |
| PS2 Sky | #5B9BD5 | Soft backgrounds, cards |
| Sepia Warm | #D4A574 | Nostalgic overlays, text secondary |
| Technicolor Cyan | #00BCD4 | Splash color, borders |
| Chrome Silver | #C0C0C0 | Y2K metallic, disabled states |
| Deep Velvet | #1A0A2E | Dark mode background |
Usage tips:
- Pair Deep Velvet + Wonka Purple + Yellow Brick for Wonka vibes
- Emerald City + Ruby Slipper + Yellow Brick for Oz palette
- PS2 Sky + Sepia Warm + Chocolate for nostalgic warmth
- Candy Pink + MTV Lime + Chrome Silver for Y2K energy
- Mix all freely for maximalist retro-fantastical compositions
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
- Interactive Tangram
- Tessellations
- Penrose Tiles
- Polyominoes
- Geometric Transformations
- Dissection Puzzle Framework
---
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 |
Related skills
How it compares
Choose awwwards-animations for multi-library scroll and physics showcases; choose motion-framer when you only need declarative Motion component patterns.
FAQ
Which animation libraries does awwwards-animations cover?
awwwards-animations documents 10+ animation libraries with React best practices, including GSAP with ScrollTrigger, Motion, and Lenis for smooth scroll, plus patterns for parallax, pinned sections, and horizontal scroll.
Does awwwards-animations target performance?
awwwards-animations emphasizes 60fps patterns, proper React hook cleanup, and TypeScript-ready components so production sites stay performant while delivering Awwwards-level motion.
Is Awwwards Animations safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.