loading
On this page
  1. What I thought it was doing
  2. The fundamental thing I found
  3. Positions are boring, forces are the fun part
  4. Where it got interesting — the koi playground
  5. Then the forces multiplied
  6. The actual lesson
article

What I learned building a fish pond with pretext

Pretext is a typography primitive I kept seeing on X. I built a koi simulation with it to learn what it actually does — and why primitives need creativity to matter.

For a few weeks my X feed kept showing the same effect. Someone would tweet a link, I’d click, and the page would load with text that flying around. You hover a word and the letters drift apart like there’s a cursor-shaped force field around your pointer. You move away and they settle back. No visible grid, no framerate drop, no wait for something to load. Text that behaves like it weighs something.

I didn’t know what I was looking at. My first instinct was “this is probably expensive,” because anything that per-character responsive usually is. My second instinct was “wait, they’re doing this on a page full of real copy, not a 6-letter demo.” My third instinct was to view source, and what I found was a library I hadn’t heard of called ✨@chenglou/pretext✨.

This post is the story of what I did next. I didn’t want to learn pretext as a user. I wanted to understand it well enough to build something with it. Playing with it and make a toys are the fastest way to stop being a viewer of a library and start being a user. The toy I ended up with is the koi simulation on this page’s home. You’re in it right now.

What I thought it was doing

Before I read any source, I guessed. Here’s what I thought pretext was doing under the hood, in rough order of confidence.

Guess 1: getBoundingClientRect on every character, every frame. I was pretty sure this was wrong even as I wrote it down. You cannot call getBoundingClientRect on a few hundred span elements per animation frame without causing the browser to crash or freeze 🥶. But I couldn’t think of an alternative that respected the DOM’s line-breaking rules.

Guess 2: an offscreen canvas that renders the whole text as an image, then reads pixels with getImageData to figure out where the characters are. 🧠 This felt cleverer, but it also felt slower (canvas raster data is not fun to iterate over), and I had no idea how you’d propagate per-character forces through a flat pixel buffer.

Guess 3: WebGL, somehow. This was the “I give up trying to guess” guess, hehe 🗿

None of these answers were correct. The real answer is much simpler, which became the first valuable lesson I learned about First Principles Thinking is a problem-solving approach where you break down complex problems into their fundamental truths and rebuild solutions from scratch, rather than relying on existing assumptions or analogies. . Being less clever than you anticipate is actually an advantage. The reason pretext is gaining attention isn’t because someone discovered an impossible optimization, it’s because someone realized that most optimizations weren’t necessary from the start.

The fundamental thing I found

Pretext is a measurement system. It does not touch the DOM at all.

That’s the entire trick.

You give it a string, a font, a max width, and a line height. It returns an array of { char, x, y, width } objects, one per grapheme, with coordinates relative to the start of the layout. The coordinates are correct for however that text would have rendered in a real container of that width. Line breaks, wraps, whitespace, emoji, complex scripts, bidirectional text: pretext handles the layout math the browser would have done, on its own, in JS.

Here’s the part that surprised me. The actual measurement happens via the canvas 2D context:

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
ctx.font = "16px Inter";
const width = ctx.measureText("hello").width;

That’s it. ctx.measureText is the browser’s own typographic width calculation, exposed as a number. Pretext uses that to compute per-grapheme advances, then runs a line-breaking algorithm over the result to figure out where the natural wraps fall. No DOM element is ever attached. No reflow is ever triggered. The browser never finds out you’re doing typography.

The cost is a measureText call per grapheme per layout. That sounds expensive but is cheap enough that you can re-lay-out on resize or font load without noticing. And you only do it once per text, not once per frame. Whatever effect you build on top queries the cached grid.

Once I saw this, the whole mystery unraveled backwards. Every effect I’d seen in the demos (magnetic text, shatter, constellation, ripple, wave) was doing the same thing underneath. Each one is a tiny physics loop that queries the grid, computes a force per character, and applies a CSS transform to the rendered span. The grid is cheap because pretext built it in one pass. The physics are cheap because they’re a hundred arithmetic ops per frame.

