beta

ASCII <mv-ascii>

Redraws an image, video, canvas or text as live ASCII art: configurable character ramp and cell size, monochrome or full color, a pointer wake that thickens the characters where it passes and cools behind it, and an optional reveal that scrambles every character before locking them from left to right. One fillText per row; the loop stops when idle.

CategoryEffects
TypeWeb Component (<mv-ascii>)
Statusbeta
Keywordsascii, canvas, image, video, text, retro, terminal, wake, effect

When to use

  • A retro, terminal or hacker aesthetic should render a photo, logo or video as live ASCII art
  • A hero visual should unscramble into characters and react to the cursor with a trail
  • Short display text should become a character-based artwork

Avoid when

  • A heading should decode into readable text character by character → use Scramble Text instead
  • The text must be indexable or selectable; it is drawn on a canvas and only exposed as an accessible name

Install

node scripts/add.mjs ascii --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["ascii"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/canvas.js, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/ascii/ascii.js, components/ascii/ascii.css.

Usage

Canonical markup, to start from and customize with attributes, data-* and CSS variables:

<div id="mv-ascii-demo">
  <style>
    #mv-ascii-demo { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1.25rem; width: 100%; align-items: center; }
    #mv-ascii-demo figure { margin: 0; display: grid; gap: .5rem; }
    #mv-ascii-demo figcaption { font-size: .8rem; color: var(--mv-fg-subtle); text-align: center; }
    #mv-ascii-demo .screen { border-radius: var(--mv-radius-xl); background: oklch(0.13 0.02 265); }
  </style>
  <figure>
    <mv-ascii text="HELLO" reveal cell="9" style="--mv-ascii-color: var(--mv-accent-fg)"></mv-ascii>
    <figcaption>Move the pointer across the letters to leave a wake.</figcaption>
  </figure>
  <figure>
    <mv-ascii class="screen" mode="color" cell="8" label="Animated sunset over the sea, drawn with characters">
      <canvas id="mv-ascii-demo-scene" width="320" height="200"></canvas>
    </mv-ascii>
    <figcaption>A live canvas redrawn in color, frame by frame.</figcaption>
  </figure>
</div>
<script type="module">
  // Demo source: a sun setting over rolling waves, drawn on a plain canvas.
  const g = document.getElementById("mv-ascii-demo-scene").getContext("2d");
  const frame = (ms) => {
    const t = ms / 1000;
    const sky = g.createLinearGradient(0, 0, 0, 120);
    sky.addColorStop(0, "#1d1446");
    sky.addColorStop(0.6, "#b4457a");
    sky.addColorStop(1, "#ffb36b");
    g.fillStyle = sky;
    g.fillRect(0, 0, 320, 120);
    const y = 92 + Math.sin(t * 0.4) * 10;
    const glow = g.createRadialGradient(160, y, 8, 160, y, 90);
    glow.addColorStop(0, "rgba(255,236,190,0.95)");
    glow.addColorStop(1, "rgba(255,236,190,0)");
    g.fillStyle = glow;
    g.fillRect(0, 0, 320, 120);
    g.fillStyle = "#ffe9b8";
    g.beginPath(); g.arc(160, y, 30, 0, Math.PI * 2); g.fill();
    for (let k = 0; k < 7; k++) {
      const top = 112 + k * 13;
      g.fillStyle = `hsl(${250 - k * 6} 55% ${34 - k * 4}%)`;
      g.beginPath();
      g.moveTo(0, 200);
      for (let x = 0; x <= 320; x += 8) g.lineTo(x, top + Math.sin(x / (22 + k * 3) + t * (1.2 + k * 0.25)) * (3 + k));
      g.lineTo(320, 200);
      g.fill();
    }
    g.fillStyle = "rgba(255,222,170,0.55)";
    for (let k = 0; k < 6; k++) g.fillRect(160 - 36 + k * 3 + Math.sin(t * 2 + k) * 4, 124 + k * 11, 72 - k * 6, 2);
    requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);
</script>

API

Attributes

NameTypeDefaultDescription
textstringText to convert when there is no child media (otherwise the text content).
charsstring" .:-=+*#%@"Ramp from lightest to densest (the first character is used for empty space).
cellnumber10Size of one character (px): smaller = more detail.
modemono | colormonoSolid ink (--mv-ascii-color) or the source colors.
colorCSS colorvar(--mv-ascii-color, var(--mv-fg))Ink in mono mode (tokens accepted, follows the theme).
invertbooleanInverts density (in mono it already adapts to a dark or light ink).
contrastnumber1.15Contrast applied to luminance.
fitcover | containcoverSource framing.
hoverwake | nonewakePointer wake: characters thicken where the pointer passes (a tap on touch) and thin out behind it. Any value other than none, such as the former ripple, turns it on.
revealbooleanOn entering the viewport, every character scrambles, then locks in place from left to right.
labelstringAccessible name (otherwise the image alt, or the text).

Content structure

NameDescription
img | video | canvasSource (direct child), kept in the DOM but hidden. Video and canvas are sampled every frame; a cross-origin image without CORS is shown as is.

CSS variables

NameDefaultDescription
--mv-ascii-colorvar(--mv-fg)Ink in mono mode (re-read when the theme changes).
--mv-ascii-fontvar(--mv-font-mono)Character font (monospace).
--mv-ascii-ratiosource ratio (16/9, 4/1 for text)Element proportions, set automatically.

Accessibility

role=img with an accessible name (label, image alt or text); the canvas and the source are aria-hidden. Reduced motion (OS setting or data-motion="reduce"): no wake and no reveal, a still picture (a video is still sampled while it plays). The frame loop stops off screen and when nothing moves. Forced colors: the artwork keeps its colors inside a system outline.