Adding React Framer Motion animations to an Astro site
While working on the final touches of my Solopreneur Masterclass website, I was browsing some random stuff and found the Aceternity UI site, and stumbled upon this canvas-based animation made with Framer Motion.
I thought this was pretty cool, and said “let’s add it to my site”, built with Astro, which looked like this:
Clean, but maybe a bit boring.
So here’s what I did.
First I installed Astro’s official React integration
npx astro add react
then I installed a couple libs that were needed by the animation, as instructed on https://ui.aceternity.com/components/vortex:
npm i framer-motion clsx tailwind-merge simplex-noise
Then I made a components/Vortex.tsx
file following the instructions:
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
import React, { useEffect, useRef } from 'react'
import { createNoise3D } from 'simplex-noise'
import { motion } from 'framer-motion'
interface VortexProps {
children?: any
className?: string
containerClassName?: string
particleCount?: number
rangeY?: number
baseHue?: number
baseSpeed?: number
rangeSpeed?: number
baseRadius?: number
rangeRadius?: number
backgroundColor?: string
}
export const Vortex = (props: VortexProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef(null)
const particleCount = props.particleCount || 700
const particlePropCount = 9
const particlePropsLength = particleCount * particlePropCount
const rangeY = props.rangeY || 100
const baseTTL = 50
const rangeTTL = 150
const baseSpeed = props.baseSpeed || 0.0
const rangeSpeed = props.rangeSpeed || 1.5
const baseRadius = props.baseRadius || 1
const rangeRadius = props.rangeRadius || 2
const baseHue = props.baseHue || 220
const rangeHue = 100
const noiseSteps = 3
const xOff = 0.00125
const yOff = 0.00125
const zOff = 0.0005
const backgroundColor = props.backgroundColor || '#000000'
let tick = 0
const noise3D = createNoise3D()
let particleProps = new Float32Array(particlePropsLength)
let center: [number, number] = [0, 0]
const HALF_PI: number = 0.5 * Math.PI
const TAU: number = 2 * Math.PI
const TO_RAD: number = Math.PI / 180
const rand = (n: number): number => n * Math.random()
const randRange = (n: number): number => n - rand(2 * n)
const fadeInOut = (t: number, m: number): number => {
let hm = 0.5 * m
return Math.abs(((t + hm) % m) - hm) / hm
}
const lerp = (n1: number, n2: number, speed: number): number =>
(1 - speed) * n1 + speed * n2
const setup = () => {
const canvas = canvasRef.current
const container = containerRef.current
if (canvas && container) {
const ctx = canvas.getContext('2d')
if (ctx) {
resize(canvas, ctx)
initParticles()
draw(canvas, ctx)
}
}
}
const initParticles = () => {
tick = 0
// simplex = new SimplexNoise();
particleProps = new Float32Array(particlePropsLength)
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
initParticle(i)
}
}
const initParticle = (i: number) => {
const canvas = canvasRef.current
if (!canvas) return
let x, y, vx, vy, life, ttl, speed, radius, hue
x = rand(canvas.width)
y = center[1] + randRange(rangeY)
vx = 0
vy = 0
life = 0
ttl = baseTTL + rand(rangeTTL)
speed = baseSpeed + rand(rangeSpeed)
radius = baseRadius + rand(rangeRadius)
hue = baseHue + rand(rangeHue)
particleProps.set([x, y, vx, vy, life, ttl, speed, radius, hue], i)
}
const draw = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
tick++
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = backgroundColor
ctx.fillRect(0, 0, canvas.width, canvas.height)
drawParticles(ctx)
renderGlow(canvas, ctx)
renderToScreen(canvas, ctx)
window.requestAnimationFrame(() => draw(canvas, ctx))
}
const drawParticles = (ctx: CanvasRenderingContext2D) => {
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
updateParticle(i, ctx)
}
}
const updateParticle = (i: number, ctx: CanvasRenderingContext2D) => {
const canvas = canvasRef.current
if (!canvas) return
let i2 = 1 + i,
i3 = 2 + i,
i4 = 3 + i,
i5 = 4 + i,
i6 = 5 + i,
i7 = 6 + i,
i8 = 7 + i,
i9 = 8 + i
let n, x, y, vx, vy, life, ttl, speed, x2, y2, radius, hue
x = particleProps[i]
y = particleProps[i2]
n = noise3D(x * xOff, y * yOff, tick * zOff) * noiseSteps * TAU
vx = lerp(particleProps[i3], Math.cos(n), 0.5)
vy = lerp(particleProps[i4], Math.sin(n), 0.5)
life = particleProps[i5]
ttl = particleProps[i6]
speed = particleProps[i7]
x2 = x + vx * speed
y2 = y + vy * speed
radius = particleProps[i8]
hue = particleProps[i9]
drawParticle(x, y, x2, y2, life, ttl, radius, hue, ctx)
life++
particleProps[i] = x2
particleProps[i2] = y2
particleProps[i3] = vx
particleProps[i4] = vy
particleProps[i5] = life
;(checkBounds(x, y, canvas) || life > ttl) && initParticle(i)
}
const drawParticle = (
x: number,
y: number,
x2: number,
y2: number,
life: number,
ttl: number,
radius: number,
hue: number,
ctx: CanvasRenderingContext2D
) => {
ctx.save()
ctx.lineCap = 'round'
ctx.lineWidth = radius
ctx.strokeStyle = `hsla(${hue},100%,60%,${fadeInOut(life, ttl)})`
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(x2, y2)
ctx.stroke()
ctx.closePath()
ctx.restore()
}
const checkBounds = (x: number, y: number, canvas: HTMLCanvasElement) => {
return x > canvas.width || x < 0 || y > canvas.height || y < 0
}
const resize = (
canvas: HTMLCanvasElement,
ctx?: CanvasRenderingContext2D
) => {
const { innerWidth, innerHeight } = window
canvas.width = innerWidth
canvas.height = innerHeight
center[0] = 0.5 * canvas.width
center[1] = 0.5 * canvas.height
}
const renderGlow = (
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D
) => {
ctx.save()
ctx.filter = 'blur(8px) brightness(200%)'
ctx.globalCompositeOperation = 'lighter'
ctx.drawImage(canvas, 0, 0)
ctx.restore()
ctx.save()
ctx.filter = 'blur(4px) brightness(200%)'
ctx.globalCompositeOperation = 'lighter'
ctx.drawImage(canvas, 0, 0)
ctx.restore()
}
const renderToScreen = (
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D
) => {
ctx.save()
ctx.globalCompositeOperation = 'lighter'
ctx.drawImage(canvas, 0, 0)
ctx.restore()
}
useEffect(() => {
setup()
window.addEventListener('resize', () => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (canvas && ctx) {
resize(canvas, ctx)
}
})
}, [])
return (
<div className={cn('relative h-full w-full', props.containerClassName)}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
ref={containerRef}
className='absolute h-full w-full inset-0 z-0 bg-transparent flex items-center justify-center'>
<canvas ref={canvasRef}></canvas>
</motion.div>
<div className={cn('relative z-10', props.className)}>
{props.children}
</div>
</div>
)
}
Finally I included this on my page, tweaking some of the defaults:
--
//...
import { Vortex } from '../components/Vortex'
---
//...
<Vortex
particleCount={300}
rangeY={200}
rangeSpeed={0.3}
baseRadius={2}
rangeRadius={4}
baseHue={320}
client:load>
<Header />
</Vortex>
//...
The only thing I had to do was add Astro’s client:load
directive to tell it to hydrate the React component client-side (otherwise by default it’s rendered on the server and shipped as HTML only).
This made Astro load React and Framer Motion client-side, and things worked:
The bundle size surprisingly didn’t increase much.
It was 1.1MB of transferred resources with cache disabled (mostly due to Google’s ReCaptcha scripts I need to avoid spam in a form).
Jumped to 1.2MB after loading React’s runtime and the JS needed to render the animation. Pretty cool.
By the way if you’re interested in how canvas works, I wrote a Canvas API tutorial a few years ago (still 100% valid as good Web tech that is a standard and never changes)
→ I wrote 17 books to help you become a better developer, download them all at $0 cost by joining my newsletter
→ JOIN MY CODING BOOTCAMP, an amazing cohort course that will be a huge step up in your coding career - covering React, Next.js - next edition February 2025