# Scroll reveal image

An image that sharpens from coarse dithered blocks to the full photo as you scroll it into view.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The dithercn agent skill, for building any dither on the engine, is at [/.well-known/agent-skills/dithercn/SKILL.md](/.well-known/agent-skills/dithercn/SKILL.md).

> **This is an example, not the only option.** It is one component built on the dithercn engine. Install it when it is what you were asked for. For any other look, don't stretch its props to fit: write your own field on `DitherShader` ([Write your own dither](/docs/engine/write-your-own.md)), and read this example's source for technique. Its full source is in [https://dithercn.dev/r/scroll-reveal-image.json](https://dithercn.dev/r/scroll-reveal-image.json), under `files[].content`.

## Installation [#installation]

```bash
npx shadcn@latest add https://dithercn.dev/r/scroll-reveal-image.json
```

## Usage [#usage]

```tsx
import { ScrollRevealImage } from "@/components/dithercn/scroll-reveal-image";

export function Chapter() {
  return (
    <ScrollRevealImage
      src="/photos/launch.jpg"
      alt="The launch pad at dawn"
      className="aspect-video w-full rounded-xl"
    />
  );
}
```

Progress follows the scroll position, not a timer. When the top of the image enters at the bottom of the viewport it is a few large blocks. It sharpens in nine steps to full dither detail, then the photo dissolves in cell by cell. It is finished when its top edge is 30% down the viewport. Scroll back and it runs in reverse.

`start` and `finish` move those two points, as a share of the viewport height from the top. Inside a scrolling panel, pass the panel's ref as `scrollRoot`, as the demo above does.

## Examples [#examples]

### In a long read [#in-a-long-read]

The image resolves as the reader reaches it. `finish={0.4}` completes it a little lower on the screen.

```tsx title="examples/scroll-reveal-image-story.tsx"
import { ScrollRevealImage } from "@/components/dithercn/scroll-reveal-image";

export default function ScrollRevealImageStory() {
  return (
    <section className="flex w-full max-w-2xl flex-col gap-6">
      <div className="flex flex-col gap-2">
        <span className="text-primary font-mono text-xs tracking-[0.14em] uppercase">
          Chapter 2
        </span>
        <h3 className="text-2xl font-medium tracking-tight text-balance">
          Seen from 380,000 kilometres away
        </h3>
      </div>
      <ScrollRevealImage
        src="/examples/earthrise.jpg"
        alt="Earth rising over the Moon's horizon, photographed from Apollo 8"
        color="blue"
        finish={0.4}
        ratio={16 / 9}
        className="w-full rounded-xl"
      />
    </section>
  );
}
```

### Ending on dither [#ending-on-dither]

`end="dither"` stops at full dither detail and never shows the photo. A later `start` on the second image staggers the pair.

```tsx title="examples/scroll-reveal-image-gallery.tsx"
import { ScrollRevealImage } from "@/components/dithercn/scroll-reveal-image";

const SHOTS = [
  { alt: "Buzz Aldrin on the Moon", src: "/examples/aldrin.jpg" },
  { alt: "The Pillars of Creation", src: "/examples/pillars.jpg" },
] as const;

export default function ScrollRevealImageGallery() {
  return (
    <div className="grid w-full max-w-xl grid-cols-2 gap-3">
      {SHOTS.map((shot, index) => (
        <ScrollRevealImage
          key={shot.src}
          src={shot.src}
          alt={shot.alt}
          preset="gameboy"
          end="dither"
          coarsest={16}
          // The second image starts a little later, so the pair staggers.
          start={1 - index * 0.12}
          ratio={4 / 5}
          className="w-full rounded-lg"
        />
      ))}
    </div>
  );
}
```

## Props [#props]

Takes every look prop of [Dither image](/docs/images/dither-image) (`preset`, `color`, `palette`, `method`, `cell`, `contrast`, `brightness`, `normalize`, `sharpen`, `invert`, `fit`), plus:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` (required) | `string` |  | Image URL. Cross-origin images need CORS headers. |
| `alt` (required) | `string` |  | Describes the image. Pass "" when it is decorative. |
| `end` | `"photo" \| "dither"` | `"photo"` | Finish on the original photo, or on the finest dither. |
| `coarsest` | `number` | `24` | How many cells wide the first blocks are. |
| `start` | `number` | `1` | Where the image's top edge is when the reveal starts, 0 (top) to 1 (bottom of the viewport). |
| `finish` | `number` | `0.3` | Where the top edge is when the reveal finishes. |
| `scrollRoot` | `RefObject<HTMLElement \| null>` |  | The scrolling element, when it is not the page. |
| `progress` | `number` |  | Set the progress yourself, 0 to 1, instead of following the scroll. |
| `onProgressChange` | `(progress: number) => void` |  | Called when the progress changes. |
| `ratio` | `number` | `The image's ratio` | Width over height. |

## Notes [#notes]

* Under reduced motion the image is shown finished: the photo, or the fine dither with `end="dither"`.
* The root has `role="img"` and your `alt` text as its label, so screen readers hear the image once, whatever stage it is at. `data-stage` is `coarse`, `fine` or `photo`.
* Each coarseness is dithered once and cached, so scrolling back and forth costs nothing after the first pass. Scroll handling only runs while the image is near the viewport, at most once per frame.