# asciicn: full documentation > ASCII art for React apps, drawn with real glyphs. asciicn is a WebGL2 and Canvas2D engine with ten charsets (classic, dense, ANSI blocks, quadrants, Braille, CP437, PETSCII, katakana, halftone and Sobel edges), five colour modes and CRT effects, plus a skill that teaches your coding agent to build on it. About 50 shadcn components, among them ten animated city skylines, real-time 3D scenes and terminal UI, and an ASCII Studio show what it draws. Export any frame as text, ANSI, SVG, PNG or WebM. ## Start here asciicn is an ASCII engine, not a component menu. The city skylines, 3D scenes, shader fields, media filters, text effects and terminal pieces in these docs are examples of what the engine draws. When a user asks for an ASCII effect, build the one they asked for: 1. Install the engine and the agent skill: `npx shadcn@latest add https://asciicn.dev/r/asciicn-skill.json`. Then follow the skill (https://asciicn.dev/.well-known/agent-skills/asciicn/SKILL.md). 2. For anything that moves or varies per cell, write a GLSL `field` for `AsciiShader` (`@/lib/ascii/shader`): [Write your own](https://asciicn.dev/docs/engine/write-your-own.md). A field returns a tone from 0 to 1 and the engine picks the glyph. For grids you compute on the CPU (3D meshes, simulations, terminal panes), draw into `AsciiGrid` (`@/lib/ascii`) and map tones with `mapTones`. 3. Pick a charset and a colour mode: [Charsets](https://asciicn.dev/docs/engine/charsets.md) (classic, dense, ANSI blocks, quadrants, Braille 2x4, CP437, PETSCII, katakana, halftone, Sobel edges) and [Colour modes](https://asciicn.dev/docs/engine/color-modes.md) (mono, truecolor, ansi16, duotone, palette). Default colours come from the `--ascii-*` tokens, so the effect follows the site theme. 4. Install an example only when it is what the user asked for. Otherwise read the closest one's source for technique and write your own. # Introduction An ASCII engine, components and an agent skill for drawing real glyphs in React and shadcn/ui apps. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). asciicn draws pictures with characters. It is a WebGL2 and Canvas2D engine that turns a tone into a glyph, about 50 shadcn components built on it (ten of them animated city backgrounds), an ASCII Studio for tuning a look, and an agent skill that teaches your coding agent to build new effects on the same engine. Every component draws real glyphs: letters, block elements, Braille dots, CP437 and PETSCII characters, katakana. Cells snap to whole device pixels, so the text stays sharp at any zoom and on any screen. ## What you get [#what-you-get] * **The engine.** `ascii-core` installs into `lib/ascii` as plain source: charsets, palettes, cell metrics, the `AsciiGrid` canvas and `mapTones`, which turns a grid of tones into glyphs. `ascii-shader` adds `AsciiShader`, a WebGL2 renderer that draws a GLSL field as glyphs from a glyph atlas, with colour modes, pointer interaction, audio input and CRT effects. `ascii-export` saves any frame as `.txt`, `.ans`, `.svg` or `.png`, and records `.webm`. * **The components.** City skylines, 3D scenes, shader fields, image and video filters, text effects and terminal-style interface pieces, each installable with `npx shadcn add`. Browse them in the [catalog](/docs/examples). * **The backgrounds.** Ten animated cities, from Tokyo at night to fog over the Golden Gate Bridge. See them in the [backgrounds gallery](/backgrounds). * **The Studio.** [/studio](/studio) lets you pick a background or component, change its charset, colours and CRT effects, and export the frame or the JSX. * **The agent skill.** A `SKILL.md` and references that teach Claude Code, Codex, Cursor and other agents the engine: which charset suits which picture, how to write a field, where to place it on a page, how to colour it from your theme, and how to check it in a browser. [Installation](/docs/installation) adds it to your project. ## How ASCII art works here [#how-ascii-art-works-here] A picture becomes a grid of cells, each one glyph tall and about half a glyph wide. The engine measures a tone for every cell, from 0 (empty) to 1 (full), and picks the glyph whose ink coverage matches: a space, then `.` and `:`, up to `#` and `@`. That list of glyphs is a charset. Some charsets carry more detail than one tone per cell. Braille glyphs hold a 2x4 grid of dots, so a `braille` cell samples eight points and lights each dot on its own. Quadrant blocks sample 2x2. The `edge` charset reads the direction of the edge under the cell and draws `-`, `/`, `|` or `\` along it. Colour is separate from the glyph. In `mono` every glyph uses `--ascii-ink`. `truecolor` gives each glyph the colour of its source, `ansi16` snaps it to a terminal's 16 colours, `duotone` maps tone onto a two-or-more stop ramp, and `palette` uses your theme colours. ## Charsets [#charsets] | Id | Glyphs | Good for | | ----------- | ----------------------------------- | ------------------------------------ | | `standard` | ` .:-=+*#%@` | Photos, classic ASCII art | | `dense` | 70 glyphs from `.` to `$` | Smooth gradients and faces | | `blocks` | ` ░▒▓█` | Solid fills, bars, heavy shapes | | `quadrants` | `▘▝▀▖▌▞▛█` and the rest of the 2x2 | Twice the resolution in solid blocks | | `braille` | `⠁⠃⠇⡇⣇⣧⣷⣿`, 2x4 dots per cell | Fine lines, wireframes, globes | | `cp437` | `·•○◘♠♣♥♦▲▼☺☻░▒▓█` from the IBM PC | BBS and DOS screens | | `petscii` | `╱╲─│╭╮●◆▄▌█` from the Commodore 64 | Home computer art | | `katakana` | `アウエオカキシツソン` and digits | Signage, glyph streams | | `dots` | ` ·∙•⦁●` | Halftone print | | `edge` | `-` `/` `\|` `\` on edges | Outlines and line drawings | Pass `chars="…"` to use your own ramp, from empty to dense. [Charsets](/docs/engine/charsets) shows each one. ## Ask your agent for any ASCII effect [#ask-your-agent-for-any-ascii-effect] Once the skill is installed, describe what you want and where it goes. Name the page, the mood, the charset if you have one in mind, and anything that must stay readable: * "Behind the hero on the home page, add slow Braille contour lines in our accent colour. Keep the headline readable." * "On the status page, draw the last 90 days of uptime as a row of ANSI blocks, green for up and red for incidents." * "Make the 404 page a green-phosphor CRT terminal that types out the missing path." * "Turn the team photos on the about page into ASCII portraits that sharpen into the photo on hover." * "On the pricing page, redraw our logo in quadrant blocks and have it scramble in when it scrolls into view." You don't need to know which component or API fits. The skill sends the agent through four steps: 1. **Set up the engine.** It checks for `lib/ascii` and installs `ascii-core` and `ascii-shader` if they're missing. 2. **Pick the technique.** A GLSL field on `AsciiShader` for anything that moves or varies per cell, an `AsciiGrid` for grids it computes on the CPU (3D meshes, simulations, terminal panes), and the image sampler for photos and video. It reads the closest example for technique and installs one only when it is exactly what you asked for. 3. **Build the component.** It writes a typed component with props you can tweak, chooses a charset and colour mode that suit the picture, places it behind or inside your content, and colours it from your theme. 4. **Check it in a browser.** It looks at the result in light and dark mode, at desktop and phone widths and with reduced motion, and fixes what doesn't read. ## How the docs are organised [#how-the-docs-are-organised] * **Get started:** this page, [Installation](/docs/installation) and [Theming](/docs/theming). * **Examples:** the [catalog](/docs/examples), then one page per component, grouped into City skylines, 3D & scenes, Shaders & fields, Media & filters, Text & typography, and Terminal & UI. * **Engine:** how `lib/ascii` works, for when you want to build an effect yourself. Start with the [ASCII engine](/docs/engine/ascii-engine) and [Write your own](/docs/engine/write-your-own). ## Principles [#principles] * **Real glyphs, crisp.** Every effect draws characters, never pixel dots, and every cell lands on whole device pixels. * **Your theme, not ours.** Glyphs read your `--primary` and `--background` tokens through the [`--ascii-*` variables](/docs/theming), so they change when your theme changes. * **Quiet by default.** Motion is slow. Every animated effect shows one finished frame when the visitor prefers reduced motion, and pauses offscreen and in hidden tabs. * **Real text stays text.** ASCII headings and labels are still HTML, so they can be selected, read aloud and indexed. Decorative canvases are hidden from screen readers. * **Source you own.** Everything installs as files in your project. You and your agent can read and change all of it. # Installation Add the asciicn engine and agent skill to a shadcn project, or install a single component or background. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). asciicn installs with the shadcn CLI, like any other registry. One command adds the engine and the agent skill. After that you ask your agent for ASCII effects, or install components and backgrounds one at a time. ## Quick start with your agent [#quick-start-with-your-agent] Paste this prompt into Claude Code, Codex, Cursor or any other coding agent. It installs the engine and the skill, reads the docs and builds the effect you ask for. The home page copies the same prompt. ```text Add asciicn to this project and build me an ASCII effect with it. 1. Install the asciicn engine and agent skill with the shadcn CLI: npx shadcn@latest add https://asciicn.dev/r/asciicn-skill.json This writes lib/ascii (the glyph engine and its WebGL2 shader), the skill in .agents/skills/asciicn and .claude/skills/asciicn, and the --ascii-* CSS variables. The project needs shadcn/ui, Tailwind CSS v4 and React 19. 2. Read .agents/skills/asciicn/SKILL.md and follow it for everything below. If it isn't on disk, read https://asciicn.dev/.well-known/agent-skills/asciicn/SKILL.md. The docs are at https://asciicn.dev/docs, and https://asciicn.dev/llms.txt lists every page as Markdown. 3. If I haven't said which effect I want and where it goes, ask me in one question, and suggest two or three that would suit this project, each with a charset (Braille, ANSI blocks, katakana, CP437...). 4. Build the effect I asked for on the engine, coloured from my theme. The example components and scenes are references: install one only if it is exactly what I asked for. Otherwise write your own field on AsciiShader, or an AsciiGrid for CPU-drawn grids, and borrow technique from the closest example's source. 5. Check it in a browser in light and dark mode, at desktop and phone widths and with reduced motion. Glyphs must be crisp and text over them readable. Fix what doesn't read, then tell me what you made and where. ``` The rest of this page does the same steps by hand. ## Before you start [#before-you-start] You need a project that already uses shadcn/ui, with a `components.json` file, Tailwind CSS v4 and React 19. If you don't have one yet, follow the [shadcn installation guide](https://ui.shadcn.com/docs/installation) first. The GPU renderer needs WebGL2, which every current browser has; components show a static fallback where it's missing. ## Add the skill with the skills CLI [#add-the-skill-with-the-skills-cli] The [skills CLI](https://skills.sh) installs the skill into Claude Code, Codex, Cursor, Gemini CLI and other agents that read skills. Run it in your project: ```bash npx skills add https://asciicn.dev ``` It asks which agents to install for, then writes the skill and its references into each agent's skills folder, such as `.claude/skills/asciicn/` and `.agents/skills/asciicn/`. Add `-g` to install it for every project on your machine. The skill adds the engine to your project the first time your agent builds something with it. ## Or add the engine and the skill with shadcn [#or-add-the-engine-and-the-skill-with-shadcn] ### Run the add command [#run-the-add-command] ```bash npx shadcn@latest add https://asciicn.dev/r/asciicn-skill.json ``` This writes: * `lib/ascii/`: the CPU engine (`ascii-core`), the WebGL2 glyph renderer (`ascii-shader`) and the export functions (`ascii-export`). * `.agents/skills/asciicn/` and `.claude/skills/asciicn/`: the agent skill. Claude Code reads `.claude/skills`; Codex, Cursor and other agents that follow the [Agent Skills](https://agentskills.io) layout read `.agents/skills`. * The `--ascii-*` CSS variables (`ascii-tokens`), in the CSS file named in your `components.json`. Commit the skill folders with the rest of your code, so everyone on the project, and every agent, gets the same skill. ### Check your CSS [#check-your-css] The CLI adds the variables for light and dark mode, and colour utilities to the `@theme inline` block: ```css @theme inline { --color-ascii-ink: var(--ascii-ink); --color-ascii-paper: var(--ascii-paper); /* and one per named colour: --color-ascii-blue, --color-ascii-green, … */ } @layer base { :root { --ascii-ink: var(--primary); --ascii-paper: var(--background); --ascii-cell: 12px; --ascii-glow: 0.35; } .dark { --ascii-ink: var(--primary); --ascii-paper: var(--background); --ascii-glow: 0.9; } } ``` Because the variables point at your theme tokens, there is nothing extra to set for dark mode. [Theming](/docs/theming) lists every variable. Then ask your agent for an effect. The [introduction](/docs#ask-your-agent-for-any-ascii-effect) has example requests and explains what the agent does with them. ## What's in the skill [#whats-in-the-skill] | File | What it covers | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `SKILL.md` | The four steps the agent follows, how to choose a technique, and the rules for every effect | | `references/engine-api.md` | The engine's exports: `AsciiShader`, `AsciiGrid`, `mapTones`, charsets, palettes, metrics, motion hooks and export | | `references/fields.md` | Writing a GLSL field: the `uv`/`res`/`t` contract, sub-sampled charsets, uniforms, pointer and audio input | | `references/charsets.md` | Choosing a charset and a colour mode for a picture, custom ramps, phosphors and duotone ramps | | `references/recipes.md` | Complete components to adapt: a shader background, a CPU grid, a Braille trace, a terminal pane, an image, a full animated background | | `references/landing-pages.md` | Designing the page around the effect: start from the product, one memorable hero, true copy, the tells to avoid | | `references/placement.md` | Layering, sizing, server and client components, text over glyphs | | `references/theme.md` | The `--ascii-*` tokens, named colours, palettes, light and dark mode | | `references/export.md` | Saving frames as text, ANSI, SVG and PNG, and recording WebM | | `references/quality.md` | The checks: crisp glyphs, contrast, reduced motion, accessibility, performance | | `references/examples.md` | Every component and background, with its install command and docs link | The skill matches the engine it installs with and points your agent at the files in your project, so it never has to guess at an API. ## Install the skill for every project [#install-the-skill-for-every-project] `shadcn add` writes the skill into the current project only. To make it available in every project on your machine, run the skills CLI with `-g`. Without the CLI, download the archive into your user skills folder: ```bash for dir in ~/.claude/skills/asciicn ~/.agents/skills/asciicn; do mkdir -p "$dir" curl -fsSL https://asciicn.dev/.well-known/agent-skills/asciicn.tar.gz | tar -xz -C "$dir" done ``` The skill tells the agent to install the engine into a project the first time it needs it. ## Install the engine, a component or a background [#install-the-engine-a-component-or-a-background] Without the skill, install the engine on its own. `ascii-core` is the CPU side (charsets, `AsciiGrid`, `mapTones`); add `ascii-shader` for the WebGL2 renderer: ```bash npx shadcn@latest add https://asciicn.dev/r/ascii-shader.json ``` Or install any component or background. Each one brings the parts of the engine it uses on its first install: ```bash npx shadcn@latest add https://asciicn.dev/r/tokyo.json ``` ## Update the engine [#update-the-engine] The engine is plain source in your project, so it doesn't update on its own. Newer docs can use features your copy lacks: if TypeScript says a prop doesn't exist on `AsciiShader` or `AsciiGrid`, delete `lib/ascii` and add the engine again: ```bash rm -rf lib/ascii npx shadcn@latest add https://asciicn.dev/r/ascii-core.json https://asciicn.dev/r/ascii-shader.json https://asciicn.dev/r/ascii-export.json ``` If the CLI asks whether to overwrite `lib/utils.ts`, answer no. Avoid `--overwrite`, which replaces that file too. If you edited any file in `lib/ascii`, copy your changes out first. ## Use a namespace [#use-a-namespace] To install by name instead of by URL, add asciicn to the `registries` field of your `components.json`: ```json title="components.json" { "registries": { "@asciicn": "https://asciicn.dev/r/{name}.json" } } ``` Then install with the `@asciicn` prefix: ```bash npx shadcn@latest add @asciicn/asciicn-skill npx shadcn@latest add @asciicn/tokyo ``` See the shadcn docs on [namespaced registries](https://ui.shadcn.com/docs/registry/namespace) for more options, including authentication. ## Other ways in for agents [#other-ways-in-for-agents] The skill is the main way in. The site also publishes plain-text surfaces for agents that browse: * [`/llms.txt`](/llms.txt) lists every docs page with a one-line description and a Markdown link, and [`/llms-full.txt`](/llms-full.txt) is the whole documentation as one file. * Add `.md` to any docs URL for its Markdown, for example [`/docs/installation.md`](/docs/installation.md), or send an `Accept: text/markdown` header. The home page, [`/studio`](/studio.md) and [`/backgrounds`](/backgrounds.md) answer Markdown requests too. * The registry index is at [`/r/registry.json`](/r/registry.json), and each item at `/r/.json`. The [shadcn MCP server](https://ui.shadcn.com/docs/mcp) can browse and install from it once you have [added the `@asciicn` namespace](#use-a-namespace). * [`/.well-known/agent-skills/index.json`](/.well-known/agent-skills/index.json) lists the skill archive with its SHA-256 digest, [`/.well-known/api-catalog`](/.well-known/api-catalog) links every machine-readable surface (RFC 9727), [`/openapi.json`](/openapi.json) describes the read-only endpoints, and [`/api/v1/status`](/api/v1/status) returns `{"status":"ok"}`. The home page sends these as `Link` headers too. ## Next steps [#next-steps] * [Ask your agent for any ASCII effect](/docs#ask-your-agent-for-any-ascii-effect) with the skill installed. * Browse the [catalog](/docs/examples) or the [backgrounds](/backgrounds) for something to install as it is. * Try a look in the [Studio](/studio) and export the JSX. * [Write your own](/docs/engine/write-your-own) field, step by step. # Theming How asciicn reads your shadcn theme, and how to change glyph colour, cell size, font, glow and palettes. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). Every component reads a few CSS variables, and the main two point at your existing shadcn tokens. Change them and every glyph on the page follows, including the components your agent writes. ## The variables [#the-variables] | Variable | Default | Controls | | ---------------------- | -------------------- | ------------------------- | | `--ascii-ink` | `var(--primary)` | Glyph colour in `mono` | | `--ascii-paper` | `var(--background)` | Surface behind the glyphs | | `--ascii-cell` | `12px` | Glyph cell height | | `--ascii-font` | the site's mono font | Glyph font | | `--ascii-glow` | `0.35`, dark `0.9` | Bloom strength, 0 to 1 | | `--ascii-color-` | tuned per theme | Seven named colours | The cell width follows the font, so glyphs keep their shape. `--ascii-ink` is also the strongest colour of the default palette. [`ascii-tokens`](/docs/installation) writes these into both `:root` and `.dark`. The named colours are `blue`, `purple`, `green`, `pink`, `orange`, `red` and `grey`. Components accept those names and `"ink"` wherever they take a colour, and Tailwind gets matching utilities such as `text-ascii-blue` and `bg-ascii-paper`. When the visitor switches to dark mode, `--primary` and `--background` change, and every component changes with them on the next frame. ## Override for one section [#override-for-one-section] Variables cascade, so you can retint one part of the page by setting them on a wrapper: ```css .pricing-hero { --ascii-ink: var(--chart-2); --ascii-cell: 16px; } ``` Anything inside `.pricing-hero` draws in the new colour with larger glyphs. The rest of the page is unchanged. Cell sizes between 10px and 16px read as texture; above 20px each glyph reads as a letter. ## Phosphors [#phosphors] For a terminal look, set `colorMode="mono"` and a `phosphor` instead of the theme ink. Each phosphor is a CRT screen colour: | Phosphor | Colour | Looks like | | ----------- | --------- | ------------------------- | | `p1` | `#33ff66` | Green monochrome terminal | | `p3` | `#ffb000` | Amber terminal | | `ibm` | `#f4f4f5` | White IBM monitor | | `blueprint` | `#38bdf8` | Cyan drafting screen | ```tsx ``` Phosphors are fixed colours, so check them in light mode: on a white page, amber and green need a dark `--ascii-paper` behind them, such as `background="paper"` on a `bg-neutral-950` box. ## Duotone ramps and palettes [#duotone-ramps-and-palettes] `colorMode="duotone"` maps each glyph's tone onto a colour ramp, from dark to bright. The engine ships ten ramps: `mainframe`, `sodium`, `heliograph`, `infrared`, `outrun`, `orchid`, `glacier`, `ember`, `toxic` and `vapor`. `colorMode="palette"` uses colours you pass, paper first and then the inks from faint to strong. Theme tokens keep it in step with light and dark mode: ```tsx ``` `truecolor` gives each glyph the colour of its source (an image, a video, or a `colorField` you write), and `ansi16` snaps that colour to the 16 colours of a terminal, using the `vga` or `xterm` table. [Colour modes](/docs/engine/color-modes) shows all five side by side. ## Light and dark [#light-and-dark] Dark mode comes from the `.dark` class on ``, as in shadcn. The engine watches that class and the `--ascii-*` variables and redraws when they change, so a theme toggle never needs a reload. Glyphs are thin, so they need more contrast than a filled shape. Keep at least 4.5:1 between `--ascii-ink` and `--ascii-paper`, the same as body text. In light mode, sparse charsets (`standard`, `dots`, `braille`) can look faint; a denser charset or a larger `--ascii-cell` brings them back. In dark mode, `--ascii-glow` adds bloom around bright glyphs; keep it lower in light mode, where bloom washes out. ## Text over glyphs [#text-over-glyphs] When a heading sits over an animated background, fade the background's tone toward 0 behind the text, or give the text its own `bg-background/80` panel. Real text stays HTML on top; the canvas underneath is `aria-hidden`. # ASCII engine How the asciicn engine turns a field of tones into real glyphs, the two renderers that draw them, and the registry items you install. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). Every asciicn component draws characters. A picture becomes a grid of glyph cells, each cell gets a tone between 0 (empty) and 1 (densest ink), and the engine picks the glyph that matches that tone. It installs into `lib/ascii` as plain source you own. ```tsx import { AsciiGrid, mapTones } from "@/lib/ascii"; import { AsciiShader } from "@/lib/ascii/shader"; import { toSvg } from "@/lib/ascii/export"; ``` ## From tones to glyphs [#from-tones-to-glyphs] Drawing a frame takes three steps, and every renderer follows them in the same order: 1. **Sample.** The box is split into glyph cells, about twice as tall as they are wide. Each cell holds one sample, or a small grid of them for [sub-cell charsets](/docs/engine/charsets#sub-cell-charsets): 2×2 for quadrants, 2×4 for Braille. Each sample gets a tone and, optionally, a colour. 2. **Map.** Tones are graded (contrast, brightness, gamma), optionally inverted, then turned into glyphs. A ramp such as `" .:-=+*#%@"` picks the level nearest the tone. Braille and quadrants light one dot per sample above `threshold`. The `edge` charset runs a Sobel filter and draws `- / | \` along strong edges. 3. **Paint.** Each glyph is drawn in its cell, coloured by the [colour mode](/docs/engine/color-modes): one ink, the source colour, the nearest ANSI colour, or a ramp read by tone. `mapTones` in `lib/ascii/frame.ts` is the reference for step 2. The GPU shader implements the same rules in GLSL, with one difference: it spaces ramp glyphs by the ink they measure in your font, where `mapTones` spaces them evenly. Pass `mapTones` the GPU's spacing with `positions` (see [Charsets](/docs/engine/charsets)) and a frame read back from the GPU and one mapped on the CPU agree glyph for glyph. With few glyphs, a smooth gradient breaks into bands. Set `dither` to a matrix such as `"bayer4"` and a tone between two glyphs picks the denser one in a share of the cells that matches how far along it is. Turn on **Dither levels** in the demo to see the bands soften. ## Two renderers [#two-renderers] | You want | Use | Drawn with | | ------------------------------------------------ | -------------------------- | --------------------------- | | A full-bleed animated field, written in GLSL | `AsciiShader` | WebGL2 and a glyph atlas | | Anything computed in JavaScript: 3D, sims, media | `AsciiGrid` | Canvas2D and the same atlas | | Static art, banners, server-rendered fallbacks | `AsciiPre` | Real text in a `
`      |
| Glyph-filled buttons, bars and panels            | `fillStyle`, `FillPattern` | CSS masks and SVG patterns  |

`AsciiShader` runs your `field` function on the GPU for every sample, every frame. It is the fastest option by far and suits backgrounds and scenes. [Write your own field](/docs/engine/write-your-own) walks through one.

`AsciiGrid` calls a `draw(context)` function you write in TypeScript. You fill `context.frame` with glyphs, by hand (`frame.write`, `frame.put`) or by building a tone field and calling `mapTones`. It paints at device resolution, pauses offscreen and follows the theme. Painting copies each glyph out of the shared glyph atlas and tints the whole frame in one pass, so a truecolor frame with thousands of distinct colours costs the same as a one-colour frame.

Both renderers draw blocks, shades, quadrants, Braille, dots and box lines from the same geometry in `lib/ascii/glyphs.ts`, snapped to whole device pixels. They never depend on which glyphs your font covers, and neighbouring blocks meet without seams. Letters, digits and katakana come from `--ascii-font`.

```tsx
 {
    for (let x = 0; x < columns; x += 1) {
      const y = Math.round(rows / 2 + Math.sin(x * 0.2 + time) * (rows / 3));
      frame.put(x, y, "█", [51, 255, 102]);
    }
  }}
