CreaTech
LAB-003CSS onlyAccessible

Duotone image reveal on hover with CSS mask and blend modes

An image that starts as a two-tone graphic, with colour bleeding back in from the centre on hover, and no JavaScript at all.

Built 4 Aug 2026 · Last verified 4 Aug 2026

The short version

A duotone reveal stacks two copies of the same image: a colour original underneath and a two-tone version on top, built with a grayscale filter and a blended gradient rather than an image editor. Hovering grows a soft-edged mask circle on the top layer, so colour bleeds outward from the centre and swallows the duotone. It is markup and CSS only, with no JavaScript in the technique at all.

LAB-003live

How it works

  1. Two stacked layers hold the same image. The lower one is untouched; the upper one is the duotone.
  2. The duotone is made in the browser: filter: grayscale(1) contrast(1.15) flattens the photo, then a pseudo-element filled with a two-stop gradient sits on top with mix-blend-mode: color, tinting shadows and highlights into the two chosen colours. Change the gradient and you change the duotone, with no new asset to ship.
  3. The upper layer carries a mask-image: a radial-gradient that is transparent out to a radius of --duo-r and opaque fourteen percent later. Where the mask is transparent the duotone disappears, so the colour original underneath shows through.
  4. --duo-r is registered with @property as a <percentage>. That registration is the whole trick: without it the browser treats the value as an opaque string and the reveal snaps, and with it the value interpolates so hovering grows the hole smoothly from nothing to the full frame.
  5. The fourteen percent gap between the two gradient stops is the soft edge. It is the single value here worth tuning by eye: too tight and it reads as a cut-out, too wide and the boundary disappears entirely.
  6. The effect is compositing only. No layout, no re-paint of the image itself, so it stays cheap even on a large photograph.

Duotone without an image editor

The usual way to ship a duotone image is to make one in Photoshop and serve it as a second asset. That means two files to download, two files to keep in sync, and a fixed pair of colours baked into a binary that nobody will ever open again.

The browser can do it instead. filter: grayscale(1) flattens the photograph to luminance, and a gradient layered on top with mix-blend-mode: color supplies hue and saturation while leaving that luminance alone. That is the actual definition of a duotone, and it is two declarations.

The payoff is that the duotone becomes a theme value rather than an asset. Every experiment on this site carries a pastel accent in a CSS variable; a duotone built this way can read those variables directly, so the tint follows the entry it belongs to with no new file to generate.

The contrast bump matters more than it looks. grayscale(1) alone tends to produce a muddy midtone-heavy image, and a duotone with no tonal separation is just a coloured rectangle. contrast(1.15) pushes the shadows and highlights apart so the two colours have something to sit on.

The blend mode is the whole trick

mix-blend-mode: color takes hue and saturation from the top layer and luminosity from what is underneath. Get this wrong and the effect collapses.

multiply darkens everything and loses the highlights. overlay and soft-light keep some of the original colour, so a photograph that already has strong colour fights the tint. hue keeps the source's saturation, which means a desaturated photo stays desaturated no matter what you throw at it. Only color gives you the clean substitution.

The layer needs isolation: isolate, or the blend reaches past its own layer and tints the colour original sitting underneath it, which produces a confusing half-effect that looks like a z-index bug.

What @property actually buys you

The reveal is a mask: a radial gradient that is transparent out to a radius and opaque shortly after. Growing that radius from nothing to the full frame is what makes colour bleed outward from the centre.

Custom properties are strings as far as the browser is concerned. Transition one and you get a discrete jump at the halfway point, because there is no defined way to interpolate between two unknown strings. This is the single most common reason a CSS-only effect "does not animate" when it looks like it should.

Registering the property fixes it:

@property --duo-r {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

Now the browser knows the value is a percentage, so it can interpolate, and a plain transition: --duo-r 0.55s ease works. inherits: true matters too: the value is set on the figure and read on the layer inside it, so it has to descend.

Where @property is missing the reveal still happens, it simply snaps. That is a fair degradation, and it is why the whole thing does not need a @supports guard.

The soft edge

There is exactly one value in this experiment worth tuning by eye, and it is the gap between the mask gradient's two stops.

Too tight and the boundary is a hard circle, which reads as a cut-out or a stencil. Too wide and the transition spreads across the whole image and stops being legible as an edge at all. Fourteen percent of the radius is where it starts to read as light falling on the picture rather than as a shape drawn on top of it.

Why no cursor tracking

The obvious next step is to move the reveal to the pointer, and I built that version first. It is better on a large image and worse everywhere else.

Following the cursor means a pointer handler, which means the effect stops being CSS, which means it does nothing without JavaScript and nothing on a keyboard. Growing from the centre gives the same visual payoff, fires identically on :focus-visible, and works on a touch device, where there is no cursor to follow in the first place.

A pointer-driven variant is worth building when the image is large enough that the centre is a long way from the edges. On a card, it is not.

The source

MIT licensed. Use it in anything, no attribution needed.

Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.

Demo.tsxtsx
import styles from "./Demo.module.scss";

/**
 * No hooks, no handlers, no event listeners: the whole effect lives in
 * Demo.module.scss. This stays a component only so the Lab can mount every
 * experiment the same way.
 */
export default function DuotoneImageReveal() {
  return (
    <div className={styles.wrap}>
      <figure className={styles.figure} tabIndex={0}>
        <div className={styles.frame}>
          <img
            className={styles.base}
            src="/lab/duotone-source.jpg"
            alt="The Christmas Shop 24 storefront: a deep green hero with a gold script wordmark."
            width={1200}
            height={750}
            // No loading="lazy": the whole demo is already gated on
            // intersection by LabMount, and a lazy image inside a
            // content-visibility: auto subtree never gets asked for at all.
            decoding="async"
          />

          {/* The duotone layer. Same picture as the one underneath, so it
              carries no alt text and is hidden from assistive technology. */}
          <span className={styles.duotone} aria-hidden>
            <img
              src="/lab/duotone-source.jpg"
              alt=""
              width={1200}
              height={750}
              decoding="async"
            />
          </span>
        </div>

        <figcaption className={styles.caption}>
          Hover or focus to let the colour back in
        </figcaption>
      </figure>
    </div>
  );
}
Demo.module.scssscss
/* Registering the custom property is what makes it interpolate. Without this
   the browser treats --duo-r as an opaque string and the reveal snaps instead
   of growing. Distinctive name: registration is global, not module-scoped. */
@property --duo-r {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

.wrap {
  display: grid;
  place-items: center;
  padding: clamp(20px, 4vw, 36px);
  max-width: 100%;
  max-height: 100%;
}

.figure {
  --duo-r: 0%;
  display: flex;
  flex-direction: column;
  width: min(100%, 520px);
  min-height: 0;
  max-height: 100%;
  transition: --duo-r 0.55s ease;
  border-radius: var(--r-md);

  &:hover,
  &:focus-visible {
    --duo-r: 100%;
  }
}

/* min-height: 0 lets the frame shrink inside a height-capped card stage;
   without it a flex item refuses to go below its content's size. */
.frame {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow: hidden;
  border-radius: var(--r-md);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-1);
  line-height: 0;
}

.base {
  width: 100%;
  height: 100%;
  object-fit: cover;
  aspect-ratio: 1200 / 750;
}

/* The duotone. `isolation: isolate` keeps the blend inside this layer, so the
   gradient tints the grayscale copy and never touches the colour original
   sitting underneath it. */
.duotone {
  position: absolute;
  inset: 0;
  isolation: isolate;
  display: block;

  /* A hole that opens from the centre. Transparent out to --duo-r, opaque
     14% later: that gap is the soft edge, and it is the one value here worth
     tuning by eye. */
  mask-image: radial-gradient(
    circle at 50% 50%,
    transparent var(--duo-r),
    #000 calc(var(--duo-r) + 14%)
  );
  -webkit-mask-image: radial-gradient(
    circle at 50% 50%,
    transparent var(--duo-r),
    #000 calc(var(--duo-r) + 14%)
  );

  img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    filter: grayscale(1) contrast(1.15);
  }
}

/* The tint. `color` blend keeps the grayscale luminance and takes only the
   hue and saturation from the gradient, which is exactly what a duotone is. */
.duotone::after {
  content: "";
  position: absolute;
  inset: 0;
  mix-blend-mode: color;
  background: linear-gradient(140deg, var(--blush), var(--lavender));
}

.caption {
  margin-top: 12px;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.04em;
  color: var(--silver-500);
  text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  /* Cut rather than grow. Nothing is lost: both layers show the same photo. */
  .figure {
    transition: none;
  }
}

Questions worth answering

Does it work in Safari?

Chrome, Edge, Safari and Firefox all support mask-image, though Safari still wants the -webkit- prefixed property, which the source below includes. mix-blend-mode: color is universal. @property, which drives the growth, needs Chrome 85+, Safari 16.4+ or Firefox 128+; older browsers still get the reveal, it just snaps rather than grows.

Does it need JavaScript?

None. The technique is a stylesheet and two img tags. This page mounts the demo lazily for its own performance budget, but the code below runs on its own with no script of any kind.

Is it accessible?

Reduced motion. The mask growth shortens to an instant swap, so the image never animates. Nothing is hidden either way: both layers show the same photograph.

Keyboard. The reveal fires on :focus-visible as well as :hover, so tabbing to the figure triggers exactly the same effect. The image carries real alt text on the layer underneath, and the duotone layer is aria-hidden so it is never announced twice.

Want this kind of care on a build?

I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.