GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Image Reveal Effect CSS — 9 Canvas-computed Wipes

Among nine image reveal effect css demos, one derives timing from a diagonal coordinate, one precomputes noise per cell, and one fixes a seed so glitch rows

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine ways to swap one picture for another sit in this set: a diagonal coordinate that sweeps a slanted edge across the frame, an angle that turns the edge into a rotating radial wipe, a value-noise field that dissolves the image in scattered but repeatable clumps, a melt order that lets a mosaic block fade into place one at a time, a distance-from-center order that drops triangular shards away from the frame, a fixed seed that misaligns and then realigns horizontal rows, a distance field built from several ink points that bleeds outward with a ragged edge, a per-cell brightness value that grows halftone dots toward their bound, and a sine curve whose phase rolls a water-like boundary up the frame. None of the nine reach for a canvas transition animation library — every pattern below is one small canvas panel, a click/keydown baseline, and exactly one named calculation function reading or writing pixels on top of that.

Demo

Screen Wipe Transition Computed by Diagonal Coordinate
Open in a new tab

01 · Screen Wipe Transition Computed by Diagonal Coordinate — Measured area 48.76% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

tFieldoutImgputImageData

Pattern 01 above needs nothing beyond a tField array and a RENDER function that reads it back through getImageData/putImageData to turn a pixel's diagonal position into its reveal timing. The remaining eight patterns are laid out further down the page, each next to its matching code block.

Anatomy of nine image reveal effect css patterns — where the one layer of calculation runs

# Pattern Cumulative area Avg. intensity Frames moved
01 Diagonal coordinate wipe 48.76% 126.4 23 of 23
02 Angle-swept radial reveal 48.249% 132.6 23 of 23
03 Noise-threshold dissolve 48.761% 169.8 20 of 23
04 Melt-order mosaic reveal 48.051% 147.4 22 of 23
05 Distance-ordered shard fall 48.444% 83.5 23 of 23
06 Seeded row glitch realign 47.115% 158.8 23 of 23
07 Ink-drop distance bleed 48.568% 115.0 23 of 23
08 Brightness-bound dot grow 34.422% 79.9 21 of 23
09 Sine-phase wave reveal 47.283% 163.5 22 of 23

All nine call requestAnimationFrame directly — there is no CSS transition or @keyframes doing the pixel work here. Each pattern precomputes one Float32Array (a coordinate field, a per-cell threshold, or a per-shard order) once, then a RENDER(p) function walks that same array on every frame and either rewrites the canvas through getImageData/putImageData or redraws it with drawImage/clip/fillRect.

Pattern 03 covers the most area (48.761%) and has the highest average intensity (169.8) of the nine, but moves in the fewest frames (20 of 23) — its noise-threshold cells swap in as whole 16px blocks rather than a soft per-pixel fade, so each frame that does move changes a lot of pixels at once while a few early and late frames land between two threshold crossings and show nothing new. Pattern 08 sits at the other end: it covers the least area (34.422%) and has the lowest average intensity (79.9), since a halftone dot's radius is capped by that cell's own brightness — dark cells never grow into large dots even once the reveal finishes, so a meaningful share of the frame simply never fills in. A photo reveal effect built from real photographs would likely widen that same gap, because brightness spread across a photo tends to be less even than the synthetic scene drawn here.

Coordinate field computed once, sampled every frame

Patterns 01, 02, and 07 each build a Float32Array sized to the full canvas pixel count in a PRECOMPUTE step, then read that same field back inside RENDER's pixel loop. 01's tField stores each pixel's diagonal position (x/iw + y/ih) divided in half. 02's angField stores each pixel's angle from the panel's center via Math.atan2, rotated so 12 o'clock reads as zero. 07's distField stores the distance from several fixed ink-drop points, blended with a value-noise offset so the edge that results is ragged rather than a clean circle.

Per-cell or per-row values locked in by a fixed seed or brightness

