Skip to content

Components / Scroll

Pixel Reveal

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.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 4.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 115 · not Firefox · Safari 26; needs scroll-timeline. Elsewhere: without view timelines the picture is shown whole and no square is drawn.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import PixelReveal from '@moonarc/core/PixelReveal';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/pixel-reveal.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

PixelReveal.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>

Props

PropTypeDefaultDescription
colsnumber12Squares across, 4 to 24.
rowsnumber8Squares down, 3 to 18.
colourstringnoneThe squares' colour. Unset, the block's ground, else the page's.
seedstring''A word the scatter is varied by.

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.

Why it is built this way

Replaces: Pixel Transition (React Bits) · Pixelated image reveal (Magic UI). See the migration table.

The scene as a line drawingThe scene in colour

drag · click · ← →

Compares a before and an after image with a line you drag between them.

newly · Chrome 124 · Firefox 121 · Safari 16.5pointerclick

Room Art

Backgrounds

A room drawn flat, for a stay that has no photograph of it yet: a single, a double, a twin, a suite, a family room, a bunk room or a loft, in the colours of the block or tone it sits on, with a shade, a light and a pale tone mixed from them.

every browserpointer

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown