Canvas and animation loops
Draw on a canvas from Vue: a template ref read in onMounted, a bitmap sized by devicePixelRatio, a useRafFn() loop that clears, moves and draws plain data, and reduced motion respected.
What you'll learn
When you finish, you can do these.- 1Set up a canvas that stays sharp on every screen
- 2Run a loop that clears, moves and draws every frame
- 3Keep sliders in refs and dot positions in a plain array
- 4Start paused for reduced motion and label the canvas
Picture a page where dots drift around a box: 400 at first, up to 2,000 with a slider. In SVG, the image format written as tags, each dot would be an element the browser lays out and paints, like each bar of the bar chart in Reactive SVG charts. That suits a chart that changes now and then, not thousands of dots moving on every frame. A <canvas> is one element holding a bitmap, a grid of pixels you paint with JavaScript, however many dots it shows.
You need one idea from Lifecycle and template refs, in the Beginner track: a template ref, a box Vue fills with an element once the component is on the page. The Code tab holds the dots, already working, with a Pause button and two sliders, Speed and Dots; by the end you can read every line of its code.
The element and its two sizes
Before anything moves, the canvas needs a drawing context and a sharp bitmap. In <script setup>, useTemplateRef('canvas') creates the template ref for <canvas ref="canvas">. The ref holds null until the component is on the page, so onMounted() runs the starter's setUp function, which asks the element for its drawing context with getContext('2d'): the object with every drawing method, kept in ctx.
A canvas has two sizes. The CSS size, 320 by 200 in the starter's stylesheet, is how big it looks. The bitmap size, the width and height attributes, is how many pixels it holds, stretched to fill the CSS size.
A sharp screen uses two or three device pixels, its points of light, for each CSS pixel, and window.devicePixelRatio says how many. A 320-pixel bitmap in a box 640 device pixels wide is stretched to twice its size and looks soft.
In App.vue, useDevicePixelRatio() from VueUse, a library of ready-made Vue helpers, keeps that ratio in the ref pixelRatio. setUp copies it into dpr, multiplies the bitmap by it, then calls ctx.scale(dpr, dpr) so the rest of the code keeps drawing in CSS pixels.
function setUp() {
const el = canvas.value
if (!el) return
const dpr = pixelRatio.value
el.width = WIDTH * dpr
el.height = HEIGHT * dpr
ctx = el.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.fillStyle = getComputedStyle(el).getPropertyValue('--dot')
bitmap.value = `${el.width} × ${el.height}`
frame(0)
}
onMounted(setUp)
watch(pixelRatio, setUp)
On a screen with a ratio of 2, the line of text under the canvas reads bitmap 640 × 400. Zooming the page or moving the window to another screen changes the ratio, and the watcher runs setUp again.
Setting width wipes the bitmap and resets the context, so setUp sets the scale and colour again and repaints the dots with frame(0).
One loop paints every frame
Animation means painting the picture again for every frame. The browser's requestAnimationFrame() calls your function right before it paints the next frame, about 60 times a second. VueUse's useRafFn() wraps it: it passes delta, the milliseconds since the last frame, returns pause(), resume() and isActive, and stops by itself when the component unmounts.
- Controlsrefs
- useRafFn tick
- clearRect
- Move plain positions
- Draw
In App.vue, frame draws one frame and useRafFn() calls it with delta:
function frame(ms: number) {
if (!ctx) return
ctx.clearRect(0, 0, WIDTH, HEIGHT)
moveDots(dots, ms * speed.value, WIDTH, HEIGHT)
drawDots(ctx, dots)
}
const { isActive, pause, resume } = useRafFn(({ delta }) => frame(delta), {
immediate: motion.value !== 'reduce',
})
First clearRect wipes the bitmap, because a canvas keeps everything painted on it. moveDots, from the starter's second file, dots.ts, moves each dot by its own speed times delta, scaled by the Speed slider, so the dots keep one pace at 60 or 120 frames a second. drawDots paints them all. On the page, the 400 dots drift across the box.
Two kinds of data meet in this loop. Speed and the number of dots are state the person controls, so they are refs: a slider writes them and the template shows them. The positions change on every frame and nothing in the template reads them, so they live in a plain array, dots. moveDots rewrites them in place; wrap brings a dot that leaves one edge back at the other:
export function moveDots(dots: Dot[], ms: number, width: number, height: number) {
for (const d of dots) {
d.x = wrap(d.x + d.dx * ms, width)
d.y = wrap(d.y + d.dy * ms, height)
}
}
Put that array in a ref() and every dot becomes a reactive proxy, a wrapper that reports each read and write to Vue, for nothing. On a laptop, moving 2,000 dots took about 0.08 ms a frame in a plain array and about 3 ms inside ref(), a fifth of a 16.7 ms frame; a phone is several times slower.
Motion, screen readers and clicks
The dots must also suit people who turn motion off or use a screen reader, a program that reads the page aloud. Some people feel sick when things move and turn on Reduce motion in their system settings. VueUse's usePreferredReducedMotion() reads that setting, which CSS calls prefers-reduced-motion, into motion as 'reduce' or 'no-preference'. On 'reduce' the starter passes immediate: false to useRafFn(), so the loop waits for Play and the dots stay still.
A screen reader finds nothing inside a canvas. role="img" with an aria-label names it, and the line under it gives the numbers as text:
<canvas ref="canvas" role="img" :aria-label="`${count} dots drifting in a box`" />
<p>{{ count }} dots · bitmap {{ bitmap }} for a 320 × 200 box · {{ isActive ? 'moving' : 'paused' }}</p>
With 400 dots the label reads "400 dots drifting in a box"; both follow the Dots slider.
A canvas has no elements to click either. If you find the dot under the pointer yourself, called hit-testing, give keyboard users another way to choose it, such as a list of buttons.
What goes wrong
The first mistake sizes the bitmap in CSS pixels.
el.width = WIDTH
el.height = HEIGHT
ctx = el.getContext('2d')!
On a sharp screen the line under the canvas says bitmap 320 × 200 and every dot has soft, smeared edges. On a screen with a ratio of 1 it looks fine until someone zooms to 200%. Multiply both sizes by dpr, then scale the context.
el.width = WIDTH * dpr
el.height = HEIGHT * dpr
ctx = el.getContext('2d')!
ctx.scale(dpr, dpr)
Multiplying without ctx.scale() fails the other way: the dots shrink to half their size and crowd into the top-left quarter of the box.
The second mistake leaves out clearRect.
function frame(ms: number) {
if (!ctx) return
moveDots(dots, ms * speed.value, WIDTH, HEIGHT)
drawDots(ctx, dots)
}
Every dot drags a solid line behind it, and within a few seconds the box is a block of blue with a few gaps. Wipe the bitmap at the start of every frame.
function frame(ms: number) {
if (!ctx) return
ctx.clearRect(0, 0, WIDTH, HEIGHT)
moveDots(dots, ms * speed.value, WIDTH, HEIGHT)
drawDots(ctx, dots)
}
What to remember
- Make a canvas's bitmap
devicePixelRatiotimes its CSS size, scale the context by the same factor, and do both again when the ratio changes. - Create the template ref in setup and read it in
onMounted(). A canvas reads no CSS, so read colour variables withgetComputedStyle(). - Each frame clears, moves by
deltaand draws.useRafFn()runs the loop and stops it when the component unmounts. - What the person controls is a ref. Data rewritten on every frame stays in plain variables.
- Start paused when the person prefers reduced motion, name the canvas with
role="img", and give keyboard users another way to reach anything you hit-test.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In
App.vue, changeconst dpr = pixelRatio.valuetoconst dpr = 1. The line under the canvas readsbitmap 320 × 200and the dots turn soft at the edges (on a screen with a ratio of 1, zoom to 200% to see it). PutpixelRatio.valueback and zoom again: the bitmap numbers grow with the zoom and the edges stay crisp. - In
App.vue, replacectx.clearRect(0, 0, WIDTH, HEIGHT)withfade(ctx, WIDTH, HEIGHT), whichdots.tsalready exports: each dot drags a short tail that fades away. Indots.ts, changectx.globalAlpha = 0.2to0.05and the tails grow longer. - Under the other watchers in
App.vue, addwatch(motion, (m) => (m === 'reduce' ? pause() : resume())). In Chrome DevTools, open the Rendering panel from More tools and set Emulate CSS media feature prefers-reduced-motion to reduce: the dots stop and the button says Play. Edit any line so the preview restarts: it opens paused. Set the emulation back to no-preference and the dots move again.