Patterns 03, 06, and 08 precompute one number per grid cell or per row instead of per pixel, and RENDER never touches raw pixel data for these three — it only compares progress against that stored number and calls drawImage, fillRect, or arc. 03's cellNoise runs a value-noise function seeded through a fixed permutation table (buildPerm) to give each cell an irregular but repeatable threshold. 06's rowOffset and rowOrder come from the same seeded generator (makeLcg), so the same rows always misalign by the same horizontal distance. 08's dotR reads each cell's average brightness once and caps that cell's dot radius to it.

Geometry recomputed from progress on every frame

Patterns 04, 05, and 09 keep a lighter precompute step and instead recompute a small piece of geometry inside RENDER itself. 04 precomputes a meltOrder rank per block from its row and column, then on every frame recalculates how far a still-melting block's colored rectangle should shrink before the sharp image underneath shows through. 05 precomputes each triangular shard's distance-from-center order, then on every frame recalculates how far a falling shard should translate downward and how transparent it should be. 09 skips precompute almost entirely — every frame it recomputes the wave boundary's height fresh from a sine function of the column position and the current progress.

Code per pattern

Nine calculation functions, one per pattern — each is a contiguous slice straight out of the demo's own index.html, not a simplified rewrite.

01Screen Wipe Transition Computed by Diagonal Coordinate

A canvas covers the whole panel and reads each pixel's diagonal coordinate back out of tField to decide, frame by frame, whether that pixel should still show the old picture or the new one. The edge that results is a straight diagonal line sweeping from one corner toward the opposite side.

Screen Wipe Transition Computed by Diagonal Coordinate
Open in a new tab

01 · Screen Wipe Transition Computed by Diagonal Coordinate — Measured area 48.76% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

tFieldoutImgputImageData
  function RENDER(p) {
    var o = outImg.data;
    for (var i = 0, n = tField.length; i < n; i++) {
      var t = tField[i], k = i * 4, reveal;
      if (t <= p) reveal = 1;
      else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
      else reveal = 0;
      o[k] = dataB[k] + (dataA[k] - dataB[k]) * reveal;
      o[k + 1] = dataB[k + 1] + (dataA[k + 1] - dataB[k + 1]) * reveal;
      o[k + 2] = dataB[k + 2] + (dataA[k + 2] - dataB[k + 2]) * reveal;
      o[k + 3] = 255;
    }
    ctx.putImageData(outImg, 0, 0);
  }

02Clockwise Radial Reveal Computed by Angle

The same pixel-blend loop as 01 runs here, except the field it reads is each pixel's angle around the panel's center rather than its diagonal position, so the boundary rotates like a clock hand instead of sliding straight across. A thin accent line is drawn on top at the current sweep angle so the motion reads clearly even mid-frame.

Clockwise Radial Reveal Computed by Angle
Open in a new tab

02 · Clockwise Radial Reveal Computed by Angle — Measured area 48.249% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

angFieldstrokeStyleputImageData
  function RENDER(p) {
    var o = outImg.data;
    for (var i = 0, n = angField.length; i < n; i++) {
      var t = angField[i], k = i * 4, reveal;
      if (t <= p) reveal = 1;
      else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
      else reveal = 0;
      o[k] = dataB[k] + (dataA[k] - dataB[k]) * reveal;
      o[k + 1] = dataB[k + 1] + (dataA[k + 1] - dataB[k + 1]) * reveal;
      o[k + 2] = dataB[k + 2] + (dataA[k + 2] - dataB[k + 2]) * reveal;
      o[k + 3] = 255;
    }
    ctx.putImageData(outImg, 0, 0);
    ctx.save();
    ctx.strokeStyle = ACCENT; ctx.lineWidth = 2; ctx.globalAlpha = .8;
    var ang = p * Math.PI * 2 - Math.PI / 2;
    var r = Math.min(W, H) * 0.42;
    ctx.beginPath();
    ctx.moveTo(cx0 / dpr, cy0 / dpr);
    ctx.lineTo(cx0 / dpr + Math.cos(ang) * r, cy0 / dpr + Math.sin(ang) * r);
    ctx.stroke();
    ctx.restore();
  }

