A logo on LinkedIn was emitting more light than the page around it. Here is how that works, how to rebuild it, and why the interesting engineering is in switching it off for most of the people who will see it.
A Jack & Jill job listing came past in my LinkedIn feed with a company logo that was, unambiguously, brighter than the white card it was sitting on. Not higher contrast. Brighter — the way a phone torch in a photo is brighter than the paper it is lying on. I dragged the window between my MacBook display and an external SDR monitor and the effect vanished on one and came back on the other.
That narrowed it immediately. Nothing in CSS can exceed the page white point. Whatever was happening was inside the image file.
A PNG is a signature followed by a flat sequence of chunks, each one length · type · data · CRC32. Dumping the chunk list is a dozen lines of Python and it gave the whole answer at once:
offset chunk length
8 IHDR 13
33 cICP 4
49 IDAT 31195
31256 IEND 0
cICP
colour primaries 9 BT.2020
transfer characteristics 16 SMPTE ST 2084 (PQ)
matrix coefficients 0 identity (RGB)
full range flag 1 fullFour bytes. That is the entire trick — everything else is consequence.
sRGB's transfer curve is relative. A pixel value of 255 means “as bright as this display's white”, whatever that happens to be. You cannot ask for more, because there is no more to ask for — 255 is the top.
PQ, standardised as SMPTE ST 2084 and used by BT.2100, is absolute. Each code value names a specific luminance in cd/m², up to 10,000. Tell a display “this pixel is 1000 nits” and, if it has the headroom, it will give you 1000 nits — regardless of where the operating system has parked SDR white, which is typically around 200.
m1 = 2610 / 16384 m2 = 2523 / 4096 * 128 c1 = 3424 / 4096 c2 = 2413 / 4096 * 32 c3 = 2392 / 4096 * 32 Y = L / 10000 V = ((c1 + c2 * Y**m1) / (1 + c3 * Y**m1)) ** m2
The counter-intuitive part: encoding white at 1000 nits makes the stored byte smaller, not larger. 1000 nits lands at code 192 out of 255. The pixel is darker on disk and brighter on screen, because the curve it is read back through is a completely different one.
| Requested | 8-bit code | Actual | Step | Verdict |
|---|---|---|---|---|
| 203 nits | 150 / 255 | 203 | ±5 | HDR reference white. Indistinguishable from ordinary page white. |
| 1000 nits | 192 / 255 | 1010 | ±36 | Shipped here. Clearly brighter than the UI, comfortable to look at. |
| 4000 nits | 230 / 255 | 3977 | ±145 | The Wiz / Port.io level. Unmistakable, and past most panels' peak. |
| 10000 nits | 255 / 255 | 10000 | — | PQ ceiling. Clips everywhere and is genuinely unpleasant. |
The Step column is the honest cost of doing this in an 8-bit PNG. PQ was designed for 10- and 12-bit signals. At 8 bits, a single code step near 1000 nits is worth about 36 nits, and near 4000 it is worth 145. You cannot hit a target precisely; you can only pick the nearest available rung. My encoder asserts exactly that — that it chose the closest code, not that it landed within some percentage it could never achieve. Writing that check as “within 1%” failed on the first run, which is how I found the number.
Scaling every pixel to 1000 nits does not produce a glowing logo. It produces an image where everything is blinding, which the eye reads as a badly exposed photo rather than as light. The effect depends on contrast within the file.
w = smoothstep(threshold, knee, luma) # 0.80 -> 0.95 L = linear * (background_nits + w * (nits - background_nits))
With --background-nits 60 and --nits 4000, the plate behind a wordmark sits at ordinary brightness while the letters run 60× hotter. That ratio is the effect. The ramp is a smoothstep rather than a threshold because a hard cut bands visibly at 8 bits.
One more correction that is invisible until it is wrong: cICP declares BT.2020 primaries, so the linear RGB has to actually be converted from BT.709 first. Skip it and every non-neutral colour is reinterpreted in a wider gamut and comes out desaturated. It is a no-op for a white ring, and wrong for anything with hue.
Pillow cannot write cICP, so the pixels go through Pillow and the chunk stream is rebuilt by hand. The injector is four lines:
def make_chunk(ctype: bytes, data: bytes) -> bytes:
return (struct.pack(">I", len(data))
+ ctype
+ data
+ struct.pack(">I", zlib.crc32(ctype + data) & 0xFFFFFFFF))| Byte | Value | Meaning |
|---|---|---|
| 0 | 9 | colour primaries — BT.2020 |
| 1 | 16 | transfer characteristics — SMPTE ST 2084 (PQ) |
| 2 | 0 | matrix coefficients — identity, data is already RGB |
| 3 | 1 | full range flag |
Placement is specified: cICP shall appear before PLTE and IDAT. Directly after IHDR satisfies that. PNG's third edition also fixes precedence when colour chunks disagree — cICP outranks iCCP, which outranks sRGB, which outranks cHRM and gAMA. So stripping the others is not strictly required. I strip them anyway, because a decoder that has never heard of cICP will happily act on a contradictory sRGB tag and render the file as a washed-out mess.
There is an older route: embed a Rec.2100 PQ ICC profile via iCCP. The W3C published a profile for exactly this, ITUR_2100_PQ_FULL.ICC, 13,472 bytes of it — versus four. That spec is now deprecated in favour of cICP, but deprecated is not the same as unsupported, and the sources disagree loudly about which one Safari and iOS actually honour.
Since I control this pipeline end to end, guessing was unnecessary. The encoder emits all three — --mode cicp, --mode icc, --mode both — and they are all in the demo below, same pixels, different metadata. both is the pragmatic ship: cICP wins where it is understood, the profile catches everything else, and the precedence rules guarantee they cannot fight.
A verification note that cost me a while: the standard incantation, exiftool -icc_profile:all, cannot see the cICP variant at all. That file has no ICC profile by design, so the command returns empty and reads exactly like a failure. ffprobe does read PNG cICP — it reports smpte2084 / bt2020 — but is blind to the ICC route. Neither tool covers both cases, which is why the verifier here is 200 lines of dependency-free chunk parsing that runs on a piped curl.
Below is the real thing, served from /public/hdr/. On an HDR display in Chrome 136+ or Safari 26+, the tagged tiles will visibly out-emit the page. On anything else they will look like ordinary images — which is the correct outcome, not a broken one.
Two-level mapping — plate at 60 nits, letters at 4000


