Note · August 2026

Brighter than white

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.

SMPTE ST 2084PNG cICPdynamic-range-limitPython · Pillow · Next.js
01

The listing that did not look right

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.

02

Pulling the file apart

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   full
scripts/hdr_verify.py on the encoded asset. The cICP chunk is 16 bytes total — 4 of payload and 12 of framing.

Four bytes. That is the entire trick — everything else is consequence.

03

Why PQ is different

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 inverse EOTF. Encoding L nits to a code value V. The constants are fixed by the standard.

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.

Requested8-bit codeActualStepVerdict
203 nits150 / 255203±5HDR reference white. Indistinguishable from ordinary page white.
1000 nits192 / 2551010±36Shipped here. Clearly brighter than the UI, comfortable to look at.
4000 nits230 / 2553977±145The Wiz / Port.io level. Unmistakable, and past most panels' peak.
10000 nits255 / 25510000PQ 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.

04

The two-level mapping is what makes it read as a glow

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))
Two anchors. Black stays black, mid-tones sit at SDR-normal brightness, only near-white climbs.

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.

05

Writing the chunk

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))
CRC32 covers the type and the data — not the length field. Getting that wrong produces a file every decoder silently rejects.
ByteValueMeaning
09colour primaries — BT.2020
116transfer characteristics — SMPTE ST 2084 (PQ)
20matrix coefficients — identity, data is already RGB
31full 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.

06

cICP or an ICC profile?

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.

07

The demo

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.

This display reports no HDR — you are seeing the SDR fallback, which is the point

Two-level mapping — plate at 60 nits, letters at 4000

SDR source variant
SDR sourcePlain sRGB. The control.
cICP variant
cICPPNG-3 chunk, 4 bytes: 9 / 16 / 0 / 1.

Tagging strategies — same pixels, 1000 nits, different metadata

SDR variant
SDRWhat non-HDR displays get.
cICP variant
cICPPNG-3 chunk only.
iCCP variant
iCCPRec.2100 PQ ICC profile only.
cICP + iCCP variant
cICP + iCCPBoth tags. cICP wins where understood.

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.

08

Colour costs you most of the light

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
Measured peak per channel, decoding the encoded PNG back through the PQ EOTF.

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.

09

An HDR asset you cannot resize

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 tall
The wrapper stretched the asset to fill it. Different scale factors per axis.

Scaling 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.

10

Gating is the actual work

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>
components/hdr-glow.tsx. A server component — the effect ships zero JavaScript and works with JS disabled.

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;
}
styles/globals.css. Tailwind 4 has no utility for this property.

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.

11

The Next.js trap

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.

12

What this costs

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.

13

Prior art

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.