03Dissolve Whose Per-cell Order Comes From a Value-noise Field

The panel is divided into a grid of small cells, and each cell's own value-noise reading decides when that cell swaps from the old picture to the new one. Because the noise field is generated from a fixed seed, the same cells always dissolve in the same order even though the pattern looks irregular.

Dissolve Whose Per-cell Order Comes From a Value-noise Field
Open in a new tab

03 · Dissolve Whose Per-cell Order Comes From a Value-noise Field — Measured area 48.761% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

buildPermcellNoisefadeN
  function PRECOMPUTE() {
    cols = Math.ceil(W / NOISE_CELL_PX);
    rows = Math.ceil(H / NOISE_CELL_PX);
    cellNoise = new Float32Array(cols * rows);
    for (var r = 0; r < rows; r++) {
      for (var c = 0; c < cols; c++) cellNoise[r * cols + c] = noise2(c * NOISE_FREQ, r * NOISE_FREQ);
    }
  }

  function RENDER(p) {
    ctx.drawImage(bufB, 0, 0, W, H);
    var cw = Math.round(NOISE_CELL_PX * dpr), ch = Math.round(NOISE_CELL_PX * dpr);
    for (var r = 0; r < rows; r++) {
      for (var c = 0; c < cols; c++) {
        if (cellNoise[r * cols + c] <= p) {
          var sx = Math.round(c * NOISE_CELL_PX * dpr), sy = Math.round(r * NOISE_CELL_PX * dpr);
          ctx.drawImage(bufA, sx, sy, cw, ch, c * NOISE_CELL_PX, r * NOISE_CELL_PX, NOISE_CELL_PX, NOISE_CELL_PX);
        }
      }
    }
  }

04Mosaic Resolve Whose Block Size Shrinks From a Time Function

Each block in a coarse grid holds a flat averaged color at first, and a meltOrder value computed from that block's row and column decides when it switches to the sharp picture underneath. Just before that switch, the flat color's rectangle shrinks in height so the sharp image seems to melt upward through it.

Mosaic Resolve Whose Block Size Shrinks From a Time Function
Open in a new tab

04 · Mosaic Resolve Whose Block Size Shrinks From a Time Function — Measured area 48.051% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

avgColormeltOrderdrawImage
  function RENDER(p) {
    ctx.drawImage(bufB, 0, 0, W, H);
    for (var r = 0; r < rows; r++) {
      for (var c = 0; c < cols; c++) {
        var order = meltOrder[r * cols + c];
        var bx = c * BLOCK_PX, by = r * BLOCK_PX;
        if (order > p) {
          var sx = Math.round(c * BLOCK_PX * dpr), sy = Math.round(r * BLOCK_PX * dpr);
          ctx.drawImage(bufA, sx, sy, Math.round(BLOCK_PX * dpr), Math.round(BLOCK_PX * dpr), bx, by, BLOCK_PX, BLOCK_PX);
        } else if (order > p - MELT_WINDOW) {
          var u = (p - order) / MELT_WINDOW;
          var h = BLOCK_PX * (1 - u);
          ctx.fillStyle = avgColor[r * cols + c];
          ctx.fillRect(bx, by + BLOCK_PX * u * 0.6, BLOCK_PX, h);
        }
      }
    }
  }

05Fracture Transition Whose Fall Is Computed Per Triangular Shard

The frame is split into small triangular shards, and each shard's distance from the panel's center decides when it starts falling. A shard that has started falling is clipped to its own triangle, translated straight down by an amount computed from how far past its threshold the progress value is, and faded out at the same time.

Fracture Transition Whose Fall Is Computed Per Triangular Shard
Open in a new tab

