Where'd the color go?

10 min read

I first noticed this while exporting UI designs for a creative tool. The interface leaned on colorful labels and status indicators, and the greens in particular, the kind of saturated green that only exists on a wide-gamut screen, came out of the published pages noticeably flatter than the same exports looked in Figma on the same MacBook. Nothing in the build had errored or warned about it, and it took me a while to accept that the image pipeline had quietly thrown the color away on its own.

I’ve since hit the same thing twice more, on two different frameworks, and the second time was on the site you’re reading. This post is the story of chasing it down: what’s actually happening, the two-line fix at the center of it, and how much scaffolding each framework made me build to reach those two lines.

Where the color goes

Every React publishing stack I’ve used converges on the same machinery for images. Next.js Image, Astro’s asset pipeline, Gatsby, Eleventy Image: under the hood they all hand your files to Sharp for resizing, format conversion and responsive srcsets, and those optimizations are real ones you want.

They also all inherit Sharp’s default behavior, which is to strip the ICC color profile and convert everything to sRGB. That’s a sensible default for compatibility and a bad one for anything photographed or designed in Display P3, where it gives you fast images that look washed out without any framework telling you it happened.

The same saturated render before and after sRGB conversionDisplay-P3sRGB

Drag the slider to compare. The left half keeps its Display P3 profile, and the right half is the same image after the pipeline’s sRGB conversion, with every color that doesn’t fit clipped to the nearest one that does. On a P3 screen the difference is obvious; on an sRGB screen the two halves look identical, which is exactly why this bug survives code review.

What annoyed me most, once I found it, was how small the fix is, since Sharp is perfectly capable of keeping the profile and the frameworks simply never ask it to:

// Default framework processing
sharp(inputBuffer)
  .resize(width, height)
  .webp({ quality: 75 }) // No ICC profile preservation
  .toBuffer();

// With P3 preservation
sharp(inputBuffer)
  .resize(width, height)
  .keepIccProfile()
  .webp({
    quality: 75,
    smartSubsample: false, // Maintains color accuracy
  })
  .toBuffer();

What P3 actually adds

sRGB has been the web’s color space since the 1990s, and it covers a surprisingly small slice of what your eyes can see. Display P3 is a superset of it: every sRGB color exists in P3, but P3 reaches roughly 25% further1, mostly into the deep reds and the vivid greens that make interfaces feel alive. The mechanism is just wider primaries. When a color lives in P3 but not in sRGB, conversion snaps it to the closest color in sRGB.

This doesn’t say much about where the extra colors actually are, so I built the explorer below for this post. Drag the sliders to move through OKLCH: the solid shape is sRGB and the translucent shell around it is P3. Rotate it and you can see the shell bulge at some hues, the greens and reds especially, and barely exist at others.

oklch(70.0% 0.2660 150.0)
sRGBOut of gamut
Display P3In gamut

The reason any of this matters is that the screens moved. MacBooks, iMacs, decent monitors and basically every phone can show P3 now. Safari has rendered P3 in CSS since 2016 and Chrome caught up in 20232. A pipeline that silently converts to sRGB is optimizing for displays its readers no longer own.

Next.js: going around the optimizer

The first time I fixed this was on a Next.js site. Next.js Image processes images at request time behind an optimizer you can’t configure beyond a handful of knobs, and none of the knobs are about color. The only escape hatch is unoptimized, which skips Sharp entirely:

<Image unoptimized src="/hdr-photo.webp" width={800} height={600} alt="Full color, no optimization" />

That keeps the color and throws away everything else: no resizing, no format conversion, no srcset. It’s fine for one hero image and doesn’t scale to a site.

So instead of fighting the architecture I went around it. I built a P3Image component that wraps Next.js Image with a custom loader: local images route through my own API endpoint, external URLs pass through untouched, and all of Next.js Image’s client-side behavior stays intact.

const p3Loader = ({ src, width, quality }) => {
  if (src.startsWith("http://") || src.startsWith("https://")) {
    return `${src}?w=${width}&q=${quality}`;
  }

  const params = new URLSearchParams({
    src: src,
    w: width.toString(),
    q: quality.toString(),
    format: format,
  });

  return `/api/image?${params.toString()}`;
};

export default function P3Image({ src, width, height, ...props }) {
  return <NextImage loader={p3Loader} src={src} {...props} />;
}

The API route is where the two lines live. It checks for an existing profile and keeps it with keepIccProfile(), or assigns P3 with withIccProfile('p3') when the source has no color information at all. (Sharp’s ICC profile API has the details.)

const metadata = await sharpInstance.metadata();

if (metadata.icc) {
  sharpInstance = sharpInstance.keepIccProfile();
} else {
  sharpInstance = sharpInstance.withIccProfile("p3");
}

const optimizedBuffer = await sharpInstance
  .webp({
    quality: quality,
    effort: 4,
    smartSubsample: false,
  })
  .toBuffer();

smartSubsample: false turns off the chroma subsampling that smears saturated edges; for JPEG output the equivalent is chromaSubsampling: '4:4:4' instead of the default '4:2:0'. Both add 10 to 20% of processing time per image, which is fine because the results get cached.

