I wanted the large abhiigatty at the bottom of my site to move when someone passed their mouse over it. We built a local prototype with a bright white ASCII wordmark and a softer ocean of characters behind it.

The first version reacted late. The pointer had to travel thousands of invisible character cells before it reached the mouse. That bug turned out to be more useful to understand than the wave formula.

This post describes the local implementation and its checks. It is still a prototype, with physical low-end device testing left to do.

The starting point was the interactive ASCII mark on Swash Motion. Initially, we inspected its footer and built a small letter wave. I had to point out the actual target: the character field below it.

Inspecting that element showed two overlapping <pre> elements. One held the moving field. The other held the wordmark. Its script sampled a logo into a character grid and distorted that grid near the pointer.

That gave us a useful structure. Our version samples the text abhiigatty in the site’s existing font and generates an ocean-like background. CSS handles the layout and colours. JavaScript calculates the characters.

The effect needed a few visual revisions. Orange letters looked detached from the footer. We tried the name as empty space, then settled on bright white letters with dimmer white waves. Removing the separate background panel made the art feel like part of the footer.

A few terms make the code easier to read

TermWhat it means in this effect
ASCII artAn image assembled from text characters such as dots, hashes, and @ signs.
Character rampA string ordered from visually sparse to dense characters. A number selects one character.
RasterizationTurning the font’s letter shapes into a small grid of pixels we can inspect.
Alpha maskAn array of opacity values that tells us where the letters occupy the grid.
SamplingReading one value from that mask at a chosen coordinate.
DisplacementChanging the coordinate we sample, which makes a letter appear to bend.
FalloffHow quickly an interaction loses strength as distance from the pointer increases.
PhaseA wave’s position in its repeating cycle. Changing it over time makes the wave travel.
Frame budgetThe time available to calculate and display the next image. A 60 Hz display refreshes about every 16.7 ms.

You don’t need a separate element for every character. Our visible output is two text blocks:

<div class="footer-ascii" aria-hidden="true">
  <pre class="footer-ascii-field"></pre>
  <pre class="footer-ascii-mark"></pre>
</div>

The wrapper hides this decorative text from assistive technology. Nobody needs a screen reader to announce several thousand punctuation characters.

Both layers use the same monospace font, font size, and line height. Matching those measurements keeps their cells aligned.

Draw the name once, then read its shape

We create a small canvas in memory and draw the wordmark into it. The canvas itself never appears on the page.

The browser’s getImageData method returns the pixel data. Each pixel has red, green, blue, and alpha components. We only need alpha:

const pixels = context.getImageData(0, 0, columns, rows).data;
const alpha = new Float32Array(columns * rows);

for (let index = 0; index < alpha.length; index += 1) {
  alpha[index] = pixels[index * 4 + 3] / 255;
}

Zero means the cell is outside the letters. One means it is fully covered. Intermediate values describe the softened edges of the font.

Our renderer treats values above 0.3 as part of the name. It gives those cells dense characters and leaves a matching gap in the background layer. That keeps the waves from showing through the white letters.

There is an easy proportion bug here. A monospace character cell is usually taller than it is wide. A square pixel in the mask becomes a rectangle when displayed as text.

We measure the character width and line height, then compensate when drawing the mask:

const cellRatio = charHeight / charWidth;
context.scale(1, 1 / cellRatio);

The full implementation also fits and centres the word inside this corrected coordinate space. This stopped the name from looking tall and narrow.

We rebuild the mask when the stage changes size and when the fonts finish loading. We do not read canvas pixels on every animation frame.

Make a number look like water

The background starts with this character ramp:

const characters = " .:-=+*#%@";

A low value selects a space or dot. A high value selects a dense character. This produces apparent brightness using text alone.

The earlier background used broad sine patterns. To make it look more like an ocean, we gave the field curved crests, finer ripples, and tighter wave spacing near the top.

Here is a simplified version of the current formula. x and y are grid coordinates, and time is in seconds:

const seaX = x / columns;
const depth = y / rows;

const swell = Math.sin(seaX * 12 - time * 0.55)
  + 0.4 * Math.sin(seaX * 23 + time * 0.3);

const phase = Math.sqrt(depth + 0.08) * 32 - time * 1.25
  + swell * (0.65 + depth * 1.5);

const crest = Math.pow(0.5 + 0.5 * Math.cos(phase), 7);
const ripple = 0.5 + 0.5 * Math.sin(seaX * 95 + depth * 28 - time * 1.8);
const brightness = 0.16 + 0.12 * ripple + crest * (0.5 + 0.2 * ripple);

Each part has a visible job. The two slow sine waves bend the crests across the width. The square root packs waves closer together near the top, suggesting distance. Subtracting time moves them toward the bottom.

Raising the crest value to the seventh power narrows the bright band. The finer ripple breaks up that band so it looks less like a clean contour line.