05 · Fracture Transition Whose Fall Is Computed Per Triangular Shard — Measured area 48.444% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

clipTriglobalAlphamaxDist
  function RENDER(p) {
    ctx.drawImage(bufB, 0, 0, W, H);
    for (var i = 0; i < shards.length; i++) {
      var s = shards[i];
      if (s.dist > p) {
        ctx.save(); clipTri(s.tri);
        ctx.drawImage(bufA, 0, 0, cv.width, cv.height, 0, 0, W, H);
        ctx.restore();
      } else {
        var u = Math.min(1, (p - s.dist) / FALL_WINDOW);
        if (u < 1) {
          var off = u * u * FALL_DIST_PX;
          ctx.save();
          ctx.globalAlpha = 1 - u;
          ctx.translate(0, off);
          clipTri(s.tri);
          ctx.drawImage(bufA, 0, 0, cv.width, cv.height, 0, -off, W, H);
          ctx.restore();
        }
      }
    }
  }

06Glitch Transition Whose Row Offsets Are Computed From a Fixed Seed

The panel is sliced into thin horizontal rows, and a fixed-seed generator assigns each row a left-right offset once, before any rendering starts. As progress sweeps past a row's own position in the stack, that row's offset shrinks back toward zero and the row settles into its final, aligned place showing the new picture.

Glitch Transition Whose Row Offsets Are Computed From a Fixed Seed
Open in a new tab

06 · Glitch Transition Whose Row Offsets Are Computed From a Fixed Seed — Measured area 47.115% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

makeLcgrowOffsetrowOrder
  function PRECOMPUTE() {
    rowsN = Math.ceil(H / ROW_PX);
    var rng = makeLcg(SEED_GLITCH); // fixed seed — never redrawn per frame
    rowOffset = new Float32Array(rowsN);
    rowOrder = new Float32Array(rowsN);
    for (var i = 0; i < rowsN; i++) {
      rowOffset[i] = (rng() * 2 - 1) * GLITCH_MAX_PX;
      rowOrder[i] = i / rowsN;
    }
  }

  function RENDER(p) {
    for (var i = 0; i < rowsN; i++) {
      var order = rowOrder[i];
      var src = order <= p ? bufA : bufB;
      var near = Math.abs(order - p);
      var dx = near < GLITCH_WINDOW ? rowOffset[i] * (1 - near / GLITCH_WINDOW) : 0;
      var dy = i * ROW_PX;
      var sh = Math.min(ROW_PX, H - dy);
      if (sh <= 0) continue;
      var sy = Math.round(dy * dpr), shPx = Math.round(sh * dpr);
      ctx.drawImage(src, 0, sy, cv.width, shPx, dx, dy, W, sh);
    }
  }

07Ink-bleed Reveal Whose Spread Radius Comes From a Distance Field

A handful of fixed points act as ink drops, and every pixel's distance to its nearest drop — blended with a touch of value noise so the edge isn't a clean circle — decides when that pixel switches pictures. The result reads like ink spreading outward from a few origin points rather than one uniform sweep.

Ink-bleed Reveal Whose Spread Radius Comes From a Distance Field
Open in a new tab

07 · Ink-bleed Reveal Whose Spread Radius Comes From a Distance Field — Measured area 48.568% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

distFieldnoiseIbuildPerm
  function RENDER(p) {
    var o = outImg.data;
    for (var i = 0, n = distField.length; i < n; i++) {
      var t = distField[i], k = i * 4, reveal;
      if (t <= p) reveal = 1;
      else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
      else reveal = 0;
      o[k] = dataB[k] + (dataA[k] - dataB[k]) * reveal;
      o[k + 1] = dataB[k + 1] + (dataA[k + 1] - dataB[k + 1]) * reveal;
      o[k + 2] = dataB[k + 2] + (dataA[k + 2] - dataB[k + 2]) * reveal;
      o[k + 3] = 255;
    }
    ctx.putImageData(outImg, 0, 0);
  }

