Mischief

66 / Scenes

Render Surface

The canvas the other scenes are drawn on. It stays the size of its box, sleeps when nobody is looking at it, and holds still when motion is reduced.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/render-surface
import { RenderSurface } from "mischief-ui/render-surface"

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/render-surface/render-surface.tsx
"use client" import * as React from "react"import { cn } from "@/lib/utils" export type SurfaceContextType = "2d" | "webgl" | "none" export type SurfaceContextFor<K extends SurfaceContextType> = K extends "2d"  ? CanvasRenderingContext2D  : K extends "webgl"    ? WebGLRenderingContext    : null export type SurfaceSize = {

Usage

export function Dots() {
  return (
    <RenderSurface
      setup={({ size }) => makeDots(size)}
      draw={({ context, size, state, delta }) => {
        context.setTransform(size.dpr, 0, 0, size.dpr, 0, 0)
        context.clearRect(0, 0, size.width, size.height)
        for (const dot of state) step(context, dot, delta)
      }}
    />
  )
}

What it refuses to do

A canvas that animates forever is a battery that empties forever. This one stops on its own in three situations, and none of them need anything from the component drawing on it.

  • Scrolled out of view. An IntersectionObserver with a 128 pixel margin stops the loop just after the surface leaves the screen and starts it again just before it returns.
  • Tab hidden. The loop stops on visibilitychange rather than relying on the browser to throttle it.
  • Reduced motion. One frame is painted and no loop is started at all.

That last one is the important one. A reduced motion setting is not a request for a blank rectangle, so the surface still draws -- it draws the scene at rest and leaves it there.

One frame is enough for a scene that has everything it needs at the moment it mounts, and not enough for one waiting on a picture that has not arrived. That is what revision is for: change it when the late thing turns up and the surface paints once more. Sleeping and waking never rebuild the canvas, because resizing a backing store clears it, and a surface that had painted once would be wiped by the act of stopping.

Time that does not jump

draw receives both time and delta in seconds. Time counts only the frames that were actually drawn, so a scene that was paused for a minute resumes where it stopped rather than skipping a minute forward.

delta is clamped to a fifteenth of a second. Physics integrated against an unclamped delta after a long stall will throw every particle out of the box in a single step, and clamping is cheaper than discovering that on a slow machine.

When a resize should not rebuild

By default a resize runs setup again, which is what a particle field wants: the count depends on the area. A setup that acquires something scarce should not do this. A browser allows only a handful of WebGL contexts at once, so a renderer rebuilt on every resize will exhaust them during a single drag of the window edge.

<RenderSurface
  contextType="none"
  rebuildOnResize={false}
  setup={({ canvas, size }) => makeRenderer(canvas, size)}
  draw={({ size, state }) => {
    if (size.width !== state.width) resizeRenderer(state, size)
    state.renderer.render(state.scene, state.camera)
  }}
/>
With rebuildOnResize off, the canvas is still resized for you. Only setup is skipped.

Reading the theme

useThemeColors reads custom properties off a mounted element and returns them as plain channels between zero and one, which is the form a shader uniform or a canvas fill wants. It re-reads when the theme changes, so a scene recolours itself when someone switches to dark.

const ref = React.useRef(null)
const colors = useThemeColors(ref, ["--primary", "--background"])

// colors["--primary"] is [r, g, b], each 0 to 1

The conversion is done by painting one pixel and reading it back, rather than by parsing the value. shadcn themes are written in oklch and often in color-mix, and letting the browser resolve them is the only approach that stays correct as CSS gains more colour spaces.

API

setup(args) => TStateBuilds whatever the drawing needs. Runs again after a resize, and after a lost GPU context comes back.
draw(args) => voidCalled once per frame with the state, the size, the seconds elapsed, and the seconds since the last frame.
teardown(state) => voidReleases anything setup acquired.
contextType"2d" | "webgl" | "none"Which context to ask the canvas for. "none" hands you the bare canvas for a library that wants to attach its own renderer.
maxDprnumberHighest backing store scale. Defaults to 2, because above that the cost climbs faster than the result improves.
rebuildOnResizebooleanWhether a resize runs setup again. Defaults to true.
revisionstring | numberChange it to ask for one more frame. Needed by anything whose content arrives late.
pausedbooleanStops the loop without unmounting the canvas.
labelstringAnnounces the canvas as an image with this description. Without one it is hidden from assistive technology as decoration.

Accessibility

The canvas is hidden from assistive technology unless you pass a label, because most scenes are decoration and announcing them is noise. With a label it becomes an image with that description. Reduced motion is honoured by the surface itself, so no component drawing on it can forget to.