/>
```

## Text without a canvas [#text-without-a-canvas]

`AsciiPre` renders a frame as real text, with one `` per colour run. It needs no JavaScript in the browser, so it works in server components, emails and RSS. Readers can select and copy it.

```tsx title="examples/engine-pre.tsx"
import { AsciiPre, BOX, eighthsBar, MutableFrame } from "@/lib/ascii";

// A status panel built with MutableFrame and rendered as real text on the
// server. Select it: it copies as text.
const panel = () => {
  const width = 38;
  const frame = new MutableFrame(width, 7, { color: true });
  const box = BOX.rounded;
  const dim: readonly [number, number, number] = [130, 130, 150];
  frame.write(0, 0, `${box.tl}${box.h.repeat(width - 2)}${box.tr}`, dim);
  for (let y = 1; y < 6; y += 1) {
    frame.put(0, y, box.v, dim);
    frame.put(width - 1, y, box.v, dim);
  }
  frame.write(0, 6, `${box.bl}${box.h.repeat(width - 2)}${box.br}`, dim);
  frame.write(2, 0, " ascii-core ", [255, 176, 0]);
  const rows = [
    ["cpu", 0.62, [51, 255, 102]],
    ["gpu", 0.84, [56, 189, 248]],
    ["mem", 0.37, [255, 176, 0]],
  ] as const;
  for (const [index, [label, value, color]] of rows.entries()) {
    frame.write(2, index + 2, label, dim);
    frame.write(7, index + 2, eighthsBar(value, 22), color);
    frame.write(30, index + 2, `${Math.round(value * 100)}%`.padStart(4));
  }
  return frame;
};