08Halftone-dot Reveal Whose Radius Comes From Brightness

The picture is sampled into a grid of dots, and each dot's own average brightness caps how large that dot is allowed to grow. As progress advances, every dot's radius scales up toward its own cap together, so the whole frame fills in like a sheet of print halftone rather than one dot growing at a time.

Halftone-dot Reveal Whose Radius Comes From Brightness
Open in a new tab

08 · Halftone-dot Reveal Whose Radius Comes From Brightness — Measured area 34.422% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

dotsXdotRdotColor
  function RENDER(p) {
    ctx.drawImage(bufB, 0, 0, W, H);
    for (var i = 0; i < dotsX.length; i++) {
      var r = dotR[i] * Math.min(1, p * 1.15);
      if (r < 0.6) continue;
      ctx.save();
      ctx.beginPath(); ctx.arc(dotsX[i], dotsY[i], r, 0, Math.PI * 2); ctx.clip();
      ctx.fillStyle = dotColor[i];
      ctx.fillRect(dotsX[i] - DOT_PX, dotsY[i] - DOT_PX, DOT_PX * 2, DOT_PX * 2);
      ctx.restore();
    }
  }

09Liquid Wave Reveal Whose Boundary Comes From a Sine Phase Offset

A single boundary line sweeps up the frame, and its height at each column is offset by a sine curve so the line ripples rather than staying flat. Everything above the boundary at a given column stays clipped to the old picture while everything below shows the new one.

Liquid Wave Reveal Whose Boundary Comes From a Sine Phase Offset
Open in a new tab

09 · Liquid Wave Reveal Whose Boundary Comes From a Sine Phase Offset — Measured area 47.283% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

baseYbeginPathdrawImage
  function RENDER(p) {
    ctx.drawImage(bufB, 0, 0, W, H);
    var baseY = H * (1 - p);
    var amp = WAVE_AMPLITUDE_PX * Math.sin(p * Math.PI);
    for (var x = 0; x < W; x += COL_STEP) {
      var y = baseY + Math.sin(x * WAVE_FREQ + p * WAVE_PHASE_SPEED) * amp;
      if (y >= H) continue;
      y = Math.max(0, y);
      ctx.save();
      ctx.beginPath(); ctx.rect(x, y, COL_STEP, H - y + 1); ctx.clip();
      ctx.drawImage(bufA, 0, 0, W, H);
      ctx.restore();
    }
  }

Where values change the screen — diagonal coordinates, noise thresholds, fixed seeds

01 — EDGE_SOFT

This constant sets how wide a band of pixels sits between "still the old picture" and "fully the new one" along the diagonal boundary — not the boundary's angle itself, just how soft or hard that one edge reads.

03 — NOISE_CELL_PX

This is the pixel size of one dissolve cell — not the noise value itself, just how coarse or fine the chunks are that swap in together when a cell's threshold is crossed.

09 — WAVE_AMPLITUDE_PX

This is the maximum height the sine curve can push the wave boundary away from its base line — not the wave's speed, just how far the ripple bulges up and down as it climbs.

Variant Changed value What it feels like
EDGE_SOFT 0.05 → 0.015 Diagonal edge softness drops from 0.05 to 0.015 The blend band between old and new picture narrows sharply, so the diagonal boundary reads as a near-hard cut instead of a soft gradient
NOISE_CELL_PX 16px → 8px Dissolve cell size shrinks from 16px to 8px Each swap-in chunk covers a quarter of the area, so the dissolve scatters into finer, busier specks instead of a handful of visible blocks
WAVE_AMPLITUDE_PX 10px → 26px Wave amplitude rises from 10px to 26px The boundary's ripple grows more than twice as tall, so the water-like curve looks rougher and more pronounced as it sweeps upward

Where it breaks — accessibility, reduced-motion, low-end devices

