Adding React Framer Motion animations to an Astro site

By

Learn how to add a React Framer Motion animation to an Astro site, installing the React integration and using the client:load directive to hydrate it.

~~~

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.

Aceternity UI Vortex animation showing swirling blue particles on a dark background

I thought this was pretty cool, and said “let’s add it to my site”, built with Astro, which looked like this:

Original Astro site with clean, minimal design before adding animations

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 of libs the animation needed, as instructed on https://ui.aceternity.com/components/vortex:

npm i framer-motion clsx tailwind-merge simplex-noise

Framer Motion has since been renamed Motion. For React, the package is now motion instead of framer-motion, and you import it from motion/react. The components work the same way.

The code below is what I used at the time, with framer-motion. To switch a project to the new package, replace it:

npm uninstall framer-motion
npm install motion

Then change your imports from 'framer-motion' to 'motion/react'.

Next 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:

Astro site with working Framer Motion Vortex animation displaying colorful swirling particles

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).

It 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).

Tagged: React, Astro · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about react: