# Pixel Reveal (Moonarc)

A picture that resolves from blocks as it scrolls into view: a grid of squares in one colour lies over the slot, and each square clears on its own stretch of the picture's entry, picked by a seed, so the picture comes through in a scatter and stands whole by the time it is a third of the way up the screen. The squares run on the picture's own view timeline, so there is no script and no observer. Where view timelines are not supported, and with reduced motion, the picture is shown whole and the squares are never drawn. Zero JavaScript.

- Import: `import PixelReveal from '@moonarc/core/PixelReveal'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/pixel-reveal.json`
- Tier A · category scroll · trigger scroll
- Readout: `<PixelReveal cols={12} rows={8}><img … /></PixelReveal>`
- Browser support: limited (Chrome 115 · not Firefox · Safari 26); elsewhere: without view timelines the picture is shown whole and no square is drawn
- Measured cost: 0 B JS (CSS 4.9 kB raw)
- Page: https://moonarc.dev/components/pixel-reveal/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `cols` | `number` | `12` | Squares across, 4 to 24. |
| `rows` | `number` | `8` | Squares down, 3 to 18. |
| `colour` | `string` | none | The squares' colour. Unset, the block's ground, else the page's. |
| `seed` | `string` | `''` | A word the scatter is varied by. |

## Usage

```astro
<PixelReveal cols={14} rows={9} seed="garden">
  <img src="/garden.webp" alt="The garden at dusk, lanterns in the trees" width="1600" height="1000" />
</PixelReveal>
```

## Reduced motion

The squares are not drawn: the picture is there whole.

## With ClientRouter

No script: it works on any page however it was reached.

## Craft

- One named view timeline on the grid, read by every square through its own animation-range: entry plus a seeded start, so a hundred squares cost no script.
- The starts lean earlier towards the bottom, the edge that enters first, so the picture clears upwards with the scroll.
- The squares overlap by half a pixel, so no seam of the picture shows between two that have not cleared.
- A picture already in view on load is past its entry, so it paints whole at once.

## Replaces

- Pixel Transition (React Bits)
- Pixelated image reveal (Magic UI)

## Source

```astro
---
/**
 * PixelReveal — a picture that resolves from blocks as it scrolls into view: a grid of squares in one colour lies over
 * the slot, and each square clears on its own stretch of the picture's entry, picked by a seed, so the picture comes
 * through in a scatter and stands whole by the time it is a third of the way up the screen. The squares run on the
 * picture's own view timeline: no script, no observer. Where view timelines are not supported, and with reduced
 * motion (the media query, or a page's data-ma-motion="reduce"), the picture is shown whole and the squares are never
 * drawn. Zero JavaScript.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Squares across, 4 to 24. */
  cols?: number;
  /** Squares down, 3 to 18. */
  rows?: number;
  /** The squares' colour, any CSS colour. Unset, the block's ground, else the page's. */
  colour?: string;
  /** A word the scatter is varied by. */
  seed?: string;
}

const { cols = 12, rows = 8, colour, seed = '', class: className, style, ...rest } = Astro.props;
if (cols < 4 || cols > 24 || rows < 3 || rows > 18) throw new Error(`PixelReveal: ${cols} by ${rows} squares; it takes 4 to 24 across and 3 to 18 down.`);
let h = 2166136261;
for (const c of `${cols}x${rows}:${seed}`) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
const r = () => ((h = Math.imul(h ^ (h >>> 15), 2246822507) + 0x6d2b79f5) >>> 0) / 4294967296;
// each square's start along the entry, 0 to 60 %: a scatter, a little earlier towards the bottom, which arrives first
const cells = Array.from({ length: cols * rows }, (_, i) => Math.round((r() * 0.75 + (1 - Math.floor(i / cols) / (rows - 1)) * 0.25) * 60));
const vars = [`--ma-px-cols:${cols}`, `--ma-px-rows:${rows}`, colour ? `--ma-px-colour:${colour}` : '', typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-pixel', className]} style={vars} {...rest}>
  <div class="ma-pixel__media"><slot /></div>
  <div class="ma-pixel__cells" aria-hidden="true">{cells.map((s) => <i style={`--s:${s}%`} />)}</div>
</div>

<style is:global>
  @layer components {
    :where(.ma-pixel) { position: relative; display: grid; overflow: hidden; }
    :where(.ma-pixel__media) { grid-area: 1 / 1; min-inline-size: 0; }
    :where(.ma-pixel__media > *) { display: block; inline-size: 100%; block-size: 100%; }
    :where(.ma-pixel__cells) { display: none; }
    @supports (animation-timeline: view()) {
      :where(.ma-pixel__cells) {
        display: grid;
        grid-area: 1 / 1;
        grid-template-columns: repeat(var(--ma-px-cols), 1fr);
        grid-template-rows: repeat(var(--ma-px-rows), 1fr);
        pointer-events: none;
        view-timeline: --ma-px block;
      }
      /* a hair of overlap, so no seam of the picture shows between two squares that have not cleared */
      :where(.ma-pixel__cells > i) { margin: -0.5px; background: var(--ma-px-colour, var(--ma-block-ground, var(--background, Canvas))); animation: ma-px-clear linear both; }
      /* the timeline in a rule of its own: in the same rule as the shorthand, a minifier folds it into the shorthand,
         which does not take one, and the browser drops the declaration */
      :where(html) :where(.ma-pixel__cells > i) { animation-timeline: --ma-px; animation-range: entry var(--s) entry calc(var(--s) + 40%); }
    }
    /* reduced motion (the media query, or the site's data-ma-motion switch): the picture whole, no squares */
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-pixel__cells) { display: none; }
    }
    @keyframes ma-px-clear {
      to { opacity: 0; }
    }
  }
</style>

```
