ASCII Render

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

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.

tsx
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.

0:00 / 0:03
0s1s2s3.2s
0:00 / 0:03
tsx
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.

0:00 / 0:03
0s1s2s3s
0:00 / 0:03
tsx
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.

0:00 / 0:03
0s1s2s3s
0:00 / 0:03
tsx
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.

tsx
<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.

tsx
<AsciiRender colored>
  <Scene />
</AsciiRender>

Props6

PropTypeDefault
glyphSize
number26

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
booleanfalse

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
number1

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.

  • glyphSizenumber

    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.

    Default26

  • charsetstring

    Density ramp, darkest character first. Any printable characters work; an empty string falls back to the default ramp.

    Default" .:-=+*#%@"

  • coloredboolean

    When set, each glyph takes the colour of its own cell. When unset, all glyphs use ink.

    Defaultfalse

  • inkstring

    Glyph colour in the monochrome mode. Ignored when colored is set.

    Default"#9dff9d"

  • intensitynumber

    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.

    Default1

  • childrenReact.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.