---
title: ASCII Reveal
description: A photo typed out in ASCII that scrambles into the real image.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/ascii-reveal
registry: https://ui.dimaac.com/r/ascii-reveal.json
---

# ASCII Reveal

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.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/ascii-reveal.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/ascii-reveal.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/ascii-reveal.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/ascii-reveal`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/ascii-reveal.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { AsciiReveal } from "@/components/dimaac/ascii-reveal" and give its parent a size: it fills it.

## Usage

```tsx
import { AsciiReveal } from "@/components/dimaac/ascii-reveal";

<AsciiReveal src={src} />
```

## 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). |

## Credits

- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/monochrome-portrait-of-a-young-woman-shb7s), Lummi License
- Portrait: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Portrait: studiomiklus, Lummi (https://www.lummi.ai/photo/striking-portrait-jodiu), Lummi License
- Portrait: Dynamic Wang, Lummi (https://www.lummi.ai/photo/profile-portrait-of-woman-xyyue), Lummi License
- Portrait: Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Portrait: Steph Meade, Lummi (https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc), Lummi License

## Source

### ascii-reveal.tsx → `components/dimaac/ascii-reveal.tsx`

```tsx
"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>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { AsciiReveal, type AsciiRevealProps } from "@/components/dimaac/ascii-reveal";

// A small index of six portraits, each typed out in ASCII until you hover it.
// Photos from Lummi (Lummi License), credited in meta.ts.

const portraits = [
  { file: "portraits/mono-profile-4x5.jpg", title: "Mono profile", by: "West Kast", alt: "Black and white profile of a young woman in dramatic light" },
  { file: "portraits/neon-profile-4x5.jpg", title: "Neon pink", by: "Shaun Duval", alt: "A woman in neon pink sunglasses in profile against a bright blue background" },
  { file: "portraits/platinum-blonde-4x5.jpg", title: "Platinum", by: "studiomiklus", alt: "A young man with platinum blonde hair and a yellow jacket against an orange background" },
  { file: "portraits/warm-cool-profile-4x5.jpg", title: "Two lights", by: "Dynamic Wang", alt: "Profile of a woman lit by warm and cool light" },
  { file: "portraits/red-coat-4x5.jpg", title: "Red coat", by: "Lloyd Creates", alt: "A person in a red coat against a deep blue background" },
  { file: "portraits/lime-blazer-4x5.jpg", title: "Lime blazer", by: "Amino", alt: "A person with curly red hair in a lime green blazer against a bright pink background" },
];

export default function Demo(props: Partial<AsciiRevealProps>) {
  return (
    <div className="flex h-full w-full flex-col gap-4 bg-[#eeeae3] px-4 pt-5 pb-5 text-[#1b1a18] [--ink:#1b1a18] [--tile:#e3ded5] md:gap-6 md:px-10 md:pt-8 md:pb-8 lg:flex-row lg:gap-12 dark:bg-[#0b0b0b] dark:text-[#dcd8d0] dark:[--ink:#d9d5cd] dark:[--tile:#151515]">
      <header className="flex shrink-0 items-end justify-between gap-6 lg:w-[300px] lg:flex-col lg:items-start xl:w-[340px]">
        <div>
          <h1 className="text-[30px] leading-[0.95] font-medium tracking-[-0.04em] md:text-[52px] lg:text-[64px]">
            Faces, <br className="hidden lg:block" />
            typed out.
          </h1>
        </div>
        <div className="hidden font-mono text-[11px] leading-[1.6] tracking-[0.08em] uppercase sm:block lg:w-full">
          <p className="max-w-[26ch] text-right opacity-55 lg:text-left">Hover a portrait to scramble it into the photo</p>
          {/* The charset, emptiest to densest, as a little legend */}
          <p className="mt-5 hidden justify-between border-t border-current/15 pt-3 opacity-55 lg:flex" aria-hidden="true">
            {[..." .:-=+*#%@"].map((char, i) => (
              <span key={i} className="w-[1ch] text-center whitespace-pre">
                {char}
              </span>
            ))}
          </p>
        </div>
      </header>

      {/* The tiles take the biggest 4:5 size that fits: 2 x 3 on phones, 3 x 2 from tablets up */}
      <main className="min-h-0 min-w-0 flex-1" style={{ containerType: "size" }}>
        <ul className="mx-auto grid h-full w-fit grid-cols-[repeat(2,var(--tile-width))] content-center gap-x-3 gap-y-3 [--tile-width:min(calc((100cqw-12px)/2),calc(((100cqh-24px)/3-22px)*0.8))] md:grid-cols-[repeat(3,var(--tile-width))] md:gap-x-6 md:gap-y-5 md:[--tile-width:min(calc((100cqw-48px)/3),calc(((100cqh-20px)/2-26px)*0.8))] lg:mr-0">
          {portraits.map((portrait, i) => (
            <li key={portrait.file}>
              <figure>
                <AsciiReveal
                  src={media(portrait.file)}
                  alt={portrait.alt}
                  delay={0.15 + i * 0.12}
                  background="var(--tile)"
                  color="var(--ink)"
                  {...props}
                  className={`aspect-[4/5] w-full ${props.className ?? ""}`}
                />
                <figcaption className="mt-2 flex justify-between gap-3 font-mono text-[10px] leading-none tracking-[0.1em] whitespace-nowrap uppercase md:text-[11px]">
                  <span>
                    {portrait.title}
                  </span>
                  <span className="hidden opacity-45 md:inline">No. {String(i + 1).padStart(2, "0")}</span>
                </figcaption>
              </figure>
            </li>
          ))}
        </ul>
      </main>
    </div>
  );
}
```

## Agent prompt

```text
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.txt
```
