This guide builds an ASCII ripple background in four steps. You write one GLSL function, the field, and AsciiShader does the rest: picking glyphs, colouring them, resizing, motion and reduced motion. The same steps make any background you can describe with a little math. Your agent follows them too, through the asciicn skill.
You need the engine installed (see Installation) and a section with a size to put the background in.
Have your agent write it
Your agent can follow this page. Fill in the two blanks and paste this in:
Build an ASCII background for <where it goes> that looks like <what you want>, drawn in <charset>. Follow https://asciicn.dev/docs/engine/write-your-own.md and the asciicn skill's references/fields.md. Write a GLSL field on AsciiShader from lib/ascii/shader and colour it from the theme. Check it in a browser in light and dark mode and with reduced motion.
If the engine isn't installed yet, start with the quick start prompt instead. The rest of this page is the same build, step by step.
1. Return a tone
The field runs once for every sample and returns a tone from 0 to 1: 0 is an empty cell, 1 the densest glyph in the charset. A field that returns the height draws a ramp of glyphs from bottom to top:
"use client";
import { AsciiShader } from "@/lib/ascii/shader";
const FIELD = `
float field(vec2 uv, vec2 res, float t) {
return uv.y;
}
`;
export function Ripples() {
return (
<AsciiShader
field={FIELD}
charset="standard"
palette={["--ascii-paper", "blue"]}
className="absolute inset-0"
/>
);
}uv is the sample's position from 0 to 1 on both axes, with y pointing up. t is time in seconds. res.y is the number of sample rows and res.x / res.y is the box's true aspect ratio, so 1.0 / res.y is the height of one sample row.
A sample is usually one cell, but not always. Braille packs 2×4 samples into each glyph and quadrants 2×2, so the same field drawn in Braille has four times the vertical detail. Write fields in uv and never assume one sample per glyph, and they look right in every charset.
2. Draw a shape
uv stretches with the box, so a circle measured in uv comes out as an oval. Multiply x by the aspect ratio first, and distances are true. Then compute anything that has a distance in it, here rings around a point that fade as they spread:
float field(vec2 uv, vec2 res, float t) {
float aspect = res.x / res.y;
vec2 p = vec2(uv.x * aspect, uv.y);
vec2 drop = vec2(0.5 * aspect, 0.45);
float d = length(p - drop);
float rings = 0.5 + 0.5 * cos(d * 48.0);
float fade = exp(-d * 3.0);
return rings * fade;
}cos turns distance into repeating bands, and exp fades them out. Every tone between 0 and 1 picks a different glyph, so the rings shade from . to @ instead of having hard edges. The engine measures how much ink each glyph in the font really uses and spaces the ramp to match, so a tone of 0.5 looks half as dense as 1.
3. Add motion and props
Three changes finish the shape. Noise bends the rings so they look like water, t moves them outwards, and uniforms turn the fixed numbers into props:
"use client";
import { AsciiShader } from "@/lib/ascii/shader";
import type { AsciiShaderProps } from "@/lib/ascii/shader";
const FIELD = `
float field(vec2 uv, vec2 res, float t) {
float aspect = res.x / res.y;
vec2 p = vec2(uv.x * aspect, uv.y);
vec2 drop = vec2(rippleDrop.x * aspect, 1.0 - rippleDrop.y);
float wobble = fbm(p * 2.2 + vec2(t * 0.03, 0.0)) - 0.5;
float d = length(p - drop) + wobble * 0.08;
float rings = 0.5 + 0.5 * cos(d * rippleRings - t * 0.9);
float fade = exp(-d * 2.6);
return rippleStrength * rings * fade;
}
`;
export type RipplesProps = Omit<AsciiShaderProps, "field" | "uniforms"> & {
/** Where the drop falls, from the top left, 0 to 1. */
drop?: readonly [number, number];
/** How tightly the rings are packed. */
rings?: number;
/** Peak tone, 0 to 1. */
strength?: number;
};
export const Ripples = ({
drop = [0.5, 0.42],
rings = 46,
strength = 0.85,
...props
}: RipplesProps) => (
<AsciiShader
{...props}
field={FIELD}
stillTime={4}
uniforms={{
rippleDrop: drop,
rippleRings: rings,
rippleStrength: strength,
}}
/>
);fbmis one of the helpers every field gets, withsnoiseandhash21. It returns soft, cloudy noise from 0 to 1.- Each key in
uniformsis declared for you: a number becomes afloat, a pair avec2. Changing a prop updates the uniform without recompiling. Prefix the names (ripple…) so they never clash with another field. stillTime={4}is the moment shown when the visitor prefers reduced motion. Pick one that looks finished.- The rest of
AsciiShaderPropspasses through, socharset,colorMode,palette,cell,fxandspeedwork onRipplesas they do on every background. The preview above passescharset="braille".
4. Colour it
By default glyphs take the palette's ink. To colour each glyph on its own, add a colorField: the same arguments as field, returning an sRGB colour. Setting it switches the colour mode to truecolor; colorMode="ansi16" snaps the same colours to the 16 ANSI colours instead.
vec3 colorField(vec2 uv, vec2 res, float t) {
float aspect = res.x / res.y;
float d = length(vec2(uv.x * aspect, uv.y) - vec2(0.5 * aspect, 0.55));
return toSrgb(oklch(0.78, 0.14, d * 9.0 - t * 0.4));
}oklch(L, C, h) returns linear RGB for a lightness, chroma and hue in radians. Hues at the same lightness look equally bright, which a plain HSV rainbow doesn't. Pass the result through toSrgb before returning it. field and colorField compile into one shader, so a helper defined in field can be called from both.
GLSL reference
Fields are GLSL ES 1.00, the dialect of WebGL1, so most shader snippets you find work as written.
| Name | What it is |
|---|---|
u_time | Seconds of motion, scaled by speed. The same value as t. |
u_resolution | The same value as res. |
u_pointer | vec3: the pointer in uv (y up), and z from 0 to 1 while it's over the box. Smoothed. |
u_pointerVelocity | vec2: how fast the pointer moves, in uv per second. |
u_click | vec3: where the last press landed in uv, and z the seconds since. |
u_audio | vec4: bass, mid, treble and level from the audio prop, each 0 to 1. |
u_seed | The seed prop, for seeded variations. |
hash21(p) | A stable random value in [0, 1) for a vec2. |
snoise(p) | 2D simplex noise in [-1, 1]. |
fbm(p) | Four octaves of noise in [0, 1]. |
spectrum(h) | A saturated rainbow; 0 is red and it wraps at 1. |
paletteColor(x) | The palette's inks as a ramp from 0 to 1. |
oklch(L, C, h) | An OKLCH colour as linear RGB, h in radians. |
toSrgb(c) | Linear RGB to sRGB. |
aces(c) | The ACES filmic tone map, for colours brighter than 1. |
Names starting with u_ and ascii_ are reserved. When a field doesn't compile, the console names the line of your field or colorField that failed, and the shader shows its fallback.
Put it on the page
<section className="relative isolate overflow-hidden rounded-3xl">
<Ripples
className="absolute inset-0 -z-10"
charset="dense"
palette={["--ascii-paper", "blue"]}
/>
<div className="px-8 py-32">…</div>
</section>The shader fills its positioned parent and ignores pointer clicks, so your content sits in a sibling on top. Keep the tone low where text sits; here strength caps it at 0.85, and the rings fade before they reach the edges.
Where to go next
- Charsets and Colour modes cover every glyph set and colour mapping a field can use.
- Post effects adds the CRT finish, pointer interaction and audio input.
- Motion and Performance cover pausing, reduced motion and what a shader costs.
- The city skylines and shader fields are built this way. Open one's source for a field to start from.