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