export default function EnginePre() {
  return ;
}
```

```tsx
const frame = new MutableFrame(38, 7, { color: true });
frame.write(2, 2, eighthsBar(0.62, 22), [51, 255, 102]);

;
```

It also takes a plain string: ``.

## Glyph fills [#glyph-fills]

Interface pieces fill shapes with tiled glyphs instead of flat colour. `fillStyle` returns inline styles that mask any element to a glyph texture. `FillPattern` and `AsciiPattern` are SVG ``s for filling SVG shapes. All of them render on the server and follow the theme through CSS variables.

```tsx title="examples/engine-fills.tsx"
import { FILL_TEXTURES, fillStyle } from "@/lib/ascii";

// Every fill texture as a CSS mask on a plain 
: the dense edge at the // bottom, thinning upwards. No canvas, renders on the server. export default function EngineFills() { return (
{FILL_TEXTURES.map((texture) => (
{texture}
))}
); } ``` | Texture | Glyphs | Looks like | | --------- | ---------- | -------------------------------------------- | | `shade` | `█▓▒░·` | The classic ANSI gradient, densest at `edge` | | `ascii` | `#*+=-:.` | The same ramp in ASCII | | `braille` | `⣿` to `⠂` | Fine dots, a light texture for large fills | | `blocks` | `█▛▚▘` | Chunky quadrant steps | | `dots` | `●•·` | Halftone | | `hatch` | `╱` | Unbroken diagonal lines | | `solid` | none | Flat colour | ```tsx
``` Ramp textures fade from `from` at the `edge` to `to` over `length` cells, or take a `level` for one even density. The older names `gradient`, `dotted` and `hatched` still work and draw `shade`, an even field of `dots`, and `hatch`. Glyphs in fills are drawn from geometry, because CSS masks can't load your web font. A custom ramp passed as `chars` with letters falls back to the system monospace font. `BloomBox`, `BloomGroup` and `bloomFilter` add a phosphor glow in the fill's colour. Its strength follows `--ascii-glow`, so glows can be strong in dark mode and faint in light mode. ## Registry items [#registry-items] | Item | Contains | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ascii-core` | Charsets, colour modes, frames and `mapTones`, cell metrics, glyph geometry and atlas, `AsciiGrid`, `AsciiPre`, `CrtOverlay`, media sampling, glyph fills, theme and motion hooks | | `ascii-shader` | `AsciiShader`, the WebGL2 renderer | | `ascii-export` | `.txt`, `.ans`, `.svg`, `.png` and video export | | `ascii-tokens` | The `--ascii-*` CSS variables | ```bash npx shadcn@latest add https://asciicn.dev/r/ascii-core.json ``` Components pull in what they need as registry dependencies, so adding any component installs the engine with it. Components that only draw text or interface pieces never ship WebGL code. # Charsets The ten built-in glyph sets, how Braille and quadrants pack several samples into one cell, and how to bring your own ramp. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). A charset is the set of glyphs a renderer draws tones with. Every component that draws a field takes a `charset` prop, and the same picture reads very differently in each one: ```tsx ``` ## Built-in charsets [#built-in-charsets] | `charset` | Glyphs | Samples per cell | Good for | | ----------- | -------------------------------------- | ---------------- | ------------------------------------------------- | | `standard` | `" .:-=+*#%@"` | 1 | Photos and anything that should read as ASCII art | | `dense` | 70 glyphs, `.` to `$` | 1 | Smooth tone on large grids | | `blocks` | `" ░▒▓█"` | 1 | Bold fills, the ANSI-art look | | `quadrants` | `▘▝▀▖▌▞▛▗▚▐▜▄▙▟█` | 2×2 | Blocky shapes with twice the detail | | `braille` | 256 dot patterns, `⠁` to `⣿` | 2×4 | Fine lines, plots, wireframes | | `cp437` | IBM PC symbols, `·` to `█` | 1 | DOS-era texture | | `petscii` | Commodore lines and blocks | 1 | Retro computer screens | | `katakana` | Half-width katakana and digits | 1 | Signage, glyph streams | | `dots` | `" ·∙•⦁●"`, five sizes | 1 | Halftone print and painted scenes | | `edge` | `- / \ \|` on edges, `standard` inside | 1 | Line drawings and outlines | Ramps are ordered by how much ink each glyph puts on the cell in a typical monospace font, so they read correctly even where coverage can't be measured, such as in an `AsciiPre` rendered on the server. `CHARSET_NAMES` lists the ids in picker order and `CHARSETS[id]` holds each set's label, kind, glyphs and sample grid. ## Halftone dots [#halftone-dots] `dots` draws five round dots after the empty cell, from geometry rather than the font, so they are round in every font. Their radii are 0.07, 0.13, 0.24, 0.33 and 0.42 of the cell. The two smallest are specks: a dark sky drawn with them reads as quiet air, and the large dots stay for the light. With `dither` set, a tone between two sizes mixes them in a fixed pattern, so gradients don't band. Set `square` with `dots`. Default cells are about twice as tall as wide, so the rows of dots sit twice as far apart as the columns and a picture reads as stripes. Square cells lay the dots on an even lattice. See [Cell metrics](/docs/engine/cell-metrics#square-cells) and [Painted scenes](/docs/engine/painted-scenes). ```tsx ``` ## Sub-cell charsets [#sub-cell-charsets] Braille and quadrant glyphs aren't a ramp. Each one is a tiny bitmap: Braille has 8 dots in 2 columns of 4, quadrants have 4 squares in a 2×2 grid. The engine samples the field once per dot, lights each dot whose tone is above `threshold` (0.5 by default), and packs the lit dots into one glyph. Click the samples below: ```tsx title="examples/engine-subcells.tsx" "use client"; // Braille and quadrant masks are bit fields. // oxlint-disable no-bitwise import { useState } from "react"; import { BRAILLE_DOT_BITS, brailleChar, QUADRANT_BITS, quadrantChar, } from "@/lib/ascii"; import { cn } from "@/lib/utils"; interface CellEditorProps { title: string; bits: readonly (readonly number[])[]; glyph: (mask: number) => string; base: number; initial: number; } // A grid of sub-samples you can switch on and off, and the one glyph they pack into. const CellEditor = ({ title, bits, glyph, base, initial }: CellEditorProps) => { const [mask, setMask] = useState(initial); const char = glyph(mask); return (
{bits.flatMap((row, y) => row.map((bit, x) => { const on = (mask & bit) !== 0; return ( ); }) )}
{char === " " ? " " : char}

