Screenshot Colors Are Not sRGB, and Your UI Will Show It
A hex picked from a macOS screenshot is in the display profile, not sRGB. Paste it into code and the color shifts. Here is the round-trip proof.
If you match a color by sampling a screenshot with an eyedropper and pasting the hex into your stylesheet, the result can be visibly off. The number is not wrong. It is measured in the wrong space. On a wide-gamut display, a screenshot is encoded in the display profile, while your CSS, your theme manifest, and your design tokens are interpreted as sRGB. Same digits, different color.
I hit this while matching one UI element to another that already looked right. The value I
sampled was #6DCEBD. The value that actually produces that pixel is #28D0BC. Those are
not close.
What the eyedropper actually gives you
A screenshot on a wide-gamut display is not a dump of sRGB values. The compositor renders into the display's color space, and the screenshot file carries an ICC profile describing it. When you open that file and read a pixel, you get a number expressed in that profile.
Your code is read differently. CSS hex, theme manifests, and most design token formats are sRGB by definition. Nothing in the pipeline converts on your behalf. So the round trip has two conversions in it, and only one of them is visible to you.
The round trip that proves it
Do not take the theory on faith. You can verify the whole pipeline in a few lines, because you already know some ground truth: any color your own code declared is, by definition, an sRGB value. Find it in the screenshot and convert back.
from PIL import Image, ImageCms
import io
im = Image.open("screenshot.png")
profile = ImageCms.ImageCmsProfile(io.BytesIO(im.info["icc_profile"]))
to_srgb = ImageCms.buildTransformFromOpenProfiles(
profile, ImageCms.createProfile("sRGB"), "RGB", "RGB", renderingIntent=0
)
converted = ImageCms.applyTransform(im.convert("RGB"), to_srgb)
Three declared colors, sampled raw and then converted:
| Declared (sRGB) | Raw pixel in file | After conversion |
|---|---|---|
10, 8, 23 |
10, 8, 22 |
10, 8, 23 |
25, 24, 36 |
25, 24, 35 |
25, 24, 36 |
163, 18, 98 |
147, 33, 96 |
164, 18, 99 |
Every declared value comes back within one unit. The pipeline is confirmed. Now run it forward on the color you want to match:
sRGB 40, 208, 188 becomes on screen 109, 206, 189
sRGB 109, 206, 189 becomes on screen 138, 204, 190
The first line is the answer. The second line is what happens if you paste the sampled hex straight into your code: you get a third color, close enough to look intentional and wrong enough to look muddy next to the element you were matching.
Why dark colors hide the problem
Look at the table again. The first two rows shift by a single unit. The third row moves by 16 in red and 15 in green. That asymmetry is why this bug survives casual checking.
Conversion between color spaces is close to identity near the neutral axis and near black. Dark backgrounds, borders, and shadows all round trip almost perfectly. If you spot check your screenshot against a background color and see a match, you conclude the screenshot is in sRGB and move on.
The divergence lives where saturation lives. Accent colors, brand hues, status indicators, and syntax highlighting are exactly the values that shift most, and exactly the values you are most likely to be matching by eye. The check you ran passed because you ran it on the part of the gamut where the bug does not exist.
There is a second trap in the same shape. If a tool strips or ignores the ICC profile when saving, the numbers stay as they were but the meaning is lost. A screenshot pasted through a chat app, resized, or re-encoded may arrive with no profile at all. At that point nothing in the file tells you which space you are in, and the only honest answer is that you cannot know from the file alone.
Two ways to fix it
Convert explicitly. If you need an absolute value, run the conversion shown above and use the converted number. This is the right choice when the color has to be written into a stylesheet, a manifest, or a token file that other people will read. Keep the original screenshot with its profile intact so the derivation can be repeated later.
Or compare inside one image. If your actual goal is "make element A the same color as element B," you do not need absolute values at all. Take one screenshot containing both, extract both pixels with the same tool and the same code path, and compare them to each other. Whatever space the file is in, both samples are in it. The comparison is valid even if you never learn the sRGB value.
The second approach is more robust and I now reach for it first. In the case that started this, the final check was exactly that: sample the reference element and the target element from one capture, and compare hue and saturation. Hue differed by 0.0 degrees, saturation by 0.003. No conversion was needed to confirm the match.
Two details make in-image comparison reliable:
- Sample glyph interiors, not edges. Antialiased text blends toward the background at the boundary. Take the highest-saturation pixels in the region rather than an average, or you will measure the blend instead of the color.
- Watch for disabled states. A greyed-out icon in a toolbar can still pass a saturation filter while sitting far from the active color. Pick your reference element deliberately instead of scanning a whole region and trusting the most common value.
Where this shows up
The pattern is not specific to one platform or one file format. It appears anywhere a rendered image is treated as a source of truth for a value that will later be interpreted in a fixed space.
- Matching a UI element to a color from a design mockup exported on a wide-gamut machine.
- Building a theme or skin file by sampling an existing one that you cannot read directly.
- Writing visual regression tests that compare captured pixels to hardcoded expected values.
- Extracting a palette from a photo or a video frame for use in CSS.
- Reporting a color bug with a screenshot, where the reader samples your image and gets a number neither of you rendered.
Visual regression testing deserves a specific warning. A suite that captures on a laptop and asserts against sRGB constants will pass or fail based on which machine ran it. If the CI runner has a different profile than the developer machine, the failures look like flakes and get retried away.
FAQ
Does this affect Windows and Linux too? The mechanism is general, but exposure depends on the display and the capture tool. The problem appears when the capture is encoded in a wide-gamut space rather than sRGB. A standard-gamut display capturing to sRGB round trips cleanly, which is why this was rare before wide-gamut panels became common on laptops.
Can I just strip the profile? No. Removing the profile does not convert anything. It only removes the information needed to convert, leaving numbers whose meaning is now undocumented. If a file has no profile, you cannot recover the original space from the pixels alone.
My design tool shows a different hex than my screenshot. Which one is right? Neither is right without a stated space. Ask what space each number is in. Design tools often display sRGB values while previewing them through a wide-gamut display, which is precisely why the on-screen pixel and the reported hex disagree.
Is this the same as gamma or bit depth? No. Gamma encoding and bit depth are separate concerns that can also distort naive pixel math. This specific failure is about the color space the numbers are expressed in, and it persists even with correct gamma handling and 8 bits per channel throughout.
How do I keep this from happening again?
Write the space next to the value. A comment saying sRGB beside a hex constant, or a
filename that records where a sample came from, costs nothing and removes the ambiguity that
makes the bug possible. Keep the original capture with its profile so any value derived from
it can be recomputed rather than re-guessed.