Skip to content
blotter

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.

PropertyDefaultNotes
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.

Live · edits run as you type
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.

Live · edits run as you type
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
  });
}