Two red squares:
leftrightIn hex, the only notation most of us have for colour, both are #ff0000. The left one because that’s what it is. The right one because hex has no other number for it.
If you’re reading this on a MacBook, an iPhone, or most monitors sold in the last few years, the right one is redder. A purer red, further out in the same direction, in a way that’s obvious once you’ve seen them side by side and impossible to name with the tools you use every day.
If you’re on an older monitor, they’re the same red. That’s also part of it.
The question the squares raise isn’t about screens. It’s about what #ff0000 means, because two things that both call themselves #ff0000 can’t both be the reddest red, and it turns out neither of them is.
Let’s start with what a colour value is, because most of us have been using them for years without ever being told.
#ff0000 is three numbers: 255 of red, 0 of green, 0 of blue. Three numbers can describe a colour, but only once you’ve said three more things: which red, which green, and which blue. “255 of red” means “as much of this particular red light as the screen has”, and different screens have different red lights. A number without a light attached is like a temperature without a scale. It’s a quantity of something unspecified.
The picture that makes this obvious was drawn in 1931, and almost nobody shows it to web developers. That year the International Commission on Illumination, the CIE, published a map of every colour a human can see, laid flat with brightness left out, built from experiments in which a handful of people (ten in one lab, seven in another) looked at a small split field and adjusted three coloured lights until one half matched a pure wavelength on the other. Plot the results and you get a horseshoe:
The curved edge is the spectrum, one wavelength at a time. The straight bottom edge is the purples, which no single wavelength produces; you only get them by mixing red and blue light. Everything inside the shape is a colour a person can see. Everything outside it is nothing at all.
Now pick three lights. Put a dot on the map for each and connect them, and you have a triangle, like the two drawn on the map. The triangle is every colour those three lights can mix. Everything outside the triangle is a colour that exists, that you can see, and that a screen built from those three lights cannot show you. No triangle of real lights covers the horseshoe, because the horseshoe bulges outward and its rim is made of real colours: put the three corners anywhere inside, and some of the rim is left outside.
So a screen’s range of colour, brightness aside, is a triangle, and a colour value is a point inside it, measured from its corners. #ff0000 is the red corner. Which means #ff0000 is a different colour on every screen with a different red light, unless somebody writes down which triangle they mean.
Somebody did. In 1996.
In November 1996 four engineers, two from Hewlett-Packard and two from Microsoft, published a proposal with a plain title, “A Standard Default Color Space for the Internet”, and a plain aim: a colour definition “based on the average performance of personal computer displays”. They called it sRGB, and it did three things. It fixed the triangle. It fixed the curve that turns a number like 128 into an amount of light. And it fixed the room you were assumed to be sitting in.
The triangle came from television. The document says so with what reads like relief: “We are fortunate to have obtained in April 1990 unanimous worldwide agreement on a calibrated nonlinear RGB space for HDTV production and program exchange”, which is the standard called Rec. 709, and its three corners were a standardised description of the phosphors in the cathode-ray tubes of the day. Computer monitors were the same kind of tube, and the authors said so: “Most computer monitors are similar in their key color characteristics - the phosphor chromaticities (primaries) and transfer function”. So the web’s red became television’s 1990 description of a cathode-ray tube’s red.
The curve came from measurement. HP had tested VGA monitors “from many brands” and found “the average CRT gamma to be indeed 2.2, with a standard deviation of about 0.2”, and so the standard’s curve was built to fit a 2.2 power law, “with an slight offset to allow for invertability in integer math”, which is also why it has a small straight segment near black. Not because eyes or phosphors have one. “Real monitors are not as exact as this equation suggests, and have no linear segment”, as Charles Poynton wrote in his FAQ on the subject.
And the room. The standard specifies a reference viewing environment: a screen at 80 candelas per square metre, an ambient light of 64 lux, a surround reflecting 20 percent. Sixty-four lux is dim. The authors knew: “While we believe that the typical office or home viewing environment actually has an ambient luminance level around 200 lux, we found it impractical to attempt to account for the resulting large levels of flare”. So the standard describes a slightly darker office than anyone sat in, on a kind of monitor that has since disappeared, and it was the right decision, because it gave everyone the same triangle to point at.
The web took it fast. HTML 4.0 adopted it in December 1997, CSS2 in May 1998, and the sentence CSS2 wrote, “All RGB colors are specified in the sRGB color space”, stayed through CSS Color 3; the current spec keeps the rule in new words: “sRGB is the default color space for CSS, used for all the legacy color functions.” Every hex colour since 1998 has meant a point in the 1996 triangle.
Put that sentence next to the horseshoe. It doesn’t say “the screen’s red”. It says sRGB’s red. Which is a promise, and it goes both ways: the browser promises that #ff0000 will be the 1996 red on any screen it can, and in exchange, the screen’s own red, whatever it is, is unreachable. In 1996 that cost nothing. Every screen was the triangle, more or less, so the screen’s red and sRGB’s red were the same light. The promise was free for as long as monitors stayed still.
They didn’t.
The next triangle came from cinemas. In July 2005 the studios’ Digital Cinema Initiatives published a specification for digital projection with a “minimum color gamut” in it, and the three corners of that gamut, wider than sRGB’s in red and green, became known as P3. It sat in projection booths for a decade.
Then Apple put it in a desk. In October 2015 the iMac with Retina 5K display shipped with, in Apple’s words, “a wider P3-based color gamut that provides a 25 percent larger color space”. The 9.7-inch iPad Pro followed in March 2016, and the iPhone 7 in September 2016 with “a wide color gamut for cinema-standard colors”. The version Apple shipped isn’t quite the cinema one: it keeps P3’s corners but uses the same white and the same curve as sRGB, so that only the corners move and everything else about an sRGB colour carries over. The CSS working group first called the keyword dci-p3 and then, after its July 2016 draft, renamed it display-p3 to “avoid claiming this is DCI P3, as these are not the same”.
So by the end of 2016 there were screens in a great many pockets and on a great many desks whose red corner was outside the 1996 triangle, and the browser, keeping its promise, showed them the 1996 red anyway.
The first browser to do something about it was Safari. In July 2016 Dean Jackson wrote on the WebKit blog about “HTML and CSS having been defined to work only in the sRGB color space”, and put a red square on the page. “There are actually two different shades of red in that image, one of which is only distinct on wide-gamut displays. On such a display you’ll see a faint WebKit logo inside the red square.” The square was an image in the P3 space, filled with P3’s full red, with the logo painted in a red slightly less full. On an sRGB display both reds fall outside the triangle and get pulled back to the same corner, so the logo vanishes. On a P3 display it’s there. That was these two squares, ten years earlier, done with a picture because CSS had no way to say it.
The way to say it was already being drafted: a function called color() that names the triangle and then gives the three numbers, which appeared in the CSS colour specification’s first public draft that same week. Safari 10.1 shipped color(display-p3 1 0 0) in March 2017. Chrome shipped it in March 2023, in version 111; Firefox accepted the syntax in May 2023, in 113, with a catch. Six years in which one browser could reach the screen’s red and the others could not, and in which, as WebKit put it in 2020, “WebKit is the only browser engine that supports Display-P3 color as of January 2020”.
The map again, this time with the triangles named:
The squares, live, with the browser’s own numbers:
#ff0000color(display-p3 1 0 0)The first readout is a media query, color-gamut, that reports, roughly, whether this screen reaches the P3 triangle (Chrome says yes once a screen covers 90 percent of it). The other two don’t depend on your screen at all: each colour is drawn into a canvas that works in P3’s numbers, then read back, which gives the three values the browser would hand a P3 panel for it, whatever panel you actually have. On a P3 screen the squares differ. On an sRGB screen they don’t, because the P3 red has nowhere to go but the corner, and the browser puts it there.
If your browser can do the measurement, hex red comes back as (234, 51, 35).
A P3 panel has a red light, a green light and a blue light, and its own scale of 0 to 255 for each. The browser has promised that #ff0000 means the 1996 red. The 1996 red is a point inside the P3 triangle, not at its corner, so to show it the browser has to turn on the panel’s red most of the way, and a little of its green and blue to pull the point inward. Do the arithmetic the spec specifies, from the 1996 triangle to the P3 one through the CIE’s coordinates, and sRGB’s (255, 0, 0) becomes P3’s (234, 51, 35): 234 of the 255 steps the panel’s red has, 51 of its green, 35 of its blue. I did the sum with the matrices in the CSS specification and got 0.917, 0.200, 0.139. I did it again with the constants in Chrome’s graphics library, Skia, and got the same to two decimals; the two use slightly different reference constants and part company in the third. And the pixels in a screenshot of the page, taken with Chrome forced to a P3 output, read (234, 51, 35). Three routes, one pixel.
Now go the other way. P3’s red corner, (255, 0, 0) in P3, expressed on sRGB’s scale, comes out at 109 percent of red, minus 23 percent of green and minus 15 percent of blue. A negative amount of a light. That’s what “out of gamut” means when you write it down: the colour is real, but describing it from sRGB’s corners needs more red than sRGB has and less green than none. Hex can’t write it. Nothing in the 1996 system can.
So what does a browser do when it’s handed a colour it can’t show? The specification has an opinion. CSS Color 4 spends a section on it, calls plain clipping “the simplest and least acceptable method”, and defines three algorithms that walk the colour inward until it fits while keeping its hue and lightness. No browser runs any of them. Chrome’s engineers said in their 2022 intent to ship that the gamut mapping section “will need changes or likely need to be dropped entirely as it is a performance issue”; WebKit deleted its never-shipped implementation in November 2025 with the note “For now, we always clip”; Firefox has one behind a preference whose default is labelled Clip. Every engine clips: any channel over 255 becomes 255, any channel under 0 becomes 0. P3 red on an sRGB screen becomes (255, 0, 0). Which is why, on that screen, the two squares are identical: not because the browser can’t tell them apart, but because it threw away the difference at the last step.
Where that last step happens matters. Chrome’s rendering engine doesn’t clip at all. It carries the colour as three floating-point numbers, negatives and all, and hands them to Skia, which converts them into whatever colour space the drawing surface is in and only then clamps each channel to the surface’s limits. On a Mac with the GPU doing the drawing, that surface is the display’s own colour space, not sRGB. Chrome reads the monitor’s colour profile, draws in it, and tags what it draws with the profile’s exact bytes, because a comment in the source explains that compositing a surface whose profile matches the display’s “byte-for-byte” costs “substantially less (~0.5 W for fullscreen updates at 60fps)” than one that doesn’t. So the clip lands in the space the page is drawn in, P3 on a P3 screen and sRGB on an sRGB one, and the same colour survives on one and dies on the other, for half a watt.
Firefox is different. It parses color(display-p3 1 0 0), and then, in its style code, resolves every CSS colour to an 8-bit sRGB value before painting. On a P3 screen in Firefox, the right square is the left square. The syntax works and the colour doesn’t reach the panel. The bug asking for it is still open.
There’s a second thing the 1996 curve does to your colours, and it doesn’t need a wide screen to see. It needs a gradient.
Take red to blue. Halfway between #ff0000 and #0000ff, by the obvious arithmetic, is 127 and a half on two channels, and a CSS gradient between those two stops puts (127, 0, 128) in the middle. Look at it and it’s a dark, muddy purple, noticeably dimmer than either end, as if the gradient sags in the middle.
It does, and the reason is the curve. The numbers in a hex colour aren’t amounts of light. They’re positions on the sRGB curve, which fits what the tubes did and, as a side effect the 1996 authors valued, spends the 256 available steps where eyes can tell them apart, in the darks. #808080, 128 out of 255, sits in the middle of the code range and is about 22 percent of white’s light. The proposal explains the trade in two sentences: without the curve, “encoding a mid-level gray using a gamma of 1.0 would result in a digital count of 46. Such an encoding scheme would create visually objectionable artifacts such as contours.” So the curve is a compression scheme, and it’s a good one. But average two compressed numbers and you don’t get the average of the two lights. You get something darker. Half of 255 on the curve is a fifth of the light, so the middle of red-to-blue has about a fifth of the light of its ends.
The 1996 authors saw this coming and said so: they recommended “that effects such as alpha masking be performed either prior to encoding or by decoding to a color resolution greater than 24 bits and then converting into linear intensity space”. CSS didn’t take the advice. Since CSS got gradients, every one between hex colours has been computed on the curve, and the current CSS Images specification says of the result, flatly, that it “is clearly too dark at the midpoint”.
Fixing it turned out to have two answers, and they’re different questions. If you want to mix two lights the way a projector would, mix in linear light: undo the curve, average, redo the curve. The middle of red-to-blue becomes (187, 0, 187), which is brighter, and is what two overlapping spotlights would give you. If you want the gradient to look evenly spaced to a person, that’s a different job, because eyes aren’t linear either, and for that you need a colour space built around perception. The one the web settled on is called Oklab, with a polar twin called oklch that describes the same colours as lightness, chroma and hue. It has an unusual origin. An engineer named Björn Ottosson published it on his blog in December 2020 and named it Oklab “because it is an OK Lab color space”. Within a year the CSS working group had adopted it, because Chris Lilley of the working group wrote that without it he was “not confident of being able to write a satisfactory gamut mapping section”. A personal blog post is now the web’s default space for mixing colours, with one catch.
CSS says that when colours are interpolated and nobody has said in which space, the space is Oklab. Then it says that hex colours, named colours, rgb(), hsl() and hwb() are “legacy sRGB color formats”, and that a browser “must handle interpolation between legacy sRGB color formats … in gamma-encoded sRGB space”, for compatibility. So a gradient between two hex stops is computed the 1996 way. The same gradient with one stop written as color(srgb 0 0 1), which is exactly the same blue, is computed in Oklab, because the syntax is no longer legacy. The colour didn’t change. The spelling did, and the browser took the spelling as a signal that you knew what you were doing.
Three ways of writing the same gradient, and the exact colour at the middle of each:
linear-gradient(to right, #ff0000, #0000ff)The first chip is the hex gradient with its dark middle. The second changes the spelling of the blue and nothing else, and the middle lifts. The third asks for a polar space by name, which takes the short way round the hue circle. I measured the midpoints in Chrome 152: (127, 0, 128) for the hex pair, (139, 83, 162) once one stop was spelled color(srgb …). The working group did try to close this hole. In March 2023 it resolved that every gradient, legacy or not, should interpolate in Oklab. No browser shipped it, and in June 2025 they reverted, so the spelling rule stands.
One more place the promise shows, because it’s the one people notice without knowing what they’re looking at.
You screenshot the right-hand square on a P3 screen. The file macOS writes carries the display’s colour profile inside it (I checked one; Apple doesn’t document this anywhere I could find), the same way Chrome tags what it draws, and any colour-managed viewer reads the profile and shows the square red. Then the image goes through a tool that drops the profile, and some do, and lands in front of someone. An image with no profile is, in Chrome and Safari and by the same 1996 rule that governs CSS, assumed to be sRGB. (Firefox, by default, colour-manages only tagged images and sends untagged pixels straight to the panel.) The pixel that meant P3’s (255, 0, 0) is now read as sRGB’s (255, 0, 0), which is a different, tamer red. Nobody changed a pixel. The file lost the sentence that said which triangle its numbers were measured in.
Untagged images work the same way in the other direction, and I measured that too. Give Chrome a PNG with no colour information or a plain <canvas>, and it treats them as sRGB and converts them for the panel: on a P3 output, a pure red pixel in either comes out as (234, 51, 35), the same as #ff0000 (a JPEG of the same red came out one step lower, at (233, 51, 35), which is compression, not colour). Tag the PNG with a P3 profile, or ask for a P3 canvas, and the red survives as (255, 0, 0). So in Chrome and Safari, which both colour-match tagged images, a PNG with a P3 profile is a way to show a reader the panel’s red even in a browser with no color() at all, and a screenshot without one is a way to lose it.
Go back to the two squares.
The left one is the promise: #ff0000 is the red corner of a triangle four engineers drew in 1996 around the phosphors of a television tube, and every browser on every screen since has kept that promise, converting the number into whatever the actual panel needs, (234, 51, 35) if the panel is Display P3, so that the colour you see is the 1996 colour and not the screen’s. The right one is the screen’s. It’s been there, unreachable from hex, since 2015 on a Mac, reachable in Safari since 2017 and Chrome since 2023 and in Firefox not yet, and you can name it with a function that says which triangle you mean.
None of that is a mistake. The promise is why a colour picked on one machine matches on another, why a photo doesn’t change when you open it somewhere else, why the web’s colours have stayed put through every kind of screen since. The cost was that the promise was made about a monitor, and the monitor went away.
So the reddest red on your screen isn’t #ff0000. It never was. #ff0000 is a point on a map from 1996, and the map was good enough that nobody looked at the edge of it for twenty years, until the screens grew past it and the number stayed where it was.