ASCII Reveal
A photo typed out in ASCII that scrambles into the real image.
Customize
<AsciiReveal src={src} />Changes show live and stay in the address, so a link keeps them.
Code
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";
// A photo that starts as ASCII characters and scrambles into the real image. The photo is shrunk
// to one pixel per character, and each pixel's brightness picks a character from the charset.
// One number, `progress`, drives everything and one GSAP tween moves it:
// 0 → 1 the characters scramble in from nothing until the ASCII portrait is there
// 1 → 2 each character scrambles again, flashes the colour of its patch of the photo, then
// opens like a window onto the real photo underneath
// Every cell starts at its own random moment, so the picture builds up and dissolves in grain.
// The cells' background is a tiny canvas (one pixel per cell) stretched over the box, so even
// 80 x 80 cells cost almost nothing per frame.
export type AsciiRevealProps = {
/** The photo. Remote images need CORS (the canvas reads their pixels); without it the photo shows as is. */
src: string;
/** Describes the photo for screen readers. Empty means the photo is decoration. */
alt?: string;
/** Characters across. */
columns?: number;
/** Characters down. */
rows?: number;
/** The characters, from the emptiest to the densest. The first one is usually a space. */
charset?: string;
/** The characters a cell flickers through while it scrambles. */
scrambleChars?: string;
/** How long one change takes (ASCII in, or ASCII to photo), in seconds. */
duration?: number;
/** What reveals the photo: hovering or focusing it (touch screens use inView), scrolling it into view, or the page loading. */
trigger?: "hover" | "inView" | "load";
/** Wait before the ASCII starts to scramble in, in seconds. Handy to stagger a grid of them. */
delay?: number;
/** Each cell flashes the colour of its patch of the photo just before it opens. */
mosaic?: boolean;
/** Use dense characters for the other end of the brightness range (a negative). */
invert?: boolean;
/** Background behind the characters. Any CSS colour, including var(--something). A transparent or see-through one takes the nearest solid background behind the box, so the photo never shows through the ASCII. */
background?: string;
/** Colour of the characters. Any CSS colour, including var(--something). */
color?: string;
/** Extra classes for the outer box. Give it a size (it fills it). */
className?: string;
};
// The share of a phase over which the cells' start times are spread. The rest is each cell's own change.
const SPREAD = 0.6;
// While a cell opens, the first part of its change is scrambled characters, the rest is the colour flash
const SCRAMBLE_SHARE = 0.55;
type Sample = {
/** The ASCII character of every cell, row by row */
chars: string[];
/** The average colour of every cell's patch, "rgb(r g b)" */
colours: string[];
};
const DEFAULT_CHARSET = " .:-=+*#%@";
// Paint CSS colours (rgb, oklch, a variable's value...) on top of each other on a 1 x 1 canvas
// and read back the result as [r, g, b, a]. The canvas does the parsing and the blending for us.
function paint(colours: string[]): [number, number, number, number] {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return [0, 0, 0, 255];
for (const colour of colours) {
context.fillStyle = colour;
context.fillRect(0, 0, 1, 1);
}
const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data;
return [r, g, b, a];
}
const readColour = (colour: string): [number, number, number] => {
const [r, g, b] = paint([colour]);
return [r, g, b];
};
const lightness = ([r, g, b]: [number, number, number]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// The solid colour the visitor actually sees behind the box. The cells must be opaque (they hide
// the photo until they open), so a transparent or see-through background is blended over the
// backgrounds of the elements behind it, up to the first solid one.
function solidBackground(box: HTMLElement): [number, number, number] {
const layers: string[] = [];
let solid = false;
for (let node: HTMLElement | null = box; node; node = node.parentElement) {
const colour = getComputedStyle(node).backgroundColor;
layers.unshift(colour);
if (paint([colour])[3] === 255) {
solid = true;
break;
}
}
// Nothing solid all the way up: guess from the ink, light characters want a dark page
if (!solid) layers.unshift(lightness(readColour(getComputedStyle(box).color)) > 0.5 ? "#111111" : "#ffffff");
const [r, g, b] = paint(layers);
return [r, g, b];
}
// Shrink the photo to one pixel per cell (cropped like object-fit: cover) and turn each pixel into a character
function sampleImage(image: HTMLImageElement, columns: number, rows: number, boxAspect: number, charset: string, denseIsBright: boolean): Sample {
const imageAspect = image.naturalWidth / image.naturalHeight;
let cropX = 0;
let cropY = 0;
let cropWidth = image.naturalWidth;
let cropHeight = image.naturalHeight;
if (imageAspect > boxAspect) {
cropWidth = image.naturalHeight * boxAspect;
cropX = (image.naturalWidth - cropWidth) / 2;
} else {
cropHeight = image.naturalWidth / boxAspect;
cropY = (image.naturalHeight - cropHeight) / 2;
}
const canvas = document.createElement("canvas");
canvas.width = columns;
canvas.height = rows;
const context = canvas.getContext("2d", { willReadFrequently: true })!;
context.imageSmoothingQuality = "high";
context.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, columns, rows);
// Throws for a cross-origin photo served without CORS: the caller shows the plain photo then
const { data } = context.getImageData(0, 0, columns, rows);
const levels: number[] = [];
const colours: string[] = [];
for (let i = 0; i < columns * rows; i++) {
const rgb: [number, number, number] = [data[i * 4]!, data[i * 4 + 1]!, data[i * 4 + 2]!];
levels.push(lightness(rgb));
colours.push(`rgb(${rgb[0]} ${rgb[1]} ${rgb[2]})`);
}
// Stretch the photo's own darkest..brightest range over the whole charset, so a grey photo still gets every character
const darkest = Math.min(...levels);
const range = Math.max(Math.max(...levels) - darkest, 0.001);
const last = charset.length - 1;
const chars = levels.map((level) => {
const bright = (level - darkest) / range;
return charset[Math.round((denseIsBright ? bright : 1 - bright) * last)] ?? " ";
});
return { chars, colours };
}
export function AsciiReveal({
src,
alt = "",
columns = 25,
rows = 22,
charset = DEFAULT_CHARSET,
scrambleChars = "#%@=+*",
duration = 1.2,
trigger = "hover",
delay = 0,
mosaic = true,
invert = false,
background = "#111111",
color = "#d4d4d4",
className = "",
}: AsciiRevealProps) {
const boxRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const cellsRef = useRef<HTMLCanvasElement>(null);
const textRef = useRef<HTMLPreElement>(null);
// Read when a tween starts, so a new duration doesn't rebuild anything
const durationRef = useRef(duration);
durationRef.current = duration;
// The current run's resample, so a new background or colour only repaints instead of starting over
const resampleRef = useRef<(() => void) | null>(null);
useGSAP(
(_context, contextSafe) => {
const box = boxRef.current;
const photo = imageRef.current;
const cellsCanvas = cellsRef.current;
const text = textRef.current;
const cells = cellsCanvas?.getContext("2d");
if (!box || !photo || !cellsCanvas || !text || !cells || !contextSafe) return;
// Reduced motion: just the photo, still
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
gsap.set(photo, { opacity: 1 });
gsap.set([cellsCanvas, text], { display: "none" });
return;
}
const count = columns * rows;
// An empty charset would draw nothing at all, so it falls back to the default one
const chars = charset.length > 0 ? charset : DEFAULT_CHARSET;
const flicker = scrambleChars.length > 0 ? scrambleChars : "#";
// When each cell starts to change (0..1), random so the picture builds up in grain
const order = Array.from({ length: count }, () => Math.random());
const state = { progress: 0 };
let sample: Sample | null = null;
// The solid colour of a closed cell, worked out with each sample rather than every frame
let fill = "rgb(17 17 17)";
let alive = true;
let tween: gsap.core.Tween | null = null;
let hold: gsap.core.Tween | null = null;
// Where a cell is in the current phase: 0 not started, 1 done
const cellProgress = (phase: number, i: number) => gsap.utils.clamp(0, 1, (phase - order[i]! * SPREAD) / (1 - SPREAD));
// A flickering character that changes about 20 times a second, different in every cell
const scrambled = (i: number) => flicker[Math.floor(i * 7.31 + gsap.ticker.time * 20) % flicker.length]!;
function draw() {
if (!sample) return;
const { progress } = state;
const lines: string[] = [];
let line = "";
let lastFill = "";
cells!.clearRect(0, 0, columns, rows);
for (let i = 0; i < count; i++) {
let char = " ";
let cellFill: string | null = fill;
if (progress <= 1) {
// Scrambling in: nothing, then flicker, then the cell's own character
const local = cellProgress(progress, i);
char = local <= 0 ? " " : local >= 1 ? sample.chars[i]! : scrambled(i);
} else {
// Opening: the character, then flicker, then the colour of the photo, then the photo itself
const local = cellProgress(progress - 1, i);
if (local <= 0) char = sample.chars[i]!;
else if (local < SCRAMBLE_SHARE) char = scrambled(i);
else if (local < 1 && mosaic) cellFill = sample.colours[i]!;
else cellFill = null;
}
if (cellFill) {
// Setting fillStyle parses the colour, so only when it changes
if (cellFill !== lastFill) cells!.fillStyle = lastFill = cellFill;
cells!.fillRect(i % columns, Math.floor(i / columns), 1, 1);
}
line += char;
if ((i + 1) % columns === 0) {
lines.push(line);
line = "";
}
}
text!.textContent = lines.join("\n");
}
// Move `progress` to the target at a steady pace: the same speed whether it goes the whole way or half
const goTo = contextSafe((target: number, wait = 0, then?: () => void) => {
hold?.kill();
tween?.kill();
const distance = Math.abs(target - state.progress);
if (distance === 0) return then?.();
tween = gsap.to(state, {
progress: target,
duration: durationRef.current * distance,
delay: wait,
ease: "none",
onUpdate: draw,
onComplete: then,
});
});
// The photo is cropped by the box's shape, so a new shape (or theme, which can flip the
// characters' brightness) means a new sample
const image = new Image();
image.crossOrigin = "anonymous";
image.decoding = "async";
const resample = () => {
if (!image.naturalWidth) return;
const boxAspect = Math.max(box.clientWidth, 1) / Math.max(box.clientHeight, 1);
const background = solidBackground(box);
fill = `rgb(${background.join(" ")})`;
// Light characters on a dark background draw the bright parts dense, ink on paper the dark parts
const lightInk = lightness(readColour(getComputedStyle(box).color)) > lightness(background);
sample = sampleImage(image, columns, rows, boxAspect, chars, lightInk !== invert);
// Resizing a canvas clears it, so only here, right before the new sample is painted in the
// same task: the old cells keep covering the photo until then
cellsCanvas.width = columns;
cellsCanvas.height = rows;
draw();
};
// No ASCII possible (the photo won't load or has no CORS): show the photo as it is.
// contextSafe, because it runs from async callbacks: the sets are then recorded in the GSAP
// context and undone when the props change, so a working photo later gets the effect again.
const showPhoto = contextSafe(() => {
gsap.set(photo, { opacity: 1 });
gsap.set([cellsCanvas, text], { display: "none" });
});
const tryResample = () => {
try {
resample();
} catch {
showPhoto();
}
};
const reveal = contextSafe(() => goTo(2));
const cover = contextSafe(() => goTo(1));
// Hover and keyboard focus both hold it open: leaving one keeps it open while the other is still there
const onPointerLeave = () => {
if (!box.matches(":focus-visible")) cover();
};
const onBlur = () => {
if (!box.matches(":hover")) cover();
};
const listeners: [string, () => void][] = [];
const cleanups: (() => void)[] = [];
// contextSafe too (it runs once the photo has decoded), so its set is undone on a prop change
// and the photo goes back under the old cells instead of showing bare for a frame
const start = contextSafe(() => {
if (!alive) return;
try {
resample();
} catch {
showPhoto();
return;
}
// The canvas now covers it, so the photo can be there underneath
gsap.set(photo, { opacity: 1 });
const hoverScreen = window.matchMedia("(hover: hover)").matches;
if (trigger === "load") {
goTo(1, delay, () => (hold = gsap.delayedCall(0.35, reveal)));
} else if (trigger === "hover" && hoverScreen) {
// Already under the pointer (say, after a prop change): open straight away
if (box!.matches(":hover")) goTo(2);
else goTo(1, delay);
listeners.push(["pointerenter", reveal], ["pointerleave", onPointerLeave], ["focus", reveal], ["blur", onBlur]);
listeners.forEach(([type, listener]) => box!.addEventListener(type, listener));
} else {
// inView, and hover on touch screens (a tap would only flash it): scramble in, then open,
// once at least a third of it is on screen
const watcher = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
watcher.disconnect();
goTo(1, delay, () => (hold = gsap.delayedCall(0.35, reveal)));
},
{ threshold: 0.35 },
);
watcher.observe(box!);
cleanups.push(() => watcher.disconnect());
}
});
const onResize = new ResizeObserver(tryResample);
onResize.observe(box);
resampleRef.current = tryResample;
// A theme switch changes the colours (the canvas paints them itself) and maybe which end is dense.
// Once now, and once more when a colour transition on the page would have finished.
let settle: gsap.core.Tween | null = null;
const onTheme = () => {
tryResample();
settle?.kill();
settle = gsap.delayedCall(0.5, tryResample);
};
const themeWatcher = new MutationObserver(onTheme);
themeWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class", "style"] });
const colourScheme = window.matchMedia("(prefers-color-scheme: dark)");
colourScheme.addEventListener("change", onTheme);
image.src = src;
image
.decode()
.then(start)
.catch(() => alive && showPhoto());
return () => {
alive = false;
resampleRef.current = null;
settle?.kill();
image.src = "";
hold?.kill();
tween?.kill();
onResize.disconnect();
themeWatcher.disconnect();
colourScheme.removeEventListener("change", onTheme);
listeners.forEach(([type, listener]) => box.removeEventListener(type, listener));
cleanups.forEach((cleanup) => cleanup());
};
},
{ scope: boxRef, dependencies: [src, columns, rows, charset, scrambleChars, trigger, delay, mosaic, invert], revertOnUpdate: true },
);
// The canvas paints the colours itself, so a new background or ink needs a repaint (after the
// box has its new style, which is why this is an effect)
useEffect(() => {
resampleRef.current?.();
}, [background, color]);
// Cell size from the box's own size (container query units), so the characters fit any box with no measuring
const cell = { width: `calc(100cqw / ${columns})`, height: `calc(100cqh / ${rows})` };
return (
<div
ref={boxRef}
tabIndex={trigger === "hover" ? 0 : undefined}
className={`relative overflow-hidden outline-offset-4 focus-visible:outline-2 focus-visible:outline-current ${className}`}
style={{ backgroundColor: background, color }}
>
{/* Hidden until the canvas covers it (or until we know there will be no ASCII) */}
<img ref={imageRef} src={src} alt={alt} draggable={false} className="absolute inset-0 h-full w-full object-cover" style={{ opacity: 0 }} />
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ containerType: "size" }}>
<canvas ref={cellsRef} className="absolute inset-0 h-full w-full" style={{ imageRendering: "pixelated" }} />
<pre
ref={textRef}
className="absolute inset-0 m-0 overflow-hidden font-bold whitespace-pre select-none"
style={{
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
// A monospace character is about 0.6 of its size wide; shrink it if the rows are tight
fontSize: `min(calc(${cell.width} / 0.6), calc(${cell.height} * 1.15))`,
lineHeight: cell.height,
// 1ch is one character's width in this font, so every character gets exactly one cell
letterSpacing: `calc(${cell.width} - 1ch)`,
paddingLeft: `calc((${cell.width} - 1ch) / 2)`,
}}
/>
</div>
</div>
);
}
The component. Installs to components/dimaac/ascii-reveal.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/ascii-reveal.jsonIt adds components/dimaac/ascii-reveal.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/ascii-reveal.
By hand
npm install gsap @gsap/react- Install the packages it uses: gsap, @gsap/react.
- Copy the component file below into components/dimaac/ascii-reveal.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { AsciiReveal } from "@/components/dimaac/ascii-reveal" and give its parent a size: it fills it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
src (required) | string | - | The photo. Remote images need CORS (the canvas reads their pixels); without it the photo shows as is. |
alt | string | "" | Describes the photo for screen readers. Empty means the photo is decoration. |
columns | number | 25 | Characters across. |
rows | number | 22 | Characters down. |
charset | string | DEFAULT_CHARSET | The characters, from the emptiest to the densest. The first one is usually a space. |
scrambleChars | string | "#%@=+*" | The characters a cell flickers through while it scrambles. |
duration | number | 1.2 | How long one change takes (ASCII in, or ASCII to photo), in seconds. |
trigger | "hover" | "inView" | "load" | "hover" | What reveals the photo: hovering or focusing it (touch screens use inView), scrolling it into view, or the page loading. |
delay | number | 0 | Wait before the ASCII starts to scramble in, in seconds. Handy to stagger a grid of them. |
mosaic | boolean | true | Each cell flashes the colour of its patch of the photo just before it opens. |
invert | boolean | false | Use dense characters for the other end of the brightness range (a negative). |
background | string | "#111111" | Background behind the characters. Any CSS colour, including var(--something). A transparent or see-through one takes the nearest solid background behind the box, so the photo never shows through the ASCII. |
color | string | "#d4d4d4" | Colour of the characters. Any CSS colour, including var(--something). |
className | string | "" | Extra classes for the outer box. Give it a size (it fills it). |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
Add the ASCII Reveal component (<AsciiReveal />) from DiMaac UI to this project.
Docs: https://ui.dimaac.com/components/ascii-reveal
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/ascii-reveal.json
Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/ascii-reveal.json
Then use it like this:
import { AsciiReveal } from "@/components/dimaac/ascii-reveal";
<AsciiReveal src={src} />
If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/ascii-reveal.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txtFiles agents read
- llms.txt
- Every component and how to install it
- ascii-reveal.md
- This page as Markdown: install, props and the full source
- ascii-reveal.json
- The shadcn registry item
About
A photo that starts as ASCII characters and scrambles into the real image. The photo is shrunk to one pixel per character and each pixel's brightness picks a character, so the portrait is still recognisable in text. One GSAP tween drives it all: the characters flicker in from nothing, then every cell scrambles again, flashes the colour of its patch of the photo and opens onto the real picture, each at its own random moment. It plays on hover (and keyboard focus), when it scrolls into view, or on load; on touch screens hover falls back to in view. The characters follow the theme (light ink is drawn as a negative automatically), it fits any box and any grid from 10 to 80 cells with no measuring, and it shows the plain photo when the visitor prefers reduced motion or the image has no CORS.
- Installs to
components/dimaac/ascii-reveal.tsx- Uses
- gsap, @gsap/react
- Tags
- gsap, ascii, scramble, image, hover, canvas
Portrait by West Kast, Shaun Duval, studiomiklus, Dynamic Wang, Lloyd Creates and Steph Meade on Lummi (Lummi License).