And that “cached” is doing a lot of work in the sentence. Around the two lines I cared about, the route also needed its own cache keyed on source and parameters, mtime-based invalidation, and path traversal checks. The built-in optimizer already had all of that. I was rebuilding it to reach a config option.

At least the optimization survived. Here’s what a 2250×1500 lossless P3 WebP costs at each viewport, through Sharp at quality 75:

Viewport unoptimized Default <Image> ICC-preserving route
640px 3,340 KB 12 KB 12 KB
828px 3,340 KB 17 KB 17 KB
1080px 3,340 KB 25 KB 24 KB
1200px 3,340 KB 28 KB 28 KB
1920px 3,340 KB 54 KB 51 KB
2250px 3,340 KB 69 KB 66 KB

Full P3 fidelity costs nothing in bytes; what it costs is architecture. The custom route runs on a server or a serverless function instead of Vercel’s edge, so first loads pick up 50 to 250ms depending on where the reader is, and automatic AVIF negotiation is gone unless you rebuild that too. A component, a loader, an API route, a cache and a set of security checks, all to reach two lines the optimizer wouldn’t expose.

Replacing the service

This site uses Astro, and images get processed once at build time rather than per request. So there’s no route to stand up and no runtime cost to weigh. But Astro’s stock image service has exactly the same flaw: the same Sharp default, left in place. A Display P3 photo comes out of the build reinterpreted as sRGB, duller on the screens most readers hold.

The difference is that Astro left a seam. You can replace the image service wholesale with one config line:

// astro.config.mjs
image: {
  service: {
    entrypoint: "./src/image-service.mjs",
  },
},

My replacement is small. Anything without a profile goes to the stock pipeline, byte-for-byte the same as before. Anything with a profile gets the stock resize and encode, plus the line it was missing:

// src/image-service.mjs (condensed)
import baseService from "astro/assets/services/sharp";

export default {
  ...baseService,

  async transform(inputBuffer, transform, config) {
    const { icc } = await sharp(inputBuffer).metadata();

    // No profile: the stock pipeline is already correct.
    if (!icc) return baseService.transform(inputBuffer, transform, config);

    // Mirror the stock resize and encode, plus the line it was missing.
    const pipeline = sharp(inputBuffer)
      .rotate()
      .resize({ width: transform.width, withoutEnlargement: true })
      .keepIccProfile();

    const { data, info } = await pipeline
      .webp(encoderOptions(transform, config))
      .toBuffer({ resolveWithObject: true });
    return { data, format: info.format };
  },
};

The pixels are never converted. The profile just rides along, and the browser renders the gamut the photo was taken in.3 Because the service sits inside the framework’s own pipeline, everything downstream keeps working: the srcset, the sizes math, the build cache, lazy loading, CLS-safe dimensions. There’s no wrapper component to remember. A plain markdown image in an article gets the treatment automatically, and so does every <Image>, like these three:

Man in sunglasses holding a drink in front of a bright red food truckCrowd celebrating under trees, one fan holding a lit red smoke flare overheadYellow Porsche 911 with a ducktail spoiler parked at the kerb on an overcast street
Three phone photographs in Display P3, through the build-time pipeline. The red of the truck and the flare and the yellow of the 911 would all be casualties of an sRGB conversion.

That’s the whole change: one file and one line of config, compared with the component, loader, API route, cache and security checks the Next.js version needed, and it doesn’t give up edge distribution to get there. Both fixes come down to the same two lines of Sharp, and the only difference is how far each framework made me go to reach them.

Reflections

None of this should have been necessary. The fix is two lines in Sharp, and there’s no reason it can’t live in the frameworks. For Next.js I’d want a preserveColorProfile option, globally and per image, that calls keepIccProfile(), disables smartSubsample for WebP and uses 4:4:4 chroma for JPEG. Default it to false and nobody’s build changes.

// next.config.ts
const nextConfig = {
  images: {
    preserveColorProfile: true,
  },
};

The broader ask, for any framework that optimizes images on my behalf, is one of two things: a knob, or a seam. Astro shipped the seam, so correcting the default was a project-local file that survives upgrades like any other project file. Next.js shipped neither, so correcting it meant rebuilding half the optimizer outside the framework. Either answer is fine. Silence is the only wrong one, because this failure is invisible in code review, invisible in CI, and visible only to someone squinting at an exported design wondering where the color went.

Notes

  1. Dean Jackson, “Improving Color on the Web,” WebKit Blog, June 2016

  2. Chrome 111 Beta: CSS Color Level 4,” Chrome for Developers, February 2023

  3. With one hard-won exception: 16-bit sources need a two-pass route, because every Sharp profile-attachment API corrupts 16-bit pixels. The 16-to-8-bit reduction runs through a color transform that gamut-clips, then tags the clipped pixels with the original profile. I verified it with WebKit’s P3 gamut-test image, whose two out-of-sRGB reds merged into one, hiding the logo on every display while the profile rode along intact. The fix: resize at 16-bit precision and cast to 8-bit with no profile handling, then attach the source profile to the already-8-bit intermediate, which is transform-free.