2 min read
A flash-free theme toggle, with a strict CSP and View Transitions
The inline script that prevents a flash of the wrong theme collides head-on with a strict CSP, and then View Transitions threatens to break it again. How all three coexist without dropping below A+.
- astro
- security
- csp
- view-transitions
- theme
A light/dark theme toggle has a silent enemy: FART, the flash of wrong theme.
If you decide the theme after React hydrates, the user sees a flicker of the
default theme before theirs kicks in. The cure is old and well known: an
<script is:inline> in the <head> that reads localStorage and sets
data-theme on <html> before the first paint.
The catch is that this same script collides with the other two things I cared about: a strict Content-Security-Policy and View Transitions.
Conflict one: the CSP doesn’t want inline scripts
A CSP worthy of an A+ on securityheaders.com carries no 'unsafe-inline' in
script-src. But my anti-FART is an inline script — by definition it has to
run before any bundle. The temptation is to loosen the policy; the right move is
to hash it.
CSP Level 3 lets you authorize a specific inline script by its SHA-256 hash. I
compute the hash of the script’s exact content at build time and add it to
script-src:
script-src 'self' 'sha256-…';
The browser hashes every inline script it finds and only runs the ones that match. The anti-FART runs; any unauthorized injection is blocked. The trap: the hash depends on the content byte for byte. One extra space, one change Astro makes to the bootstrap, and the browser blocks the script and the page breaks. That’s why the hash is recomputed on every build by a dedicated script, never by hand.
Conflict two: View Transitions re-mounts the <head>
With <ClientRouter />, Astro does cross-document navigations using View
Transitions: gorgeous, free, zero animation JS. But when it swaps documents, the
data-theme my script had set can get lost between one page and the next, and
the flicker returns — now on every navigation, not just the initial load.
Two pieces solve it. The toggle island is marked with transition:persist so it
isn’t re-mounted and keeps its state. And the theme is re-applied by listening
to the astro:before-swap event, which fires before the new document is
mounted: I set data-theme on event.newDocument so it arrives already themed
and there’s no flash, mirroring what the anti-FART does on the initial load.
What I took away
All three features are good on their own and enemies if you bolt them together
without thinking. The pattern that reconciles them is the same in both cases:
apply the theme at the exact moment before paint — on load, via a hashed script;
on each navigation, via astro:before-swap — and never trust the state to
survive on its own. The result is a toggle that doesn’t flicker, a CSP still at
A+, and smooth page transitions. None of the three had to give.