Live HTML in the canvas
drawElement draws a real DOM element inside your spryte canvas: a menu, a form with a text field, a paragraph of wrapped rich text. The element stays live. It takes clicks, focus and typing where it's drawn, its text selects, Ctrl+F finds it, and screen readers read it. It goes through spryte's transforms and shaders like any sprite.
It uses the browser's HTML-in-Canvas API, which Chrome and Edge have from version 157. Firefox and Safari don't have it yet, so always keep a fallback.
See it in the live HTML demo: a card that dissolves in through a shader over a swarm of sprites, with text you can still select and a link and button that work.
Experimental
drawElement is new in spryte 0.9 and may change while the browser API settles.
import { createRenderer, drawElement } from "spryte";
const draw = createRenderer(canvas);
const menu = document.getElementById("menu")!; // an ordinary element in your page
function frame() {
draw.begin("#000", 320, 180, scale);
drawWorld();
if (!drawElement(draw, menu, 40, 30)) {
// No HTML-in-Canvas here: the menu is still a normal element in the page, so there's nothing to do.
}
draw.end();
requestAnimationFrame(frame);
}How it works
- The first call moves the element into the canvas (it becomes a child of the
<canvas>, markeddrawable) and draws nothing. - Once the browser has painted it, later calls draw it at
x, yin your logical units, through the current transform, alpha and shader. - spryte tells the browser where the element ended up, so the pointer, focus and selection follow the drawn position, even scaled or rotated.
drawElement returns true when it drew, and false when it didn't: there's no API, it's the first frame, or you called it inside draw.into() or a mask (both unsupported for now).
Things to know
- You place it, not CSS. Inside the canvas, the element's margins and position don't put it on screen:
x, ydo. Keep the CSS anyway. Browsers without the API leave the element in the page, where that CSS lays it out. - Its size is its CSS size. By default it's drawn at its border box, one CSS pixel per logical unit. Pass
{ w, h }to scale it. - Changes show one frame late. The browser paints the element after your frame, so a change you make now appears in the next frame.
- Re-rasterising costs browser time. An element that changes every frame is re-painted every frame. A still one costs one texture upload, when it changes.
- Effects work. Wrap it in
draw.withShader()for a dissolve, ripple or CRT look. Its text stays selectable under the effect.
const dissolve = draw.createShader(`
uniform float progress;
vec4 effect(vec4 color, vec2 uv) {
float n = fract(sin(dot(floor(uv * 40.0), vec2(12.9898, 78.233))) * 43758.5453);
return source(uv) * color * step(n, progress);
}
`);
dissolve.set("progress", Math.min(1, secondsSinceShown / 1.5));
draw.withShader(dissolve, () => drawElement(draw, card, 80, 60));Checking for support
canDrawElements() returns whether this browser has the API, before you draw anything:
import { canDrawElements } from "spryte";
if (!canDrawElements()) showHtmlMenu();Try it
The example draws a form with a text field inside a small game view, tilted, with sprites in front and behind. Run npm run example:html in a checkout of spryte, and add ?backend=2d to the URL to see the Canvas 2D path.