All nine patterns share one baseline: the clickable panel carries role="button", tabindex="0", and an aria-label naming what replaying does, the canvas itself is marked aria-hidden="true" since the label already describes it, and every panel answers both a click and a keydown for Enter or Space — window.matchMedia('(prefers-reduced-motion: reduce)') is read once at load rather than left to a CSS media query alone.

When reduced motion is on, all nine skip their auto-looping driveTick call entirely, add an is-still class, jump straight to RENDER(0.5) for an immediately visible half-and-half frame, and swap the click/Enter handler so a single interaction toggles progress between 0 and 1 at once instead of animating through it — no pattern keeps its usual easing running in the background under this setting.

Lining up all nine calculation functions side by side, rather than reading them one at a time in the sections above, is easiest straight from the zip archive itself — it opens with the password zd738fjg.

The three patterns that touch every pixel through getImageData/putImageData (01, 02, 07) recompute a full canvas-sized array on every RENDER call, scaled by the page's device pixel ratio — that is the heaviest of the nine on a low-end phone at a high pixel ratio. The other six only touch a grid cell, a shard, a dot, or a row through drawImage/fillRect/arc, which is far cheaper per frame since the browser's own compositor does the actual pixel copy.

None of that per-pixel cost shows up until a pattern is dropped into a real page at a real device pixel ratio, which is exactly the condition the nine files inside the zip below are meant to be checked against.

Three variants and accessibility

The table above already covers the three confirmed variants — EDGE_SOFT, NOISE_CELL_PX, and WAVE_AMPLITUDE_PX — and each one edits exactly that single constant while every listener, aria-* attribute, and reduced-motion branch stays identical to its base pattern. A variant's index.html is the base file with only that one number changed, so none of the accessibility behavior documented above needs re-checking per variant.

Where can I find image reveal effect css examples?

Most css examples in this space turn up as either a plain CSS opacity cross-fade or a single wipe direction hard-coded into a clip-path animation. The nine patterns above sit in a different spot: a coordinate field, a per-cell threshold, or a per-shard order computed once in plain JavaScript, then read back every frame to decide what a canvas draws — no charting or animation library, no CDN script, just the zip above and a browser. A gallery, a hero banner's picture swap, or a loading screen's before/after image can all reuse the same click/keydown baseline and swap in whichever one of the nine calculation functions fits the mood.

FAQ

Why does pattern 03's noise dissolve have the highest average intensity of the nine?

Each cell swaps in as a whole 16px block through drawImage rather than fading pixel by pixel, so the frames where several cells cross their threshold at once change a large solid area in a single step — that produces a bigger frame-to-frame pixel difference than the gradual per-pixel blends in 01, 02, and 07.

Does pattern 07's ink-bleed edge use one distance point or several?

Several — PRECOMPUTE places a fixed number of ink-drop points and, for every pixel, keeps only the distance to whichever drop is nearest, then blends in a value-noise offset so the resulting edge looks ragged rather than like a set of perfect circles.

Is this image reveal effect codepen-style set safe to drop into a real gallery?

The click/keydown listeners, the aria-label/role="button" pairing, and each calculation function transfer directly — swap the demo's two fixed scenes for whichever two images your gallery actually needs, and the timing math needs no changes. What every pattern here skips on purpose is preloading the second image before the reveal starts, so add that yourself before shipping it.

Sources: MDN — getImageData · MDN — putImageData · MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — CanvasRenderingContext2D.clip

Related reading: HTML5 Canvas Animation: 9 Warp & Droplet Effects · 9 CSS Screen Transition Animation Effects · 9 Perlin Noise Animations, No Libraries, Pure Math — the first shares this post's pixel-computation spine on a full scene rather than a reveal, the second covers CSS-only screen transitions without canvas pixel work, and the third goes deeper into the same value-noise math that pattern 03 uses for its per-cell threshold.

Enter the archive password

The password is inside this article. You will find it as you read.