For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. The asciicn agent skill, for building any ASCII effect on the engine, is at /.well-known/agent-skills/asciicn/SKILL.md.

Write your own

Build an ASCII background from scratch on AsciiShader: a tone, a shape, then motion, props and colour.

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,
    }}
  />
);
  • fbm is one of the helpers every field gets, with snoise and hash21. It returns soft, cloudy noise from 0 to 1.
  • Each key in uniforms is declared for you: a number becomes a float, a pair a vec2. 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 AsciiShaderProps passes through, so charset, colorMode, palette, cell, fx and speed work on Ripples as they do on every background. The preview above passes charset="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.

NameWhat it is
u_timeSeconds of motion, scaled by speed. The same value as t.
u_resolutionThe same value as res.
u_pointervec3: the pointer in uv (y up), and z from 0 to 1 while it's over the box. Smoothed.
u_pointerVelocityvec2: how fast the pointer moves, in uv per second.
u_clickvec3: where the last press landed in uv, and z the seconds since.
u_audiovec4: bass, mid, treble and level from the audio prop, each 0 to 1.
u_seedThe 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