Tagging strategies — same pixels, 1000 nits, different metadata




Every tile above is the real asset, served straight from /public/hdr/. If they all look identical, your display is SDR, your browser has no HDR image path, or low-power mode has taken the headroom away — all three are the intended outcome, not a bug.
If you screenshot this section and send it to someone, the effect will not survive. A screen capture is written back out as an SDR image; the tone mapping has already happened by the time the file exists. The only way to record it is to point a camera at the physical display, which is why every writeup on this topic, including this one, is bad at showing you the thing it is about.
The second place this ships is the sv_ wordmark in the nav, and it taught me something the white ring never could. I wanted the glow tinted to match the palette rather than white, so I reused the ring's 1000 nits and measured the result:
moss #5f7350 @ 1000 nits -> peak G 168 nits moss #5f7350 @ 4000 nits -> peak G 655 nits cream #ece7dc @ 2000 nits -> peak G 1612 nits
At 1000 nits the moss version peaked at 168 nits — below SDR white. It would have shipped looking exactly like its own fallback, and on a screenshot I would never have known.
The reason is obvious in hindsight and easy to miss: PQ encodes a luminance per channel, and a saturated colour puts most of its channels near zero. White has a relative luminance of 1.0, so asking for N nits gets you N. Moss linearises to roughly (0.11, 0.17, 0.09) — the same request buys under a fifth of the light. “Superwhite” is not a naming accident. White is the only colour that spends the whole budget.
So the encoder now prints measured per-channel peaks on every run. A nit target is a request, not a result, and for anything that is not white the two are far apart.
The nav glow is traced from the letterforms — render the glyphs to a mask, blur, subtract the interior, keep only the light outside the strokes. It shipped looking like fat smudges beside the letters, and the cause was not the artwork:
asset natural 162x116 @3x = 54.0 x 38.7 CSS
wrapper + bleed 34.2 x 20 +40 = 74.2 x 60.0 CSS
-> 1.37x wide, 1.55x tallScaling each axis by a different factor deforms the very outlines the halo was traced from, so it could never line up. Obvious once written down, invisible in a component that had worked fine for a radially symmetric ring.
The fix was to stop resizing it — the component now takes a natural size and centres the asset — plus reproducing the browser's text layout rather than approximating it. Tailwind's text-sm is a 20px line box; the font's ascent plus descent is 18.667px; half-leading puts the baseline 15px below the top of the box. Glyphs are drawn to that baseline and the run is centred on its advance width, trailing letter-spacing included, because browsers include it too.
The general lesson: any HDR asset whose artwork has to register against something the browser paints is a fixed-size asset. Sizing it responsively is not a styling choice, it is a correctness bug.
Un-gated, this technique looks like a rendering bug to a large share of its audience. On LinkedIn there is no choice about that. On my own site there is, at two separate layers.
First, a network gate. <source media> accepts any media query, so (dynamic-range: high) decides which file is even requested. SDR displays never download the PQ asset:
<picture>
<source media="(dynamic-range: high)" srcSet={hdrSrc} />
<img src={sdrSrc} alt="" />
</picture>Second, a brightness gate. dynamic-range-limit — Chrome and Edge 136, Safari 26 — caps how far into the display's headroom the browser will let content go. It is inherited and animatable, so one declaration on a wrapper covers everything inside it:
.hdr-glow {
dynamic-range-limit: constrained;
transition: dynamic-range-limit 400ms ease;
}
.hdr-glow:hover,
.hdr-glow:focus-visible,
.hdr-glow:has(:focus-visible) {
dynamic-range-limit: no-limit;
}Held at constrained at rest, released on interaction. That is not restraint for its own sake. On macOS, sustained HDR content pulls down the system SDR white point, and every other pixel on the page dims to compensate. An always-on glow does not make one element brighter; it makes the entire rest of the site look washed out. If constrained still reads hot, dynamic-range-limit-mix(standard 70%, no-limit 30%) gives finer control.
Which is why the site uses this on exactly one element — the ring behind the photo on the home page — and nowhere else.
These assets must not go through next/image. The optimizer re-encodes via sharp to WebP or AVIF, and nothing guarantees a cICP chunk or an ICC profile survives the round trip. That is the same failure mode that kills this trick on LinkedIn profile photos, which are re-encoded, while company logos, which are not, keep working.
This site turned out to be immune already: next/image is imported zero times anywhere in it. Every image is a plain <img>. That was an accident of house style rather than foresight, but it meant the only thing needed was to keep it that way.
Files in /public are served byte-for-byte. I checked rather than assumed — SHA-256 of three deployed PNGs and a JPEG against the repository copies, all identical, correct content types, no re-encode anywhere in the static path.
Worth being straight about the tradeoff, because it is real and it is not entirely in my favour.
This bets on hardware I do not control.
An HDR display, a browser shipped in the last year, and low-power mode switched off. Miss any of those and you get a perfectly ordinary ring with no indication anything was meant to happen. Firefox is an interesting case: it does implement the cICP chunk — it parses the colour space correctly — it just has no HDR rendering path, so it tone-maps to SDR. The common claim that Firefox has no HDR image support at all is wrong in a way that matters, because it means the fallback there is dim rather than broken.
It is also temporary. Slack has already patched theirs. Platforms will normalise uploaded colour metadata, and when they do this stops working everywhere except sites like this one, where nobody is between the encoder and the browser.
And it is inaccessible in a way I cannot fully fix. There is no prefers-reduced-luminance. I treat a brightness ramp as motion-adjacent and drop the animation under prefers-reduced-motion, but that is an approximation of a preference the platform does not let anyone express.
None of this is mine. It is worth crediting precisely, because the attributions that circulate are muddled — in particular, the most-cited project is not the one that does this to PNGs.
dtinth/superwhite
The original demonstration that a web page can emit light past its own white.
HEVC 10-bit video at 5000 nits peak, authored in Final Cut with an SDR-to-HDR (PQ) tool. Same physics as this post, entirely different container — it is not the PNG technique, and it is routinely miscredited as such.
tatarco/hdr-glow-logo
The PNG-cICP implementation this encoder is modelled on.
Background at ~60 nits, letters at 4000, tagged with a cICP chunk. Roughly a 20× ratio against a ~200-nit SDR white.
superwhite.app
A third route, via ICC rather than cICP.
Embeds a Rec.2100 PQ ICC profile in an ordinary JPEG. Survives LinkedIn feed posts; dies to screenshots, re-saves and messenger compression.
Port.io, then Wiz
Shipped it as a company logo on LinkedIn.
Port.io was first; Wiz is the one that got noticed. Company logos survive because they are not re-encoded the way profile photos are.
Slack
Already patched theirs.
Which is the correct read on the lifespan of this: it is an artefact of platforms not yet normalising uploaded colour metadata, not a durable capability.
The specifications are worth reading directly: PNG Third Edition for the chunk and its precedence rules, ITU-R BT.2100 for the PQ curve, and Chris Lilley's write-up of cICP for why four bytes replaced a 13 KB profile.