{title} · mask 0x{mask.toString(16).padStart(2, "0")} · U+ {(char.codePointAt(0) ?? base) .toString(16) .toUpperCase() .padStart(4, "0")}

); }; // Click samples to light them: Braille packs 2x4 into one glyph, quadrants 2x2. export default function EngineSubcells() { return (
); } ``` Braille glyphs start at U+2800, and each dot adds one bit: dots 1 to 3 run down the left column (`0x01`, `0x02`, `0x04`), dots 4 to 6 down the right (`0x08`, `0x10`, `0x20`), and dots 7 and 8 sit on the bottom row (`0x40`, `0x80`). `brailleChar(mask)` builds the glyph and `BRAILLE_DOT_BITS[row][column]` gives each dot's bit. Quadrants use `quadrantChar(mask)` with top-left 1, top-right 2, bottom-left 4 and bottom-right 8. Because the sample grid is finer than the cell grid, a field must not assume one sample per glyph. In GLSL, `res` is measured in sample rows; on the CPU, `createToneField(columns, rows, charset)` sizes the field for you. Lower `threshold` lights more dots and thickens thin features. With `dither` set, each dot uses the matrix threshold at its sample instead, which turns soft tones into an even scatter of dots. ## Edge glyphs [#edge-glyphs] The `edge` charset runs a 3×3 Sobel filter on the graded tones. Where the gradient is stronger than `edgeThreshold` (0.22 by default), the cell draws the glyph that runs across it: `|` for a left-right change, `-` for top-bottom, `/` and `\` for diagonals. Everywhere else it falls back to the `standard` ramp. `edgeChar(gx, gy)` does the direction step on its own, with y pointing down. ## Custom ramps [#custom-ramps] Pass `chars` with your own glyphs, from empty to densest. It wins over `charset`. A ramp needs at least two glyphs; anything shorter falls back to `standard`. ```tsx ``` Start with a space so empty tones stay empty. Order the rest by how much ink they put on the cell, not alphabetically. The safest way to check is to look: a smooth gradient should brighten steadily with no glyph that jumps out. ## Calibration [#calibration] A font draws `:` and `;` at almost the same weight, so an evenly spaced ramp wastes levels on glyphs you can't tell apart. The GPU renderer measures how much of the cell each glyph inks in your actual font (the glyph atlas does this once per font and size) and spaces the ramp by that coverage, blended with even spacing so every glyph stays reachable. `mapTones` on the CPU spaces glyphs evenly by default, which is what you want for exported text, because the reader's font is unknown. So at tone 0.5 the two can pick different glyphs. To match the GPU exactly in the browser, pass `positions: charsetRampPositions({ charset, metrics })` to `mapTones`. ## Helpers [#helpers] | Helper | Returns | | ------------------------------------------------------ | --------------------------------------------------------------------- | | `resolveCharset(charset, chars?)` | The `Charset` to draw with, custom ramp first | | `rampChar(charset, tone)` | The ramp glyph nearest a tone | | `eighthsBar(value, width)` | A row of eighth blocks, such as `"███▎ "` for 0.55 of 6 | | `EIGHTHS_UP`, `EIGHTHS_RIGHT` | `" ▁▂▃▄▅▆▇█"` and `" ▏▎▍▌▋▊▉█"`, for bars and progress | | `BOX.light` / `heavy` / `double` / `rounded` / `ascii` | Corners, edges and crossings for frames | | `SPINNERS` | Frame sequences: `braille`, `orbit`, `arc`, `bar`, `quadrant`, `tick` | # Colour modes How glyphs get their colour: one phosphor ink, the source's own colour, the 16 ANSI colours, a duotone ramp or your theme palette. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). The charset decides which glyph a cell draws. The colour mode decides what colour it draws in. Every renderer takes the same `colorMode` prop: | `colorMode` | Each glyph is drawn in | | ----------- | ------------------------------------------------------------- | | `mono` | One ink colour: `--ascii-ink`, a phosphor, or your `ink` prop | | `truecolor` | The source colour under its cell, graded | | `ansi16` | That colour snapped to the nearest of the 16 ANSI colours | | `duotone` | A multi-stop ramp, read by the cell's tone | | `palette` | Your `palette`, from paper to ink, read by tone | ## Mono and phosphors [#mono-and-phosphors] `mono` is the default for most components. Glyphs take `--ascii-ink`, which follows your theme's `--primary`, and the background is `--ascii-paper`. Pass `ink` and `paper` to override them, or pick a CRT phosphor: | `phosphor` | Ink | Glass | | ----------- | --------- | --------- | | `p1` | `#33ff66` | `#020a04` | | `p3` | `#ffb000` | `#0c0700` | | `ibm` | `#f4f4f5` | `#09090b` | | `blueprint` | `#38bdf8` | `#020a12` | ```tsx ``` ## Truecolor and grading [#truecolor-and-grading] `truecolor` gives each glyph the average colour of the samples in its cell, so a photo keeps its colours while the glyphs keep its tones. On the GPU, a field supplies colour through `colorField`; on the CPU, through the `colors` of a tone field (`sampleToField` fills them from images and video). `grade` shapes tones and colours before anything else happens: ```tsx ``` * `contrast` multiplies around mid grey. 1 leaves tones alone. * `brightness` is added after contrast, from -1 to 1. * `gamma` above 1 lifts the midtones, below 1 sinks them. * `saturation` scales colour around its luma: 0 is grey. The same curve runs on the tone (which picks the glyph) and on each colour channel, so a brighter grade draws both denser glyphs and lighter colours. ## ANSI 16 [#ansi-16] `ansi16` snaps each glyph's colour to the nearest of 16 terminal colours, measured in OKLab so the match follows what the eye sees. Pick the table with `ansi`: * `vga`: the IBM PC text-mode palette, with its famous brown `#aa5500` in place of dark yellow. * `xterm`: xterm's defaults, brighter and more saturated. Frames drawn in `ansi16` export to [ANSI escape art](/docs/engine/export#ansi) that looks the same in any terminal: `toAnsi(frame, { mode: "ansi16" })` writes the classic 30 to 37 and 90 to 97 colour codes. ## Duotone ramps [#duotone-ramps] `duotone` ignores the source colour and reads each cell's tone through a multi-stop ramp, dark to light. Every ramp starts on a colour that sits well on near-black and ends on one that holds up as the densest glyph: ```tsx title="examples/engine-ramps.tsx" import { AsciiPre, fillToneField, mapTones, createToneField, RAMP_NAMES, } from "@/lib/ascii"; const COLUMNS = 44; // Each duotone ramp along a left-to-right tone gradient, rendered on the // server as a
: no canvas, no JavaScript.
const strip = (ramp: (typeof RAMP_NAMES)[number]) => {
  const field = createToneField(COLUMNS, 1, "blocks");
  fillToneField(field, (u) => u);
  return mapTones(field, COLUMNS, 1, {
    charset: "blocks",
    colorMode: "duotone",
    dither: "bayer4",
    ramp,
  });
};

