ASCII Render

Redraw a live scene as ASCII, one character per cell, tracking the picture as it moves

Experimental — html-in-canvas

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-render

Usage

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

PropTypeDefaultDescription
glyphSize
number26Height 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
booleanfalseWhen 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
number1How 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.ReactNodeThe 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.