The clever part isn’t the measurement. The clever part is that the measurement is so cheap nobody has to care 🤯.

I want to stop here and call this out, because it’s the first thing I got wrong in my guesses and I don’t think it’s obvious. Most performance-feeling demos you see on Twitter are not clever optimizations. They are old ideas wrapped in a primitive cheap enough that the old ideas are finally practical. Pretext is the primitive. What you do with it is up to you.

Positions are boring, forces are the fun part

Here’s the thing I realized on the second day: having the positions is 20% of the effect. Maybe less.

The other 80% is: what do you do with them?

Pretext has a few companion components on this site that helped me see this. MagneticText pushes characters away from your cursor. ShatterText explodes them on click. ConstellationText draws lines between nearby letters. WaveText animates a sine along the layout. None of these are particularly clever. None of them care how the positions were computed. They iterate the grid pretext gave them, compute a per-character offset, and write transform: translate(...) to a span.

The primitive is the same in every component. The force is different.

This is the turn I want you to feel, so I’ll say it plain: pretext is not a feature. It’s a substrate. It gives you coordinates. You bring the physics. Whoever first shared the magnetic-text demo on Twitter wasn’t showing off a library — they were showing off an idea built on top of a library. The library’s job was to make the idea trivial to prototype.

Once I understood that, the question stopped being “what does pretext do?” and became “what can I do with what pretext gives me?” That second question has a much more interesting answer, because it isn’t about typography at all. It’s about whatever you want to attach to characters, treated as little independent agents. Mouse pressure. Scroll velocity. Audio frequency. Other characters. Fish.

Where it got interesting — the koi playground

I already had koi on this site. They’d been there for a while, doing a boids dance over the hero, occasionally crashing into headings and splashing 😂. I built them before I knew about pretext, and the collision was the ugly part: fish bounced off the bounding rectangle of every heading and paragraph. When a fish hit the word “platforms” it was really hitting a 400 × 30 pixel rectangle that happened to contain the word. It looked less like a fish bumping a letter and more like a fish bumping an invisible glass wall.

I wanted fish that swim between the letters.

Pretext was already in my project for the text playground effects, here is the whole change to the collision grid, condensed to the parts that matter:

import { layoutCharacters } from "@/lib/textLayout";

class CollisionGrid {
  build(targets: HTMLElement[]): void {
    this.cells = new Uint8Array(this.cols * this.rows);

    for (const el of targets) {
      const rect = el.getBoundingClientRect();
      const style = window.getComputedStyle(el);
      const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
      const fontSize = parseFloat(style.fontSize);
      const lineHeight = resolveLineHeight(style.lineHeight, fontSize);

      const result = layoutCharacters(
        el.innerText,
        font,
        rect.width,
        lineHeight,
      );

      for (const cell of result.cells) {
        if (/^\s+$/.test(cell.char)) continue; // whitespace is traversable
        this.markRect(
          rect.left + cell.x,
          rect.top + cell.y,
          cell.width,
          lineHeight,
        );
      }
    }
  }
}

Thirty lines, most of it housekeeping. The part that does the work is layoutCharacters, which is pretext. For every heading and paragraph marked with a data-collide attribute, I run pretext on the actual rendered text, and mark the grid cells that contain non-whitespace graphemes. Whitespace cells are intentionally left open.

The first time I loaded the page with this running, the visual difference was bigger than I expected. Fish stopped bouncing off invisible rectangles and started weaving between the letters. They’d try to cross the word “scalable,” get caught by the a, slide around to the gap between a and b, and keep swimming. The space between letters isn’t just empty — it’s traversable. Reading the tagline became a background activity; you could watch the fish navigate through your own words. It’s amazing dud! I was left speechless and filled with excitement 😍 hahaha.