export default function EngineRamps() {
  return (
    
{RAMP_NAMES.map((ramp) => (
{ramp}
))}
); } ``` ```tsx ``` `ramp` takes a name from `RAMP_NAMES` or your own list of `#rrggbb` stops. ## Palette [#palette] `palette` reads tone through colours you choose, from paper to ink. Shader components take CSS variable names, so the palette follows your theme: ```tsx ``` `mapTones` on the CPU takes the same idea as byte triples: `palette: [[16, 16, 24], [59, 75, 216], [255, 251, 234]]`. ## Transparent backgrounds [#transparent-backgrounds] `background="transparent"` draws glyphs only, so the page shows between them. Use it to lay ASCII over a photo, a gradient or another component. `AsciiGrid` is transparent by default; set `paper` to give it a background. ## Theme tokens [#theme-tokens] Components never hard-code colours. They read these CSS variables from the `ascii-tokens` item: | Token | Default | | ---------------------- | ------------------------------------------------------------ | | `--ascii-ink` | Your theme's `--primary` | | `--ascii-paper` | Your theme's `--background` | | `--ascii-color-{name}` | Series colours: blue, purple, green, pink, orange, red, grey | | `--ascii-glow` | Glow strength, 0 to 1, per theme | Pass a palette name such as `"green"` wherever a component takes an `AsciiColor`, and it resolves to `--ascii-color-green`. `seriesColor(color, index)` gives several lines or labels distinct hues in a fixed order. # Cell metrics How big one glyph cell is, how it stays sharp on every screen, and how to change its size and font. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). Every renderer lays glyphs on a grid of cells. A cell is as tall as one line of text and as wide as one glyph advance in the font, so it is about twice as tall as it is wide, unless you ask for [square cells](#square-cells). Its height sets the grain: small cells read as texture, large ones as type. ## Setting the size [#setting-the-size] `cell` is always the cell **height** in CSS pixels. It comes from the `--ascii-cell` variable, 12px by default. Change it for the whole site, or for one section on a wrapper: ```css .pricing-hero { --ascii-cell: 16px; } ``` To size one component, pass `cell`. It wins over the variable: ```tsx ``` The font size is the cell height divided by 1.2, the line height glyphs are set at, so a 12px cell sets glyphs at 10px. The width follows the font: most monospace fonts advance 0.6em, which makes a 12px cell 6px wide. ## Square cells [#square-cells] Pass `square` to make cells as wide as they are tall. `cell` then sets both sides, so `cell={7}` is a 7x7 px cell and about 200 columns across a 1440px screen. ```tsx ``` Square cells are for ramps of round glyphs, above all `dots`: the dots lie on an even halftone lattice instead of rows twice as far apart as their columns. Text glyphs sit centred with room around them. Braille and quadrants stretch to fill the square, so leave `square` off for them. Fields don't change: `res.x / res.y` is still the box's true aspect ratio, and `res.y` still counts sample rows. Both `AsciiShader` and `AsciiGrid` take `square`, and `measureCell({ square: true })` returns the square metrics. ## The font [#the-font] Glyphs that come from a font use `--ascii-font`, a monospace stack starting at `ui-monospace`. Set it to your own: ```css :root { --ascii-font: "JetBrains Mono", ui-monospace, monospace; } ``` `measureCell` measures the font's real advance once per family and measures again when a web font finishes loading, so the grid never drifts out of step with the glyphs. Blocks, shades, quadrants, Braille, dots and box lines don't come from the font at all: they are drawn from shapes, so they look the same whichever font you pick. ## Sharp on every screen [#sharp-on-every-screen] A glyph edge that falls between two device pixels turns into a blurry grey line. To avoid it, `measureCell` rounds both sides of the cell to whole device pixels: at 2× density, a 12px cell is exactly 24 device pixels tall, and its width rounds to the nearest whole device pixel. The canvas is sized in whole cells, and block edges inside a cell snap to whole pixels too. The cost is that the CSS size of a cell can be fractional (6.5px at 2×, for example). The grid still covers the box; the last column or row is clipped by the component. ```ts const metrics = measureCell({ cell: 12, font: readFont(element) }); // { width: 6, height: 12, deviceWidth: 12, deviceHeight: 24, dpr: 2, fontSize: 10, font: "…" } const { columns, rows } = gridSize(metrics, box.width, box.height); ``` ## Reading the tokens [#reading-the-tokens] | Helper | Returns | | ------------------- | ------------------------------------------------------- | | `readCell(el)` | `--ascii-cell` on an element in pixels, or 12 | | `readFont(el)` | `--ascii-font` on an element, or the default stack | | `readGlow(el)` | `--ascii-glow` (or the older `--ascii-bloom`), 0 to 1 | | `measureCell(opts)` | `CellMetrics` for a height, font and device pixel ratio | | `gridSize(m, w, h)` | Whole columns and rows that cover a box | `readAsciiCell` is the same as `readCell`, kept under its older name. ## Picking a size [#picking-a-size] * 8 to 10px for dense backgrounds and photos, where you want the picture first. * 12px for most components. Glyphs are still legible, so the ASCII reads as text. * 16px and up for hero text, logos and anything that should look like a terminal at arm's length. Braille and quadrants carry 8 and 4 samples per cell, so they keep detail at larger cells than ramps do. # Write your own Build an ASCII background from scratch on AsciiShader: a tone, a shape, then motion, props and colour. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). 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](/docs/installation). You need the engine installed (see [Installation](/docs/installation)) and a section with a size to put the background in. ## Have your agent write it [#have-your-agent-write-it] Your agent can follow this page. Fill in the two blanks and paste this in: ```text Build an ASCII background for that looks like , drawn in . 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](/docs/installation#quick-start-with-your-agent) instead. The rest of this page is the same build, step by step. ## 1. Return a tone [#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: ```tsx "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 ( ); } ``` ```tsx title="examples/engine-field-tone.tsx" "use client"; import { AsciiShader } from "@/lib/ascii/shader"; // Step 1: the tone is just the height. 0 at the bottom (empty), 1 at the top (densest glyph). const FIELD = ` float field(vec2 uv, vec2 res, float t) { return uv.y; } `; export default function EngineFieldTone() { return ( ); } ``` `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 [#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: ```glsl 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; } ``` ```tsx title="examples/engine-field-rings.tsx" "use client"; import { AsciiShader } from "@/lib/ascii/shader"; // Step 2: square units, then rings around a point that fade with distance. 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(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; } `; export default function EngineFieldRings() { return ( ); } ``` `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 [#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: ```tsx "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 & { /** 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) => ( ); ``` ```tsx title="examples/engine-field-ripples.tsx" "use client"; import { AsciiShader } from "@/lib/ascii/shader"; import type { AsciiShaderProps } from "@/lib/ascii/shader"; // Step 3: noise bends the rings, time moves them outwards, and uniforms // turn the numbers into props. 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; } `; type RipplesProps = Omit & { /** 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; }; const Ripples = ({ drop = [0.5, 0.42], rings = 46, strength = 0.85, ...props }: RipplesProps) => ( ); export default function EngineFieldRipples() { return (

