AnimationCanvas
A simple animatable canvas with requestAnimationFrame()
- React
- Svelte
- Vue
function App() { return ( <AnimationCanvas width={200} height={200} init={(ctx) => { ctx.font = '16px sans-serif' }} draw={(ctx, { width, height, count }) => { ctx.clearRect(0, 0, width, height) ctx.fillText(`frame: ${count}`, 0, 16) // draw sine wave const halfH = height / 2 ctx.strokeStyle = '#29bbf0' ctx.beginPath() for (let i = 0; i < width; i++) { const y = halfH + halfH * 0.5 * Math.sin((4 * Math.PI * (i + count * 2)) / width) if (i == 0) ctx.moveTo(i, y) else ctx.lineTo(i, y) } ctx.stroke() }} /> ) }
<script lang="ts">
import { AnimationCanvas } from '@tremolo-ui/svelte'
</script>
<AnimationCanvas
width={200}
height={200}
init={(ctx) => {
ctx.font = '16px sans-serif'
}}
draw={(ctx, { width, height, count }) => {
ctx.clearRect(0, 0, width, height)
ctx.fillText(`frame: ${count}`, 0, 16)
// draw sine wave
const halfH = height / 2
ctx.strokeStyle = '#29bbf0'
ctx.beginPath()
for (let i = 0; i < width; i++) {
const y =
halfH + halfH * 0.5 * Math.sin((4 * Math.PI * (i + count * 2)) / width)
if (i === 0) ctx.moveTo(i, y)
else ctx.lineTo(i, y)
}
ctx.stroke()
}}
/>
<script setup lang="ts">
import { type CanvasDrawFunction } from '@tremolo-ui/dom'
import { AnimationCanvas } from '@tremolo-ui/vue'
const draw: CanvasDrawFunction = (ctx, { width, height, count }) => {
ctx.clearRect(0, 0, width, height)
ctx.fillText(`frame: ${count}`, 0, 16)
// draw sine wave
const halfH = height / 2
ctx.strokeStyle = '#29bbf0'
ctx.beginPath()
for (let i = 0; i < width; i++) {
const y =
halfH + halfH * 0.5 * Math.sin((4 * Math.PI * (i + count * 2)) / width)
if (i === 0) ctx.moveTo(i, y)
else ctx.lineTo(i, y)
}
ctx.stroke()
}
</script>
<template>
<AnimationCanvas
:width="200"
:height="200"
:init="(ctx) => (ctx.font = '16px sans-serif')"
:draw="draw"
/>
</template>
Import
- React
- Svelte
- Vue
import { AnimationCanvas } from '@tremolo-ui/react'
import { AnimationCanvas } from '@tremolo-ui/svelte'
import { AnimationCanvas } from '@tremolo-ui/vue'
Features
- Adapt devicePixelRatio.
- Reduce flicking when resized.
- Observe the parent element's dimensions.
- Simple ticker and timer
API Reference
AnimationCanvas renders a <canvas> and takes its attributes, such as
className and style. A ref reaches the <canvas>.
In Svelte and Vue the props are the same, written their way — class rather
than className, bind:value or v-model, snippets or slots for content. See
Svelte and Vue.
| Prop | Type | Default |
|---|---|---|
draw* | CanvasDrawFunction | — |
init | CanvasInitFunction | — |
animate | boolean | true |
options | CanvasRenderingContext2DSettings | — |
reduceFlickering | boolean | true |
Full reference: AnimationCanvasCommonProps
Fixed vs Resizable
A canvas is fixed by default: width and height set its size in CSS pixels.
With resizable it follows the size of its parent element instead, so the
parent needs a size of its own. The two are exclusive, and passing width or
height together with resizable is a type error.
Fixed
| Prop | Type | Default |
|---|---|---|
resizable | false | — |
width | number | 100 |
height | number | 100 |
Full reference: AnimationCanvasFixedProps
Resizable
| Prop | Type | Default |
|---|---|---|
resizable* | true | — |
width | undefined | — |
height | undefined | — |
Full reference: AnimationCanvasResizableProps
Reactive Canvas
By default the canvas redraws on every animation frame. When the drawing is
driven by state rather than by time, set animate={false}: the canvas then
draws only when it has a reason to — when it mounts, when it is resized, and
when the component re-renders.
That last one is what makes React.useState work as the source of the
drawing. Pass the new draw and the canvas repaints; there is nothing to
memoize and no redraw to trigger by hand.
See Reactive below for a working example.
Writing draw inline is fine either way. The canvas is set up once and the
handler is swapped in place, so re-rendering never restarts the animation,
resets count and elapsedTime, or runs init again. Wrapping the component
in React.memo, or holding draw in a useCallback, is not needed.
Examples
Reactive
A canvas driven by state instead of by time, with animate={false}. Moving the
slider re-renders the component, and that is what repaints the canvas.
function App() { const [hue, setHue] = useState(200) return ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, }} > <AnimationCanvas animate={false} width={240} height={80} draw={(ctx, { width, height }) => { ctx.clearRect(0, 0, width, height) for (let x = 0; x < width; x += 12) { ctx.fillStyle = `hsl(${(hue + x) % 360} 90% 60%)` ctx.fillRect(x, 0, 10, height) } }} /> <Slider.Root className={sliderTheme.root} value={hue} min={0} max={360} onChange={(v) => setHue(v)} > <Slider.Track className={sliderTheme.track}> <Slider.Thumb className={sliderTheme.thumb} aria-label="Hue" /> </Slider.Track> </Slider.Root> </div> ) }
/** The demo theme for `Slider`, as a CSS Module: one class per part, applied by* the caller. Copy this file into your project and edit it — it is a starting* point, not a dependency.** It says what the parts look like, not where they go: the component places* them itself.*//** The size the thumb is drawn at, and the room the slider leaves for it. Both* parts need the value and nothing inherits it, so it is composed into each* of them and stays written once.*/.thumbSize {--thumb-size: 22px;}.root {composes: thumbSize;display: inline-flex;flex-direction: column;margin: calc(var(--thumb-size) / 2);cursor: pointer;outline: 0;-webkit-tap-highlight-color: transparent;&[data-orientation='vertical'] {flex-direction: row;}&[data-readonly] {cursor: not-allowed;}}.thumb {composes: thumbSize;--color: oklch(59.5% 0.17548 266.373);width: var(--thumb-size);height: var(--thumb-size);background: var(--color);border-radius: 50%;outline: none;&[data-disabled] {background: oklch(50.462% 0.03307 270.021);}&:focus-within {box-shadow: 0px 0px 0px 3pxcolor-mix(in srgb, var(--color) 20%, transparent);}}.track {--active-color: oklch(69.212% 0.12903 267.633);--inactive-color: oklch(95% 0 0);--length: 140px;--thickness: 10px;/* Which way the fill runs. `--percent` is where the thumb sits on screen,so a flipped slider only swaps which side of it is the filled one. */--axis: to right;width: var(--length);height: var(--thickness);border-radius: calc(var(--thickness) / 2);/* The component sets `--percent` and paints nothing itself, so the fill isyours to change: a second colour stop, a gradient, an image. */background: linear-gradient(var(--axis),var(--active-color) var(--percent),var(--inactive-color) var(--percent));&[data-flipped] {background: linear-gradient(var(--axis),var(--inactive-color) var(--percent),var(--active-color) var(--percent));}&[data-orientation='horizontal'] {min-width: 50px;}&[data-orientation='vertical'] {--axis: to bottom;width: var(--thickness);height: var(--length);min-height: 50px;}&:hover {--active-color: oklch(64.276% 0.15244 266.932);--inactive-color: #e0e0e0;}&[data-disabled] {--active-color: oklch(75.327% 0.01237 264.547);--inactive-color: oklch(89.755% 0.0001 271.152);}}.marks {/* Space between the marks and the track. */--gap: 6px;display: block;margin-top: var(--gap);&[data-orientation='vertical'] {margin-top: 0;margin-left: var(--gap);}}.marksOption {/* Read by the mark and the label below, which is why they are set here. */--thickness: 1px;--length: 0.5rem;--gap: 2px;display: flex;color: oklch(25.197% 0.00003 271.152);flex-direction: column;justify-content: center;align-items: center;&[data-orientation='vertical'] {flex-direction: row;}}.mark {background-color: currentColor;width: var(--thickness);height: var(--length);margin-bottom: var(--gap);&[data-orientation='vertical'] {width: var(--length);height: var(--thickness);margin-bottom: 0;margin-right: var(--gap);}}.label {text-align: right;}/** Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`* are what this site and Storybook set. Swap them for however your own app* says it, or read `prefers-color-scheme` instead.*/:global(.dark) .thumb,:global([data-theme='dark']) .thumb {--color: oklch(64.5% 0.17548 266.373);&:hover {--color: oklch(67.5% 0.17548 266.373);}}:global(.dark) .track,:global([data-theme='dark']) .track {--active-color: oklch(59.5% 0.17548 266.373);--inactive-color: oklch(41.7% 0 0);&:hover {--active-color: oklch(62.5% 0.17548 266.373);--inactive-color: oklch(44.7% 0 0);}&[data-disabled] {--active-color: oklch(75.327% 0.01237 264.547);--inactive-color: oklch(89.755% 0.0001 271.152);}}