Fish threading between the letters of the home-page tagline

I hadn’t built a feature. I’d reused a primitive that was already measuring the characters for a completely different reason, and the measurement happened to also be everything a physics collider needed.

This is the moment when I stopped seeing pretext as just a library and began to think of it as a flexible foundation I could use for anything the size of a character.

Then the forces multiplied

Fish were the first force. Once fish worked, I wanted cursor 🤣. If a mouse can hover a character and nothing happens, that feels like a wasted opportunity now that the characters are independent objects. So I added a cursor force: each character tracks the distance to your pointer, and if it’s within a radius, it gets pushed away with quadratic falloff. Smooth spring back. Same math as the fish, different source.

Once I had cursor and fish, I wanted ripples 😆. When a fish crashes on a letter it spawns a droplet, and it felt wrong for that droplet not to affect anything. So, I added a ripple wave, a circular wave of force that expands from the crash point over about three seconds, pushing nearby characters outward as the wave passes through them. It’s the same math again, just from a different source.

Once I had fish + cursor + ripples, I wanted a trail. When a character gets pushed hard enough I stamp a touchedAt timestamp on it, and in the render loop I interpolate its color toward accent gold for 500ms. Characters that fish swim past leave a gold wake. Same loop, different channel.

Captain America Avengers
GIF

Every time I added a new force, I noticed something: the thing I wasn’t doing was modifying pretext, or the engine, or the text components. What I was doing was adding a new publisher to a bus and a new subscriber to the tick loop. The architecture that actually made this scale wasn’t pretext at all. It was a four-line pub/sub module:

const subscribers = new Set<Subscriber>();
export const koiBus = {
  subscribe(fn) {
    subscribers.add(fn);
    return () => subscribers.delete(fn);
  },
  publish(positions) {
    for (const fn of subscribers) fn(positions);
  },
};

The engine publishes fish positions each frame. Text components subscribe and run their own per-frame loop. I added a second bus for ripples. Cursor is a window event, not a bus, but it plugs into the same tick loop. Every force source is independent. Every new one is ~50 lines that touches nothing else.

This is the thing I didn’t see coming when I started. The real unlock wasn’t pretext. Pretext got me to “characters are objects.” The bus pattern got me to “characters are objects you can plug anything into.” Pretext provides the groundwork. The bus is the extensibility. You need both.

The actual lesson

The hero of this site now has fish that swim between letters, a cursor that pushes text around, ripples from crashes, gold wake trails, scholar schools that cross the viewport in formation, and a rare monster that drifts through the middle of the screen as six lines of ASCII. Six force sources, one primitive, one weekend of wiring. None of this was a feature request. It was curiosity about a library I kept seeing on X, chased far enough to stop being a mystery 😊.

If I have one takeaway for you: when something trending looks like magic, the way to stop being a viewer and become a user is to build a toy with it. Not a product. Not a startup. A toy. You will discover that the magic is usually less clever and more useful than you thought, which is better, because “less clever and more useful” is the thing you can actually build on. The first cleverness goes on the primitive. The second cleverness is yours.

One more thing. While I was building this I made a minimal config panel for the pond — three sliders that let you tune the fish cap, ripple strength, and how often the scholar schools and monster spawn. Right now the panel is gated to dev mode so the production site stays clean. If this post does well enough that readers actually want to play with the pond, I’ll ship the panel to everyone. Until then, you can still open devtools on this page and type pond.show() to reach it. The gear lives in the bottom-left corner. There’s also pond.summonMonster() if you don’t want to wait for one to drift by naturally.

If you’ve been staring at a library wondering how it works, my suggestion is: pick one effect, make it do one thing, and see what you learn from the pieces you didn’t expect to care about. That’s the whole post.

I didn’t expect this to be long, but it turned out to be a lot of fun to write. I hope to commit to writing more often. See you in the next one dud! 👋🏻

← Back to all content