Guide
Texts and uniforms
Style texts, change them after the fact, and turn the dials on a running effect.
On this page
Text properties #
A Text takes a string and an optional styles object. The names are the CSS
ones you already know, and every one of them is optional.
| Property | Default | Notes |
|---|---|---|
family | "sans-serif" | Any CSS font-family list, including @font-face names. |
size | 12 | Font size in pixels. |
leading | 1.5 | Line height: a unitless multiplier, or a string in px or %. |
fill | "#000" | Text colour, any CSS colour string. |
style | "normal" | "normal" or "italic". |
weight | 400 | A number or string, as in CSS. |
padding | 0 | Space around the text, in pixels. Effects draw into it. |
paddingTop / paddingRight / paddingBottom / paddingLeft | 0 | Per-side overrides. |
Padding matters more than it does in CSS. Effects draw into it, so a channel split or a swarm of flies that reaches past the glyphs needs room on the canvas to land on. When an effect looks clipped, add padding.
Changing a text #
Texts are live objects. Assign a new value or a new set of
properties and every Blotter that draws the text rebuilds its atlas on the
next tick. Concurrent changes coalesce into one rebuild.
text.value = "revision"; // rebuilds every Blotter drawing it
text.properties = { size: 90 }; // merged over the defaults, then rebuilds
await blotter.update(); // explicit rebuild, resolves when settled
blotter.on("update", () => {
// fires after every rebuild; "ready" fires only for the first
});
One subtlety: properties is replaced, not patched. The object you assign is
merged over the defaults, not over the previous values, so read
text.properties first if you want to keep what is there.
Uniforms are live #
A material describes how the text is drawn. Its uniforms are the dials. Each entry
in material.uniforms has a value, and writing to it reaches the
running shader on the next frame: no rebuild, no flicker. The bundled
LiquidDistortMaterial has three.
import { Blotter, Text } from "blotter.ts";
import { LiquidDistortMaterial } from "blotter.ts/materials";
const text = new Text("observation", {
family: "'Castoro', serif",
size: 64,
fill: "#171717",
paddingLeft: 40,
paddingRight: 40,
});
const material = new LiquidDistortMaterial();
// Uniforms are live: change these and the next frame picks them up.
// 0 freezes the animation. Try 2.
material.uniforms.uSpeed.value = 0.25;
// How far the ripple pushes. Above 1 it gets wild.
material.uniforms.uVolatility.value = 0.15;
const blotter = new Blotter(material, { texts: text });
blotter.forText(text)?.appendTo(output); Per-text overrides #
Several texts can share one material and still look different. Each
RenderScope carries its own copy of the material’s uniforms, so a value set
through scope.material.uniforms applies to that text alone. They exist once
the first build is done, so read them after await blotter.ready.
const blotter = new Blotter(material, { texts: [a, b] });
await blotter.ready;
// Only text "a" changes; "b" keeps the material's value.
const scope = blotter.forText(a);
if (scope) scope.material.uniforms.uOffset.value = 0.2; Pointer events #
A scope emits mousedown, mouseup, mousemove,
mouseenter and mouseleave once its canvas has been appended with
appendTo. The payload is the pointer position normalised to the canvas, from 0
to 1, with y growing downwards like the DOM. Shader UV space grows upwards, so
positions that feed a uniform get flipped on the way in.
import { Blotter, Text } from "blotter.ts";
import { FliesMaterial } from "blotter.ts/materials";
const props = {
family: "'Castoro', serif",
size: 64,
fill: "#171717",
paddingLeft: 40,
paddingRight: 40,
paddingTop: 24,
paddingBottom: 24,
};
const texts = [new Text("swarm", props), new Text("dodge", props)];
const material = new FliesMaterial();
material.uniforms.uSpeed.value = 2;
const blotter = new Blotter(material, { texts });
for (const text of texts) blotter.forText(text)?.appendTo(output);
await blotter.ready;
// Per-text uniforms live on the scope and exist after "ready".
const scope = blotter.forText(texts[1]);
scope.material.uniforms.uDodge.value = 1;
scope.material.uniforms.uDodgeSpread.value = 0.25;
// Move over the second word. Pointer y grows downwards,
// shader UV grows upwards, so flip it.
scope.on("mousemove", ({ x, y }) => {
scope.material.uniforms.uDodgePosition.value = [x, 1 - y];
}); Lifecycle #
Everything draws on requestAnimationFrame, which already pauses in background
tabs. For anything more deliberate you have a loop per Blotter and a switch per text.
blotter.stop(); // pause the loop for this instance
blotter.start(); // resume it
blotter.forText(text)?.pause(); // pause one text, the others keep going
blotter.forText(text)?.play();
blotter.stop(); // when you are done: stop, then
blotter.teardown(); // release the render target and listeners
Two habits worth keeping. Respect prefers-reduced-motion by drawing one frame
and stopping, which leaves the effect visible but still. And pause instances that have
scrolled out of view, an IntersectionObserver calling stop() and
start() is enough.
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
const off = blotter.on("render", () => {
off();
blotter.stop(); // one frame drawn, then hold still
});
}