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)
download all my books for free
- javascript handbook
- typescript handbook
- css handbook
- node.js handbook
- astro handbook
- html handbook
- next.js pages router handbook
- alpine.js handbook
- htmx handbook
- react handbook
- sql handbook
- git cheat sheet
- laravel handbook
- express handbook
- swift handbook
- go handbook
- php handbook
- python handbook
- cli handbook
- c handbook
subscribe to my newsletter to get them
Terms: by subscribing to the newsletter you agree the following terms and conditions and privacy policy. The aim of the newsletter is to keep you up to date about new tutorials, new book releases or courses organized by Flavio. If you wish to unsubscribe from the newsletter, you can click the unsubscribe link that's present at the bottom of each email, anytime. I will not communicate/spread/publish or otherwise give away your address. Your email address is the only personal information collected, and it's only collected for the primary purpose of keeping you informed through the newsletter. It's stored in a secure server based in the EU. You can contact Flavio by emailing flavio@flaviocopes.com. These terms and conditions are governed by the laws in force in Italy and you unconditionally submit to the jurisdiction of the courts of Italy.