---
name: image-reveal-mask
description: Use when you want "Premium, editorial, polished" - Reveals images through sliding, expanding, or clipped masks.
---

# Image Reveal Mask

> **Category:** Image / Media  -  **Personality:** Premium, editorial, polished
>
> **Best use:** Case studies, portfolios
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

An image-reveal mask uncovers a photo with motion instead of just fading it in: a solid **overlay panel slides off** the frame (and/or the image's own **`clip-path` wipes open**) so the picture is *unveiled* rather than loaded. It reads as deliberate and expensive, which is why studios use it on case studies and portfolio grids. The demo is an editorial portfolio ("Atelier Nord") where the hero wipes horizontally, one tile wipes vertically, and one opens as an expanding iris — all triggered on scroll-in, with a **Replay** control. Because it is pure CSS transforms + `clip-path`, it is cheap and GPU-friendly.

## Dependencies / CDN

None - pure CSS for the effect, a few lines of vanilla JS to trigger it on scroll. The demo only loads display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Libre+Caslon+Text:ital,wght@0,400;0,700;1,400&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
```

## HTML

One reveal unit = a clipped `frame` holding the image and a `curtain` panel. A direction class (`h` / `v` / `iris`) on the unit picks the technique. JS toggles `is-revealed`; the unit is *armed* by adding `is-armed` to the stage.

```html
<section class="stage">
  <!-- horizontal slide -->
  <figure class="reveal h" style="--d:0ms">
    <div class="frame">
      <img src="/img/architecture-01.jpg" alt="Kvarter House">
      <span class="curtain" aria-hidden="true"></span>
    </div>
    <figcaption>Kvarter House &mdash; Architecture</figcaption>
  </figure>

  <!-- vertical slide -->     <figure class="reveal v"    style="--d:180ms"> ... </figure>
  <!-- expanding iris -->      <figure class="reveal iris" style="--d:270ms"> ...
      <span class="ring" aria-hidden="true"></span>  <!-- optional flourish -->
  </figure>
</section>
```

## CSS

```css
/* the masking happens here. The image sits under an opaque curtain panel;
   "revealed" = curtain parked off-frame + image clip fully open. */
.frame{position:relative;overflow:hidden;border-radius:13px;background:#22201c}
.frame img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  transition:transform 1.2s cubic-bezier(.76,0,.24,1) var(--d,0ms),
             clip-path 1s   cubic-bezier(.76,0,.24,1) var(--d,0ms)}
.curtain{position:absolute;inset:0;z-index:3;background:linear-gradient(120deg,#211d18,#33291f);
  transition:transform .98s cubic-bezier(.76,0,.24,1) var(--d,0ms),opacity .55s ease var(--d,0ms)}
.curtain::after{content:"";position:absolute;top:0;bottom:0;left:0;width:4px;   /* glowing trailing seam */
  background:#e3633b;box-shadow:0 0 26px 3px rgba(227,99,59,.65)}

/* HORIZONTAL: rest = parked right; armed+unrevealed = covering + image clipped from the right */
.h .curtain{transform:translateX(101%)}
.stage.is-armed .h:not(.is-revealed) .curtain{transform:translateX(0)}
.stage.is-armed .h:not(.is-revealed) img{transform:scale(1.16);clip-path:inset(0 100% 0 0)}

/* VERTICAL: curtain exits upward, image wipes from the top down */
.v .curtain{transform:translateY(-101%)}
.v .curtain::after{inset:auto 0 0 0;width:auto;height:4px}      /* seam on the bottom edge */
.stage.is-armed .v:not(.is-revealed) .curtain{transform:translateY(0)}
.stage.is-armed .v:not(.is-revealed) img{transform:scale(1.16);clip-path:inset(100% 0 0 0)}

/* IRIS: curtain just fades, the image opens as an expanding circle */
.iris .curtain{opacity:0;transition:opacity .7s ease var(--d,0ms)}
.iris img{clip-path:circle(145% at 50% 50%)}
.stage.is-armed .iris:not(.is-revealed) .curtain{opacity:1}
.stage.is-armed .iris:not(.is-revealed) img{transform:scale(1.18);clip-path:circle(0% at 50% 50%)}
```

`--d` (set inline per unit) staggers the cascade. Captions get the same gating with an extra delay so text settles *after* its image.

## JavaScript

Progressive enhancement: only arm (hide) the images once JS runs **and** motion is allowed; otherwise everything stays shown. Reveal each unit when it scrolls into view.

```js
var stage = document.querySelector('.stage');
var units = [].slice.call(stage.querySelectorAll('.reveal'));
var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;

if (reduce) {                                   // reduced motion -> shown, never armed
  units.forEach(function(u){ u.classList.add('is-revealed'); });
} else {
  stage.classList.add('is-armed');              // curtains now cover the images
  var io = new IntersectionObserver(function(entries){
    entries.forEach(function(e){
      if (e.isIntersecting){ e.target.classList.add('is-revealed'); io.unobserve(e.target); }
    });
  }, { threshold: 0.32, rootMargin: '0px 0px -6% 0px' });
  units.forEach(function(u){ io.observe(u); });
}

// Replay: kill transitions, snap back to covered, reflow, then reveal again (CSS --d staggers).
replay.addEventListener('click', function(){
  stage.classList.add('instant');
  units.forEach(function(u){ u.classList.remove('is-revealed'); });
  void stage.offsetWidth;                        // force reflow
  stage.classList.remove('instant');
  requestAnimationFrame(function(){ units.forEach(function(u){ u.classList.add('is-revealed'); }); });
});
```

## How it works

- **Two layers, one frame.** The `frame` has `overflow:hidden`; inside it an `img` and an opaque `curtain` panel stacked on top (`z-index:3`).
- **Revealed is the default state.** The "rest" CSS parks the curtain off-frame (`translateX(101%)`) and leaves the image fully shown. JS *adds* `is-armed` to flip the not-yet-revealed units into the covered state — so with no JS (or reduced motion) the images are simply visible.
- **The reveal = removing a class.** Adding `is-revealed` drops the `:not(.is-revealed)` covering rule, so the curtain transitions back to its parked transform and the image's `clip-path` opens from `inset(0 100% 0 0)` to `inset(0 0 0 0)`, synced to the same easing.
- **The seam** is the curtain's `::after` — a thin glowing bar on its trailing edge that sweeps across as the panel exits, the detail that sells it as a "mask" rather than a slide.
- **A slight `scale(1.16)` -> `scale(1)`** settle on the image adds depth so it feels like it was always behind the panel.
- **Direction is data.** `h`, `v`, `iris` only change which transform/`clip-path` pair is used; the trigger logic is identical.

## Customization

- **Direction/style:** swap the `h`/`v`/`iris` rule. A diagonal wipe = animate `clip-path:polygon(...)`; a center split = two curtains translating opposite ways.
- **Pace & feel:** the easing `cubic-bezier(.76,0,.24,1)` gives a confident in-out; raise durations for a slower, more cinematic unveil. Tune the per-unit `--d` for the cascade rhythm.
- **Curtain look:** make it your brand block (solid, gradient, or a second photo). Recolor or widen the `::after` seam, or delete it for a plain slide.
- **Trigger:** scroll-in here, but the exact same class toggle works on `load`, hover, or click.

## Accessibility & performance

- **Reduced motion = shown.** Honour `prefers-reduced-motion`: the demo never arms, so images are simply present with no animation (the catalog's global reduced-motion rule also zeroes transition durations as a backstop).
- **Content is never hidden from AT.** The real `<img>` (with `alt`) and captions are in the DOM the whole time; only the decorative `curtain`/`ring` carry `aria-hidden`. No-JS users see every image.
- **Cheap.** Only `transform`, `opacity` and `clip-path` animate — all compositor-friendly. Add `will-change:transform` on the curtain if you reveal many at once; avoid animating `width`/`top`/`filter` instead.
- Use `IntersectionObserver` (not scroll listeners) and `unobserve` after the first reveal so it runs once.

## Gotchas

- **`overflow:hidden` on the frame is mandatory** — it is what clips the sliding panel and the seam glow; without it the curtain spills outside.
- **Park the curtain just past the edge (`101%`, not `100%`)** so a sub-pixel rounding sliver never lingers on screen.
- **`clip-path` inset order is `top right bottom left`.** `inset(0 100% 0 0)` hides from the right (reveals left to right); `inset(100% 0 0 0)` hides from the top. Mismatching the panel and clip directions makes the wipe fight itself.
- **Don't default to hidden.** If the covered state is the base style and JS fails, the image is gone. Keep *revealed* as the default and let JS opt into covering (`is-armed`).
- **Animating `clip-path` needs the same units on both keyframes** (e.g. `circle(% at % %)`), or some browsers won't interpolate.
- **Replay needs a forced reflow** (`void stage.offsetWidth`) between removing and re-adding `is-revealed`, or the browser batches it and nothing replays.