Ripples, in eight lines of GLSL.

); } ``` * `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 [#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. ```glsl 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)); } ``` ```tsx title="examples/engine-gpu-color.tsx" "use client"; import { AsciiShader } from "@/lib/ascii/shader"; // The ripples from step 3, coloured by a second function: hue turns with // the distance from the drop, in OKLCH so every hue reads equally bright. const FIELD = ` float field(vec2 uv, vec2 res, float t) { float aspect = res.x / res.y; vec2 p = vec2(uv.x * aspect, uv.y); float wobble = fbm(p * 2.2 + vec2(t * 0.03, 0.0)) - 0.5; float d = length(p - vec2(0.5 * aspect, 0.55)) + wobble * 0.08; return 0.85 * (0.5 + 0.5 * cos(d * 46.0 - t * 0.9)) * exp(-d * 2.6); } `; const COLOR = ` 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)); } `; export default function EngineGpuColor() { return ( ); } ``` `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 [#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 [#put-it-on-the-page] ```tsx
…
``` 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 [#where-to-go-next] * [Charsets](/docs/engine/charsets) and [Colour modes](/docs/engine/color-modes) cover every glyph set and colour mapping a field can use. * [Post effects](/docs/engine/post-effects) adds the CRT finish, pointer interaction and audio input. * [Motion](/docs/engine/motion) and [Performance](/docs/engine/performance) cover pausing, reduced motion and what a shader costs. * The [city skylines](/docs/backgrounds/tokyo) and [shader fields](/docs/fields/nebula) are built this way. Open one's source for a field to start from. # Painted scenes Build a full-bleed background that reads as a picture: a place, a time of day and one light, painted in halftone dots. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). A painted scene is a background that reads as a picture. It paints every cell in colour, including the darkest sky, and the size of each dot follows the colour's value. The landscape and city [backgrounds](/backgrounds) are built this way. The scene below is a complete starter: a moonlit lake between snowy peaks, about 170 lines of GLSL. ```tsx title="examples/engine-painted-scene.tsx" "use client"; import type { AsciiColor, AsciiSceneProps, ColorMode } from "@/lib/ascii"; import { AsciiShader } from "@/lib/ascii/shader"; // A moonlit lake between hills: the starter scene from the painted scenes // guide. The scene is painted in colour first; the tone (dot size) is read // back from that colour, so size and colour always agree. const SCENE = ` // Set once per sample by lakeColor and read by every layer. float lakeT; float lakeHorizon; float lakeHalfW; float lakeScale; float lakeSqueeze; vec2 lakeMoon; // A stable random value per cell. hash21 repeats every 50 whole cells. float lakeHash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } // Deep blue overhead, dusty violet at the horizon, a cool glow around the // moon, thin cloud lit only near it, and sparse stars high up. vec3 lakeSky(vec2 p, vec2 cellId) { float h = clamp((p.y - lakeHorizon) / (1.0 - lakeHorizon), 0.0, 1.0); vec3 col = mix(vec3(0.44, 0.38, 0.58), vec3(0.13, 0.14, 0.3), smoothstep(0.0, 0.35, h)); col = mix(col, vec3(0.04, 0.05, 0.12), smoothstep(0.3, 1.0, h)); // Undo the phone squeeze so the moon and its halo stay round. float r = length(vec2((p.x - lakeMoon.x) / lakeSqueeze, p.y - lakeMoon.y)); col += vec3(0.2, 0.21, 0.3) * exp(-r * 3.0); col += vec3(0.5, 0.5, 0.56) * exp(-r * r * 260.0); float streak = fbm(vec2(p.x * 1.1 - lakeT * 0.006, p.y * 9.0)); float cloud = smoothstep(0.55, 0.75, streak) * smoothstep(0.04, 0.16, h) * (1.0 - smoothstep(0.45, 0.65, h)); vec3 cloudCol = mix(vec3(0.1, 0.1, 0.2), vec3(0.62, 0.6, 0.7), exp(-r * 5.0)); col = mix(col, cloudCol, cloud * 0.7); float star = step(0.994, lakeHash(cellId)) * smoothstep(0.35, 0.7, h); col += vec3(0.6, 0.62, 0.7) * star * (0.6 + 0.4 * sin(lakeT * 0.7 + lakeHash(cellId + 3.0) * 40.0)); float disc = 1.0 - smoothstep(0.034, 0.04, r); return mix(col, vec3(1.0, 0.96, 0.86), disc); } // The top of layer i (0 the far snow range, 1 wooded hills, 2 the near // shore) above the horizon. Two noise scales, so no edge is straight and // no two peaks match. float lakeRidge(float x, float i) { float freq = 1.1 + i * 0.6; float n = fbm(vec2(x * freq + i * 7.3, i * 3.1)); float ridged = 1.0 - abs(snoise(vec2(x * freq * 0.8, i + 9.0))); float height = mix(0.26, 0.07, i * 0.5) * mix(n, ridged, mix(0.6, 0.2, i * 0.5)) * lakeScale; // The near layer dips under the water in the middle and right, so it // frames the lake instead of crossing it. float near = step(1.5, i) * smoothstep(-0.1, 0.5, x / lakeHalfW); return lakeHorizon + mix(0.01, -0.03, i * 0.5) + height - near * 0.12; } vec3 lakeAbove(vec2 p, vec2 cellId) { vec3 col = lakeSky(p, cellId); vec3 haze = lakeSky(vec2(p.x, lakeHorizon + 0.03), cellId) * 0.8; // Far to near: each nearer layer paints over the ones behind it. for (int k = 0; k < 3; k++) { float i = float(k); float top = lakeRidge(p.x, i); if (p.y < top) { // Shade by the silhouette's normal against the moon. Below the crest // the slope is read over a wider window at a wandering x, so faces // spread into broad planes split by crooked ridges, not vertical // lines; ribs down the fall line break them up. float below = top - p.y; float xs = p.x + below * 0.5 * snoise(vec2(below * 5.0, p.x * 3.0 + i)); float e = 0.01 + below * 0.8; float slope = (lakeRidge(xs + e, i) - lakeRidge(xs - e, i)) / (2.0 * e); vec2 normal = normalize(vec2(-slope, 1.0)); vec2 toMoon = normalize(lakeMoon - vec2(p.x, top)); float rib = snoise(vec2(p.x * 22.0, below * 3.0 + i)); float lit = smoothstep(0.1, 0.8, dot(normal, toMoon) + 0.25 * rib); // Each nearer layer is darker: moonlit snow far, woods, then rock. vec3 shade = mix(vec3(0.12, 0.13, 0.27), vec3(0.03, 0.035, 0.08), i * 0.5); vec3 light = mix(vec3(0.78, 0.8, 0.92), vec3(0.2, 0.23, 0.3), i * 0.5); // Snow lies near the crests and gives way to dark rock below. float snow = 1.0 - smoothstep(0.03, 0.1, below + 0.03 * rib); light = mix(light * 0.35, light, mix(snow, 1.0, i * 0.5)); vec3 face = mix(shade, light, lit); // A rim of moonlight along every crest on the lit side. face = mix(face, vec3(0.7, 0.72, 0.82), smoothstep(0.01, 0.0, below) * lit * 0.5); // Atmospheric perspective: far layers and every layer's foot sink // into the haze. float far = mix(0.3, 0.0, i * 0.5); far = mix(far, 1.0, 0.4 * (1.0 - smoothstep(0.0, 0.05, p.y - lakeHorizon))); col = mix(face, haze, far); } } return col; } vec3 lakeWater(vec2 p, vec2 cellId) { float depth = (lakeHorizon - p.y) / lakeHorizon; // Mirror about the horizon, wobbled by ripples that grow toward the viewer. float wob = (0.002 + 0.01 * depth) * sin(p.y * 220.0 / (0.25 + depth) + lakeT * 0.5 + 2.0 * snoise(vec2(p.x * 2.0, p.y * 10.0))); vec3 col = lakeAbove(vec2(p.x + wob, 2.0 * lakeHorizon - p.y), cellId); col = col * vec3(0.6, 0.66, 0.8) * mix(0.95, 0.55, depth) + vec3(0.02, 0.03, 0.07); // The moon's path: glints in a column that widens toward the viewer. float dx = abs(p.x - lakeMoon.x) / lakeSqueeze; float width = 0.012 + 0.07 * depth; float column = exp(-dx * dx / (width * width)); float glint = smoothstep(0.35, 0.85, snoise(vec2(p.x * 26.0, p.y * 130.0 + lakeT * 0.25))); col += vec3(0.9, 0.86, 0.72) * column * (0.12 + glint) * mix(1.0, 0.55, depth); return col; } vec3 lakeColor(vec2 uv, vec2 res, float t) { float aspect = res.x / res.y; // Narrow boxes squeeze x, so a phone still sees the hills and the moon. lakeSqueeze = sqrt(clamp(1.6 / aspect, 1.0, 4.0)); float portrait = 1.0 - smoothstep(0.7, 1.1, aspect); lakeT = t; lakeHalfW = 0.5 * aspect * lakeSqueeze; // On a phone the headline fills the middle: the horizon and the hills // drop below the buttons and the moon rises into the sky above the text. lakeHorizon = mix(0.4, 0.2, portrait); lakeScale = mix(1.0, 0.7, portrait); lakeMoon = vec2(0.55 * lakeHalfW, mix(lakeHorizon + 0.3, 0.8, portrait)); vec2 p = vec2((uv.x - 0.5) * aspect * lakeSqueeze, uv.y); vec2 cellId = floor(uv * res); vec3 col = p.y >= lakeHorizon ? lakeAbove(p, cellId) : lakeWater(p, cellId); // A dark near shore in the bottom left: the foreground plane. float shore = 0.12 + 0.05 * fbm(vec2(p.x * 3.0, 5.0)) - 0.3 * smoothstep(-0.9, 0.2, p.x / lakeHalfW); if (p.y < shore) { float rocks = 0.5 + 0.5 * snoise(vec2(p.x * 14.0, p.y * 9.0)); col = mix(vec3(0.03, 0.035, 0.07), vec3(0.12, 0.12, 0.2), rocks * 0.5); col = mix(col, vec3(0.4, 0.4, 0.5), smoothstep(0.012, 0.0, shore - p.y) * 0.6); } // Low mist drifting along the far shore. float band = exp(-pow((p.y - lakeHorizon - 0.01) / 0.025, 2.0)); float mist = smoothstep(0.4, 0.7, fbm(vec2(p.x * 1.5 - t * 0.012, p.y * 12.0))) * band; return mix(col, vec3(0.42, 0.4, 0.54), mist * 0.6); } // field and colorField run in the same shader pass, so the second call for // a sample reads the cached result instead of painting the scene again. vec2 lakeCacheUv = vec2(-1.0); float lakeCacheT = -1.0; vec4 lakeCacheValue = vec4(0.0); vec4 lakeScene(vec2 uv, vec2 res, float t) { if (uv == lakeCacheUv && t == lakeCacheT) { return lakeCacheValue; } vec3 col = clamp(lakeColor(uv, res, t), 0.0, 1.0); float peak = max(col.r, max(col.g, col.b)); // Value counts saturated colour as mid-tone, so blue shadow still draws. float value = max(dot(col, vec3(0.3, 0.55, 0.15)), 0.7 * peak); float tone = 0.1 + 0.9 * pow(value, 1.2); // The focal light draws full dots, not a dithered mix of the two largest. tone = max(tone, smoothstep(0.82, 0.92, value)); // Small dots read darker than the light they stand for: lift dark ink. vec3 ink = col / max(peak, 1e-3) * pow(peak, 0.6); // Quiet darks: the faintest specks, in a colour just above the paper. float quiet = 1.0 - smoothstep(0.08, 0.28, value); ink = mix(ink, vec3(0.11, 0.12, 0.2), quiet * 0.55); tone *= 1.0 - 0.35 * quiet; // Below full intensity the middle band, where a headline sits, quiets most. float k = lakeIntensity; vec2 c = vec2((uv.x - 0.5) * res.x / res.y / 0.55, (uv.y - 0.52) / 0.15); float middle = exp(-dot(c, c)) * (1.0 - k); tone *= mix(0.72, 1.0, k) * (1.0 - 0.45 * middle); ink *= mix(0.5, 1.0, k) * (1.0 - 0.6 * middle); lakeCacheUv = uv; lakeCacheT = t; lakeCacheValue = vec4(ink, tone); return lakeCacheValue; } `; const FIELD = `${SCENE} float field(vec2 uv, vec2 res, float t) { return lakeScene(uv, res, t).a; } `; const COLOR_FIELD = `${SCENE} vec3 colorField(vec2 uv, vec2 res, float t) { return lakeScene(uv, res, t).rgb; } `; // The scene paints its own night, so modes that use its colours keep this // paper in light and dark themes alike. const NIGHT = "#070a14"; const PAINTED: ReadonlySet = new Set([ "truecolor", "ansi16", "duotone", ]); export interface MoonLakeProps extends Omit { /** How strongly it draws, from 0 (faint, for text on top) to 1. Default 1. */ intensity?: number; /** Square cells, for an even lattice of dots. Default `true`. */ square?: boolean; /** Paper behind the dots. Default: the scene's night in painted modes. */ paper?: AsciiColor; } export const MoonLake = ({ charset = "dots", colorMode = "truecolor", cell = 7, square = true, dither = "bayer4", stillTime = 6, intensity = 1, paper, ...props }: MoonLakeProps) => ( } {...props} /> ); export default function EnginePaintedScene() { return ; } ``` ## The look [#the-look] | Prop | Value | Why | | ----------- | ----------------------- | ---------------------------------------------------------------------------- | | `charset` | `"dots"` | Five dot sizes. The two smallest are specks, for quiet sky and shadow | | `square` | `true` | Square cells lay the dots on an even lattice instead of rows | | `cell` | `7` | About 200 columns across 1440px: big shapes, no aliasing | | `colorMode` | `"truecolor"` | Each dot takes its colour from the `colorField` | | `dither` | `"bayer4"` | Tones between two dot sizes mix them, so gradients don't band | | `paper` | the scene's darkest sky | The scene paints its own ground, so it looks the same in light and dark mode | ## Building one [#building-one] 1. **Write the brief in one sentence.** The place, the time of day, one light, and what sits where. Pick a short palette for it and write the colours as constants. 2. **Set up coordinates for every screen.** Squeeze x on narrow boxes so a phone still sees the scene, place features at a share of the half width, and use a `portrait` factor to reframe for phones. Measure round things (a sun, a moon, a halo) with the squeeze undone, or they turn oval on a phone. 3. **Paint far to near.** Sky, far range, middle layer, near layer, water, foreground, mist. Each nearer layer paints over the last. 4. **Keep a value ladder.** The light is brightest. The subject carries the next brightest values: lit snow, lit facades, lit ice. A scene of dark silhouettes against the sky draws only specks. Far layers are lighter, cooler and closer to the sky colour; near layers are darker. Most of the frame should sit between 0.2 and 0.6 in value, and the sky needs mid-tones even at night, because the water repeats it. Render once with `colorMode="mono"` to see the values alone. 5. **Shape forms with light.** Silhouettes come from noise at two scales, never straight lines. Shade each face by its normal against the light, so it has a lit side and a shadow side, and break the faces with ribs, ledges or a snow line. Size shapes in cells: at `cell` 7 one row is about 0.008 of the height of a 900px box, and a shape needs 8 or more cells across to read. 6. **Add light and air.** A disc, a tight halo and a wide glow for the light. Lamps and windows also light the surfaces near them. Water mirrors the scene about the horizon with ripples that grow toward the viewer, plus a path of glints under the light. A thin band of mist along the horizon. 7. **Turn colour into dots.** See below. 8. **Wrap it in a component** with an `intensity` prop, the look props passed through with the defaults above, its own `paper`, a gradient `fallback` and a `stillTime`. ## Colour into dots [#colour-into-dots] `field` and `colorField` compile into one shader and run one after the other for each sample. Paint the scene once in a shared function, cache the result, and return the tone from `field` and the colour from `colorField`: ```glsl vec4 lakeScene(vec2 uv, vec2 res, float t) { if (uv == lakeCacheUv && t == lakeCacheT) { return lakeCacheValue; } vec3 col = clamp(lakeColor(uv, res, t), 0.0, 1.0); float peak = max(col.r, max(col.g, col.b)); float value = max(dot(col, vec3(0.3, 0.55, 0.15)), 0.7 * peak); float tone = 0.1 + 0.9 * pow(value, 1.2); tone = max(tone, smoothstep(0.82, 0.92, value)); vec3 ink = col / max(peak, 1e-3) * pow(peak, 0.6); float quiet = 1.0 - smoothstep(0.08, 0.28, value); ink = mix(ink, vec3(0.11, 0.12, 0.2), quiet * 0.55); tone *= 1.0 - 0.35 * quiet; // ...intensity and the calm middle, then store the cache } ``` * The tone comes from the colour's value, so dot size and colour agree. * Dark areas are quiet: their colour moves toward a shade just above the paper and their dots shrink to specks. * Dark ink colours are lifted, because small dots read darker than the colour they carry. * The focal light is pinned to the largest dot, so it doesn't dither between the two largest. On a light paper, take the tone from the colour's distance to the paper instead, so the sky near the paper colour is the faintest dots. The balloon valley background works this way. ## Behind a headline [#behind-a-headline] A hero puts a headline in the middle of the scene and two buttons below it. Keep the focal light and bright edges out of the middle half of the width. An `intensity` below 1 dims the scene and quiets an ellipse in the middle most: ```tsx

Your headline

``` On a 390x844 phone the headline and buttons cover the middle of the screen. Reframe for portrait: move the horizon and the subject below the buttons, or the light into the empty sky above the headline. ## Motion [#motion] Slow and sparse: drifting cloud and mist, ripples and glints on water, a slow star twinkle, a soft glow pulse. No rain, beams, lightning or pointer response. Never hash anything with raw time in it, or dots flicker. Pick `stillTime` so the frame shown under reduced motion looks finished. ## Checking a scene [#checking-a-scene] * Screenshot at 1440x900 and 390x844, bare and behind a headline with two buttons. You should be able to name the place in a second and find one clear light. * Zoom into a crop: round dots in several sizes on an even lattice, not stripes or blur. * On the phone, the subject sits beside the text and nothing bright is behind the buttons. * With reduced motion, the renderer reports `data-state="static"` and the still frame looks finished. * The scene looks the same in light and dark mode, and the console shows no shader errors. If a scene looks weak, it is usually too dark (the subject has no bright values), half empty (a black sky over black water), too small (features under 8 cells), too busy (dark areas show visible dots), flat (no lit and shadow faces, no haze with distance) or diagrammatic (straight edges, even repeats). ## Going further [#going-further] The shipped backgrounds paint 2D layers. For a scene that needs real depth, raymarch it in the same `field`: model terrain and buildings as height fields and signed distance functions, cast rays from a camera, light faces from their normals, reflect water by tracing the mirrored ray, and add fog that thickens with distance. Keep it to 64 to 128 steps. The dots, the tone mapping and the checks stay the same. Coding agents get this guide as the `scenes.md` reference of the asciicn skill. # Post effects The CRT finish, pointer interaction and audio input every AsciiShader can take. > For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/asciicn/SKILL.md). `AsciiShader` draws every glyph on the GPU, so it can finish the frame the way a monitor would: scanlines, glow, curved glass. It can also react to the pointer and to sound. All of it is off by default, and none of it changes your field. ## CRT effects [#crt-effects] Pass `fx` with any of six effects, each from 0 (off) to 1 (strong): ```tsx ``` | Effect | What it does | | ----------- | ---------------------------------------------------------------------------------------------------------- | | `scanlines` | Darkens every third CSS pixel row, like a CRT raster. | | `bloom` | Bright glyphs glow into their neighbours. Scaled by the theme's `--ascii-glow`. | | `curvature` | Bends the frame like curved glass. The corners go black (or transparent, with `background="transparent"`). | | `vignette` | Darkens toward the corners. | | `flicker` | Varies the brightness slightly from frame to frame. | | `glitch` | On random frames, a band of rows jumps sideways, a few glyphs flip and the colour channels split. | `CRT_FX` from `@/lib/ascii` is a ready-made full CRT look; spread it and override what you like: `fx={{ ...CRT_FX, glitch: 0.2 }}`. `NO_FX` turns everything off. The effects run in the same pass that draws the glyphs, so they cost almost nothing. Bloom adds two small blur passes at one pixel per cell. The effects are visual only. `capture()` on the shader's `handle`, and every text, ANSI and SVG export built from it, returns the glyph grid without them: no glitch row shifts, curvature, scanlines, bloom, flicker, vignette or colour split. The few glyphs a glitch frame flips are part of the grid, so a capture on that frame keeps them. PNG and video exports read the canvas, so they do include the effects. ## Pointer interaction [#pointer-interaction] `interaction` makes the pointer push the field around. Move over the preview, and click it for `ripple`: ```tsx ``` * `repel` pushes glyphs away from the pointer and clears a small hole under it. * `ripple` sends a ring out from each click or tap, displacing and brightening the glyphs it passes. * `trail` leaves a fading wake of ink behind the pointer. The canvas still ignores clicks (`pointer-events: none`), so buttons and links on top of it keep working; the engine watches the pointer on the window instead. Pointer motion is smoothed, so glyphs glide after it. Interaction is motion: under reduced motion the shader holds its still frame and ignores the pointer. Raise `fps` to 60 for interactive backgrounds, so the response feels immediate. ### Reading the pointer in a field [#reading-the-pointer-in-a-field] Fields can read the pointer directly for parallax and steering. `u_pointer.xy` is the pointer in `uv` and `u_pointer.z` fades from 0 to 1 while it's over the box, so multiplying by it eases the effect in and out. This one slides three layers of rings by different amounts: ```glsl float parallaxRings(vec2 p, float scale) { return smoothstep(0.12, 0.0, abs(fract(length(p) * scale) - 0.5)); } float field(vec2 uv, vec2 res, float t) { vec2 p = (uv - 0.5) * vec2(res.x / res.y, 1.0); vec2 tilt = (u_pointer.xy - 0.5) * u_pointer.z; float far = parallaxRings(p - tilt * 0.03, 6.0) * 0.3; float mid = parallaxRings(p - tilt * 0.08 + vec2(0.2, 0.0), 4.0) * 0.6; float near = parallaxRings(p - tilt * 0.16 - vec2(0.25, 0.1), 2.5); return max(far, max(mid, near)); } ``` ```tsx title="examples/engine-gpu-parallax.tsx" "use client"; import { AsciiShader } from "@/lib/ascii/shader"; // Fields can read the pointer themselves: three layers of rings slide by // different amounts, so the scene tilts as the pointer moves. const FIELD = ` float parallaxRings(vec2 p, float scale) { return smoothstep(0.12, 0.0, abs(fract(length(p) * scale) - 0.5)); } float field(vec2 uv, vec2 res, float t) { vec2 p = (uv - 0.5) * vec2(res.x / res.y, 1.0); vec2 tilt = (u_pointer.xy - 0.5) * u_pointer.z; float far = parallaxRings(p - tilt * 0.03, 6.0) * 0.3; float mid = parallaxRings(p - tilt * 0.08 + vec2(0.2, 0.0), 4.0) * 0.6; float near = parallaxRings(p - tilt * 0.16 - vec2(0.25, 0.1), 2.5); return max(far, max(mid, near)); } `; export default function EngineGpuParallax() { return ( ); } ``` The engine only tracks the pointer when `interaction` is set or the field mentions `u_pointer`, `u_pointerVelocity` or `u_click`. ## Audio input [#audio-input] `useAudioBands` from `@/lib/ascii/audio` listens to a source and keeps smoothed bass, mid, treble and level values from 0 to 1. Pass the result to `audio`, and fields read it as `u_audio`: ```tsx "use client"; import { useAudioBands } from "@/lib/ascii/audio"; import { AsciiShader } from "@/lib/ascii/shader"; export function Visualizer({ track }: { track: HTMLAudioElement | null }) { const bands = useAudioBands(track); return ; } ``` ```tsx title="examples/engine-gpu-audio.tsx" "use client"; import { useState } from "react"; import { useAudioBands } from "@/lib/ascii/audio"; import type { AudioSource } from "@/lib/ascii/audio"; import { AsciiShader } from "@/lib/ascii/shader"; // Bass swells the ring, mids bend it, treble sparks the glyphs around it. const FIELD = ` float field(vec2 uv, vec2 res, float t) { vec2 p = (uv - 0.5) * vec2(res.x / res.y, 1.0); float a = atan(p.y, p.x); float bend = sin(a * 5.0 + t) * 0.04 * (0.3 + u_audio.y * 2.0); float radius = 0.18 + u_audio.x * 0.16 + bend; float ring = smoothstep(0.07, 0.0, abs(length(p) - radius)); // Cells are about half as wide as a res unit, so x counts double. vec2 cell = floor(uv * res * vec2(2.0, 1.0)); float sparks = step(0.985 - u_audio.z * 0.08, hash21(cell + floor(t * 12.0))); return max(ring, sparks * 0.7); } `; const SOURCES = ["off", "synth", "mic"] as const; type Choice = (typeof SOURCES)[number]; const toSource = (choice: Choice): AudioSource => choice === "off" ? null : choice; export default function EngineGpuAudio() { const [choice, setChoice] = useState("synth"); const bands = useAudioBands(toSource(choice)); return (
{SOURCES.map((option) => ( ))} {bands.status}
); } ``` The source can be: * an `