These are art controls. We are not simulating fluid pressure or water particles. Adjusting the constants changes the apparent scale, speed, and roughness of the sea.

Bend the sample coordinates near the pointer

For each cell, we calculate its distance from the pointer. Inside an 18-cell radius, the influence gets stronger toward the centre:

const distanceSquared = dx * dx + dy * dy;
const strength = distanceSquared < 18 * 18
  ? (1 - Math.sqrt(distanceSquared) / 18) ** 2
  : 0;

We use that strength to shift the coordinates for both the wave calculation and the letter mask. Sampling a neighbouring part of the mask makes the wordmark bend without moving individual DOM elements.

The radius is measured in character cells. Because those cells are rectangular, its screen footprint is slightly stretched. That is acceptable for this effect. A circular footprint in pixels would need the same aspect-ratio correction used for the font.

Most cells are outside the pointer radius. They use their original coordinates and skip the distortion calculations.

Why the original hover felt late

We initially kept the inactive pointer at -10000. We also smoothed movement with this calculation on every rendered frame:

pointerX += (targetX - pointerX) * 0.18;

Moving the real pointer over the art only changed targetX. The rendered pointer still started far outside the grid. After ten frames, about 14% of that initial gap remained. After twenty frames, about 2% remained.

With a gap near 10,000 cells, even 2% is much larger than the interaction radius. The effect looked unresponsive while the invisible pointer caught up.

The fix was direct: use the event’s coordinates immediately. The next scheduled frame reads those coordinates. We kept the smooth ocean motion but removed smoothing from pointer position.

Stop work that nobody can see

The old loop requested another frame before checking whether the art was visible. It skipped some rendering, but continued scheduling callbacks and checking layout.

We now use an Intersection Observer to track whether the art intersects the viewport. Leaving the viewport cancels the pending frame and timer. Returning schedules work again.

We also stop when the document becomes hidden. This makes the lifecycle explicit and covers the ambient timer as well as animation frames.

For ambient waves, the scheduler waits about 33 ms before requesting a frame. This limits idle activity to roughly 30 updates per second or fewer. Timer delay, rendering time, and display timing mean this is not a guaranteed 30 fps.

A pointer event clears that waiting timer and requests a frame immediately. requestAnimationFrame queues work before a repaint. If a frame is already queued, we reuse it and keep only the latest pointer coordinates.

That separates two needs: the ocean can move at a modest rate, while the user’s hand gets a prompt response.

Reduce the cost of each frame

Several smaller changes helped keep the loop bounded:

  • The slow swell depends on the column and time. We cache it once per column per frame. Only distorted cells need a fresh calculation.
  • The depth-based part of the phase is shared across a row. We calculate it outside the inner loop.
  • We compare squared distances before taking a square root. Cells outside the interaction radius skip it.
  • We cache the stage’s bounding rectangle for pointer events and invalidate it on scroll, resize, and pointer exit.
  • We update the wordmark’s text only when its character string changes. The ambient background can move while the name stays still.

The CSS character size also grows with the viewport:

font-size: max(6px, 0.96vw);

This prevents the character count from growing indefinitely on wide screens. A larger display gets larger cells. Our 390px-wide local check produced 2,974 characters in the field string, including line breaks, with no horizontal overflow.

There are still per-frame string allocations and text painting. This is a small text renderer, and it remains work for the browser.

Keep motion optional and the art unobstructed

When prefers-reduced-motion is enabled, the renderer uses a fixed time, renders a static state, and ignores pointer animation. It also listens for preference changes while the page is open.

The floating booking button needed a separate adjustment. It already hid when the old footer wordmark entered view. Replacing that wordmark meant its observer no longer found the target.

We updated the target to the ASCII stage. The button now hides and becomes inert while the art is visible, then returns when the reader scrolls away. The footer’s regular booking link stays available.

What we measured, and what we still need to test

A local browser check dispatched a pointer event and watched for the wordmark’s DOM update. One run measured about 4.8 ms. That measures event-to-DOM-mutation time. It does not include the final screen paint or establish a typical response time across devices.

We also added a small runnable test:

cd blog-astro
node tests/footer-ascii.test.mjs
npm run check

The focused test checks that the first pointer frame changes the mark, pointer input bypasses the ambient delay, and offscreen or hidden states cancel pending work. It also checks cached bounds and reduced-motion behaviour.

That test uses mocked canvas and browser APIs. Its timing output is useful for local comparison, but it excludes layout and paint. Astro’s check passed with zero errors and warnings; existing hints remained.

We have not established smooth performance on every device. The next useful check is a real lower-end phone, with the art visible for long enough to notice heat, battery cost, and dropped frames. If text painting becomes the bottleneck, reducing the grid density is the first adjustment to try.

To build your own version, start with a static wordmark mask. Confirm the letter proportions before adding water. Then test the very first pointer movement, leaving the viewport, and reduced motion. Those small checks caught more useful problems than another wave formula would have.