AnimationCanvas
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>
インポート
- React
- Svelte
- Vue
import { AnimationCanvas } from '@tremolo-ui/react'
import { AnimationCanvas } from '@tremolo-ui/svelte'
import { AnimationCanvas } from '@tremolo-ui/vue'
特長
- devicePixelRatio に合わせます。
- リサイズしたときのちらつきを抑えます。
- 親要素の大きさを監視します。
- シンプルなティッカーとタイマー
API リファレンス
AnimationCanvas は <canvas> を描画し、その属性(className や style など)を受け取ります。ref は <canvas> に届きます。
Svelte / Vue でも props は同じで、書き方だけがそれぞれの流儀になります(className ではなく class、bind:value や v-model、中身は snippet やスロットなど)。Svelte と Vue を参照してください。
| Prop | 型 | 既定値 |
|---|---|---|
draw* | CanvasDrawFunction | — |
init | CanvasInitFunction | — |
animate | boolean | true |
options | CanvasRenderingContext2DSettings | — |
reduceFlickering | boolean | true |
すべての説明: AnimationCanvasCommonProps
Fixed と Resizable
キャンバスは既定で固定サイズで、width と height が CSS ピクセルでの大きさを決めます。resizable を付けると、代わりに親要素の大きさに合わせるので、親要素が自分の大きさを持っている必要があります。2 つは排他的で、resizable と一緒に width や height を渡すと型エラーになります。
Fixed
| Prop | 型 | 既定値 |
|---|---|---|
resizable | false | — |
width | number | 100 |
height | number | 100 |
すべての説明: AnimationCanvasFixedProps
Resizable
| Prop | 型 | 既定値 |
|---|---|---|
resizable* | true | — |
width | undefined | — |
height | undefined | — |
すべての説明: AnimationCanvasResizableProps
Reactive Canvas
既定では、キャンバスはアニメーションフレームごとに描き直します。描画が時間ではなく state で決まるときは animate={false} にします。するとキャンバスは、描く理由があるときだけ描きます。マウントしたとき、リサイズされたとき、そしてコンポーネントが再レンダーされたときです。
最後の 1 つがあるので、React.useState をそのまま描画の元にできます。新しい draw を渡せばキャンバスが描き直すので、メモ化するものも、手で起こす再描画もありません。
動く例は下の Reactive にあります。
どちらの場合でも、draw はインラインで書いて構いません。キャンバスは 1 度だけ作られ、ハンドラはその場で差し替えられるので、再レンダーでアニメーションが最初からやり直しになったり、count や elapsedTime がリセットされたり、init がもう 1 度走ったりはしません。コンポーネントを React.memo で包んだり、draw を useCallback で持ったりする必要はありません。
例
Reactive
animate={false} にして、時間ではなく state で描くキャンバス。スライダーを動かすとコンポーネントが再レンダーされ、それがキャンバスを描き直します。
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);}}