ASCII Render
Redraw a live scene as ASCII, one character per cell, tracking the picture as it moves
This filter re-reads the real pixels of the scene it wraps through the experimental html-in-canvas browser API. The preview above shows the true effect in Chrome 149+ with chrome://flags/#canvas-draw-element enabled (nested canvases need 152.0.7944.0+). Every other browser — Safari, Firefox, Chrome without the flag — falls back to a CSS approximation, so the page and your video still play.
Rendering is supported through the CLI, Studio, Lambda and SSR with --gl=angle, or --gl=swangle on a machine without a GPU. Chrome may change or discontinue the API; the fallback is what keeps your composition renderable if that happens.
Installation
$ pnpm dlx shadcn@latest add @remocn/ascii-renderUsage
AsciiRender is a wrapper. Put a scene inside it and the frame is divided into
glyph cells; each cell measures the average brightness of the picture inside it
and draws the character from charset that matches. Because the measurement
happens every frame, the characters flow with whatever moves underneath.
import { AsciiRender } from "@/components/remocn/ascii-render";
export const MyScene = () => (
<AsciiRender>
<Scene />
</AsciiRender>
);What to reach for it for
On its own, a scene converted to text is a look. What makes it useful in a video
is intensity, which is read every frame: it blends the ASCII back toward the
untouched scene, so the treatment can arrive, leave, or spike.
Resolve the scene out of text
The strongest use. The scene enters as characters and sharpens into the real UI over the first second — a beginning with more intent than a fade, and one that says "this is software" before the viewer can read anything.
import { interpolate, useCurrentFrame } from "remotion";
import { AsciiRender } from "@/components/remocn/ascii-render";
export const Opening = () => {
const frame = useCurrentFrame();
const intensity = interpolate(frame, [0, 30], [1, 0], {
extrapolateRight: "clamp",
});
return (
<AsciiRender intensity={intensity} glyphSize={22}>
<Dashboard />
</AsciiRender>
);
};Animating glyphSize alongside it — coarse to fine, as the preview does — makes
the scene feel like it is being resolved rather than revealed. The preview runs
the move in reverse at the end so that it loops; a real opening would stop at 0
and stay there.
Punch it in on a beat
Hold the scene clean and let it drop into text for a few frames, the way
sustained-glitch is used for damage. Good on a hard cut in the music or the
moment a command runs.
const intensity = interpolate(frame, [44, 46, 58], [0, 1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});Two frames of attack and a dozen of release is the shape that reads as a hit. Give it a slow ramp and it stops being punctuation.
Build a backdrop out of characters
The filter wraps anything, not just your main scene. Wrap something that moves and you get a field of text to sit content on — and the content stays perfectly sharp, because it lives outside the wrapper rather than inside it.
import { AbsoluteFill } from "remotion";
import { AsciiRender } from "@/components/remocn/ascii-render";
export const Scene = () => (
<AbsoluteFill>
<AsciiRender glyphSize={18} ink="#2f7a52">
<YourMovingBackdrop />
</AsciiRender>
<AbsoluteFill>{/* headline and body stay sharp */}</AbsoluteFill>
</AbsoluteFill>
);A shader background works here too, but only on Chrome 152+, where a canvas nested inside the captured subtree is itself captured. A backdrop built from DOM has no such requirement, which is what the preview above uses.
The charset is a ramp
charset is read as a density ramp, darkest first: the leftmost character is
what a black cell becomes, the rightmost is what a white cell becomes. The
default " .:-=+*#%@" runs from a space to a solid glyph, which is why it reads
as shading rather than as noise.
Order matters more than length. A ramp whose characters do not grow steadily denser left to right will produce texture instead of tone.
<AsciiRender charset=" ░▒▓█" glyphSize={20}>
<Scene />
</AsciiRender>Colour
By default the glyphs are drawn in a single ink colour on black — a terminal.
Set colored and each glyph takes the colour of the picture in its own cell
instead, which keeps the scene recognisable while still reducing it to text.
<AsciiRender colored>
<Scene />
</AsciiRender>Props
| Prop | Type | Default | Description |
|---|---|---|---|
glyphSize | number | 26 | Height of one glyph cell in composition units. The cell width follows from the monospace aspect, so this alone sets how much of the scene survives. |
charset | string | " .:-=+*#%@" | Density ramp, darkest character first. Any printable characters work; an empty string falls back to the default ramp. |
colored | boolean | false | When set, each glyph takes the colour of its own cell. When unset, all glyphs use ink. |
ink | string | "#9dff9d" | Glyph colour in the monochrome mode. Ignored when colored is set. |
intensity | number | 1 | How much of the ASCII survives, from 0 to 1. Read every frame, so interpolating it is how the scene resolves out of text or drops into it. At 0 the scene passes through exactly as it was. |
children | React.ReactNode | — | The scene the filter wraps and re-reads every frame. |
Notes
Between the glyphs the filter writes opaque black wherever the scene was opaque, so the result reads as a terminal rather than as characters floating over the original. Where the scene is transparent it stays transparent, so the filter can sit over a backdrop without boxing it in.
The glyph atlas is drawn once per charset with the system monospace face and cached for the lifetime of the canvas, so it costs nothing per frame. It uses a system font rather than a loaded webfont specifically so that there is no frame where the atlas has been built from a fallback face.
In browsers without html-in-canvas the scene renders untouched. There is no honest CSS approximation of resampling a live scene into glyphs, and a half-measure that only tinted the frame green would misrepresent what the component does.