Skip to content
TutorialGraphics & 3D

Canvas and animation loops in a Vue component

A canvas is a bitmap, and Vue's template has nothing to say about what is painted on it. Let Vue own the element and the controls, and let a frame loop, by hand or through useRafFn(), own the pixels.

vuejs.school team15 Jan 20266 min readVue 3.5 · VueUse 14.1

SVG works until you have a few thousand moving things, and then you want a canvas: one element, a bitmap you paint with JavaScript, no DOM node per shape. Vue cannot see those pixels, so you paint them from a frame loop that you start and stop yourself. Vue still owns the element and the sliders and buttons that steer the loop, but a drawn frame never makes the component re-render.

You will build two examples: a bouncing ball with a hand-written requestAnimationFrame loop, and a particle field run by VueUse's useRafFn() that pauses when the tab is hidden. Both assume a Vue 3.5 project with @vueuse/core 14.1 installed.

Why the canvas lives outside the render cycle

Vue's job is to turn state into DOM. When a ref changes, Vue re-runs the template and patches the elements that depend on it. A <canvas> element does not change when its contents change: the pixels inside it are not DOM, and Vue cannot see them.

So "reactive drawing", where a watcher repaints the canvas every time a value changes, is the wrong model for anything that moves. Sixty times a second you would be asking Vue to notice a change, schedule an update, run the watcher and paint, when the browser already has a clock for painting: requestAnimationFrame, which calls a function once before each screen refresh.

The split that works is this. Vue owns the element and the controls, meaning the refs the user changes with sliders and buttons. Plain variables own the per-frame data, meaning positions and velocities. The frame loop reads the controls, updates the plain data and paints. Nothing in the template depends on the per-frame data, so Vue never re-renders because of it.

Get the context in onMounted, scaled for the screen

The element comes from useTemplateRef(), which is null until the component is mounted, so the context is created in onMounted(). One more step keeps the drawing sharp. A canvas has two sizes: the CSS size it occupies on the page and the bitmap size in pixels. On a screen with devicePixelRatio of 2, a 480-pixel-wide canvas needs a 960-pixel bitmap, and the context is scaled by 2 so that your drawing code can keep thinking in CSS pixels.

The bouncing ball component starts with just that:

src/components/BouncingBall.vue
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'

const WIDTH = 480
const HEIGHT = 240
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
let ctx: CanvasRenderingContext2D | null = null

function setup() {
  const el = canvas.value!
  const dpr = window.devicePixelRatio || 1
  el.width = WIDTH * dpr
  el.height = HEIGHT * dpr
  ctx = el.getContext('2d')
  ctx?.scale(dpr, dpr)
}

onMounted(setup)
</script>

<template>
  <canvas
    ref="canvas"
    :style="{ width: `${WIDTH}px`, height: `${HEIGHT}px` }"
    role="img"
    aria-label="A ball bouncing inside a box"
  />
</template>

Setting el.width resets the context, including any scale you applied before, which is why setup() scales after sizing and why you call it again after any resize.

The role and aria-label are there because canvas content is invisible to assistive technology; describe what it shows or offer the same information in text nearby.

Run the loop and cancel it on unmount

requestAnimationFrame(tick) asks the browser to call tick once before the next repaint and returns an id. The loop keeps itself going by requesting the next frame at the end of each one, and cancelAnimationFrame(id) stops it. The browser passes a timestamp in milliseconds to the callback, and the time since the previous frame is what you move things by.

Add these lines to the script of BouncingBall.vue (merge the import with the existing one):

src/components/BouncingBall.vue
import { onBeforeUnmount } from 'vue'

const ball = { x: 40, y: 40, vx: 180, vy: 120, r: 12 } // velocity in pixels per second
let frame = 0
let last = 0

function tick(now: number) {
  const delta = Math.min(now - last, 50) / 1000 // seconds, never more than 50 ms
  last = now
  ball.x += ball.vx * delta
  ball.y += ball.vy * delta
  // past a wall: point the velocity away from it
  if (ball.x < ball.r) ball.vx = Math.abs(ball.vx)
  if (ball.x > WIDTH - ball.r) ball.vx = -Math.abs(ball.vx)
  if (ball.y < ball.r) ball.vy = Math.abs(ball.vy)
  if (ball.y > HEIGHT - ball.r) ball.vy = -Math.abs(ball.vy)
  if (ctx) {
    ctx.clearRect(0, 0, WIDTH, HEIGHT)
    ctx.beginPath()
    ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2)
    ctx.fill()
  }
  frame = requestAnimationFrame(tick)
}

onMounted(() => {
  last = performance.now()
  frame = requestAnimationFrame(tick)
})
onBeforeUnmount(() => cancelAnimationFrame(frame))

Two things are easy to forget:

  • The cancel in onBeforeUnmount() is not optional. Without it the loop keeps running after the component is gone, painting onto a detached canvas and holding the component's closures in memory for as long as the page lives.
  • ball is a plain object, not reactive(). Making it reactive would wrap every read and write in the loop with dependency tracking that nothing subscribes to.

Move by time, not by frame

The mistake that looks fine on your own screen is moving a fixed distance per frame. In the two versions below, a draw() helper stands in for the painting lines and the wall checks are left out, so only the movement differs:

src/components/BouncingBall.vueBreaks
function tick() {
  ball.x += 3 // three pixels per frame
  ball.y += 2
  draw()
  frame = requestAnimationFrame(tick)
}

On a 60 Hz screen the ball crosses 180 pixels a second. On a 120 Hz laptop it moves twice as fast, on a 144 Hz monitor 2.4 times as fast, and on a slow phone that drops frames it crawls. The animation's speed has become a property of the hardware. The fix is the movement from the previous section:

src/components/BouncingBall.vueWorks
function tick(now: number) {
  const delta = Math.min(now - last, 50) / 1000
  last = now
  ball.x += ball.vx * delta // 180 pixels per second, on every screen
  ball.y += ball.vy * delta
  draw()
  frame = requestAnimationFrame(tick)
}

Velocities are in pixels per second, and each frame moves by however much time has passed. The Math.min(…, 50) clamp caps a single step at 50 milliseconds. Without it, the first frame after a long pause, such as the tab coming back from the background, would receive a delta of several seconds and teleport the ball through the wall.

A clamped step can still carry the ball up to 9 pixels into a wall (50 milliseconds at 180 pixels per second). If the wall check flipped the velocity with *= -1 and the next, shorter step left the ball inside the wall, the check would flip it again, and the ball could stay there, shaking. That is why the full loop sets the direction with Math.abs(): past a wall, the velocity always points away from it.

Run the loop with VueUse's useRafFn

VueUse wraps the same pattern in useRafFn(). You pass it the per-frame function, and it:

  • calls that function on every frame with delta, the milliseconds since the previous frame, and the frame's timestamp;
  • returns pause(), resume() and an isActive ref;
  • stops the loop when the component's scope is disposed, which happens on unmount, so the cleanup is no longer your job.

Here is a particle field that uses it, with two reactive controls and a canvas that follows its container's width through useElementSize():

src/components/Particles.vue
<script setup lang="ts">
import { onMounted, ref, useTemplateRef, watch } from 'vue'
import { useElementSize, useRafFn } from '@vueuse/core'

const HEIGHT = 240
const wrapper = useTemplateRef<HTMLDivElement>('wrapper')
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
const { width } = useElementSize(wrapper)

const count = ref(300) // controls: reactive, set by the sliders
const speed = ref(1)

type Particle = { x: number; y: number; vx: number; vy: number }
let particles: Particle[] = [] // per-frame data: plain, never reactive
let ctx: CanvasRenderingContext2D | null = null

function resize() {
  const el = canvas.value
  if (!el || !width.value) return
  const dpr = window.devicePixelRatio || 1
  el.width = width.value * dpr
  el.height = HEIGHT * dpr
  ctx = el.getContext('2d')
  ctx?.scale(dpr, dpr)
}

function seed() {
  particles = Array.from({ length: count.value }, () => ({
    x: Math.random() * width.value,
    y: Math.random() * HEIGHT,
    vx: (Math.random() - 0.5) * 60,
    vy: (Math.random() - 0.5) * 60,
  }))
}

const { pause, resume, isActive } = useRafFn(({ delta }) => {
  if (!ctx) return
  const dt = Math.min(delta, 50) / 1000
  const w = width.value
  ctx.clearRect(0, 0, w, HEIGHT)
  for (const p of particles) {
    p.x = (p.x + p.vx * speed.value * dt + w) % w
    p.y = (p.y + p.vy * speed.value * dt + HEIGHT) % HEIGHT
    ctx.fillRect(p.x, p.y, 2, 2)
  }
})

onMounted(resize)
watch(width, () => { resize(); seed() })
watch(count, seed)
</script>

<template>
  <div ref="wrapper">
    <canvas ref="canvas" :style="{ width: '100%', height: `${HEIGHT}px` }" role="img" aria-label="Drifting particles" />
  </div>
  <label>Particles <input v-model.number="count" type="range" min="10" max="3000" /></label>
  <label>Speed <input v-model.number="speed" type="range" min="0" max="4" step="0.5" /></label>
  <button type="button" @click="isActive ? pause() : resume()">{{ isActive ? 'Pause' : 'Resume' }}</button>
</template>

useRafFn() also takes options: immediate: false to create the loop paused, fpsLimit to cap how often your function runs, and once for a single frame. The fpsLimit option is the one to reach for when a visualisation does not need to run at the screen's full rate.

Read the controls in the loop without re-rendering

Look at where the refs are read in Particles.vue. speed.value and width.value are read inside the frame function, which is a plain callback, not a watcher or a template, so reading them subscribes nothing. Moving the speed slider changes the number the next frame picks up. The component re-renders once per change, because v-model keeps the slider in step, but never once per frame.

count is different: a new count needs a new particle array, which is a one-off job rather than a per-frame one, so a watch() calls seed() when it changes. Three thousand particles at 60 frames a second is 180,000 position updates a second, and because particles is a plain array none of them goes through Vue.

Pause when the tab is hidden

Most browsers stop firing requestAnimationFrame in a background tab, so the loop already sleeps when the user switches away. An explicit pause still helps in two ways:

  • resume() starts timing afresh, so the first frame back gets a delta of 0. Without the pause it gets the whole time the tab was hidden, which only the clamp cuts down to 50 milliseconds.
  • The browser pauses only the frames. Anything you run alongside the loop, such as a setInterval that spawns particles or a WebSocket that feeds it, keeps going in the background, and a visibility watcher is the place to stop it too.

useDocumentVisibility() returns a ref that is 'visible' or 'hidden'. Add this watcher to the script of Particles.vue (merge the import with the existing one):

src/components/Particles.vue
import { useDocumentVisibility } from '@vueuse/core'

const visibility = useDocumentVisibility()

watch(visibility, (state) => {
  if (state === 'visible') resume()
  else pause()
})

With that in place, the loop pauses when the tab is hidden and resumes when it is shown again, with the same pause() and resume() the button uses. One side effect: an animation the user paused with the button starts again when they come back to the tab. To prevent that, keep the user's choice in its own ref and resume only if they have not paused.

Check it stuck3 questions from the Canvas and animation loops quiz.
Practice now
Was this helpful?

Keep reading