Last active
August 19, 2026 19:50
-
-
Save ericboehs/1d2983db0b2f784f5dde3fc9707c1fce to your computer and use it in GitHub Desktop.
Fixes Claude artifacts rendering blank in Safari (WebKit flex-basis:0% abspos containing-block bug)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Claude Artifact Blank Frame Fix | |
| // @description Works around a WebKit layout bug that renders Claude artifacts blank in Safari | |
| // @author Eric Boehs | |
| // @version 1.1 | |
| // @match https://claude.ai/code/artifact/* | |
| // @match https://claude.ai/public/artifacts/* | |
| // @run-at document-end | |
| // @inject-into content | |
| // @grant none | |
| // ==/UserScript== | |
| // claude.ai sizes the artifact iframe with: | |
| // #frame-slot { flex: 1 1 0%; min-width: 0; position: relative; } | |
| // #frame-content { position: absolute; inset: 0; width: 100%; height: 100%; } | |
| // Because #frame-slot is a flex item with flex-basis:0%, WebKit resolves the | |
| // absolutely-positioned containing block as zero-sized on first layout and never | |
| // invalidates it, so the iframe stays 0x0 while still getting class="ready" and | |
| // opacity:1 -- the page looks fully loaded but is blank. Any subsequent relayout | |
| // (resizing the window) fixes it permanently, so all we need is to force one. | |
| // | |
| // Self-retirement: if the upstream bug is fixed, the frame will size itself and | |
| // nudge() will never fire. After CLEAN_LOADS_BEFORE_BANNER consecutive loads | |
| // that needed no nudge, the script says so and offers to be turned off. | |
| (function () { | |
| 'use strict'; | |
| const MAX_NUDGES = 5; | |
| const STORE_KEY = 'claudeArtifactFrameFix'; | |
| const CLEAN_LOADS_BEFORE_BANNER = 10; | |
| const SNOOZE_MS = 30 * 24 * 60 * 60 * 1000; | |
| const VERDICT_DELAY = 7000; | |
| const GIST_URL = 'https://gist.github.com/ericboehs/1d2983db0b2f784f5dde3fc9707c1fce'; | |
| const nudged = new WeakMap(); | |
| let nudgesThisLoad = 0; | |
| // localStorage can throw outright under strict storage-blocking settings, so | |
| // every access is guarded and the script stays functional without it. | |
| function readState() { | |
| try { | |
| return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; | |
| } catch (e) { | |
| return {}; | |
| } | |
| } | |
| function writeState(state) { | |
| try { | |
| localStorage.setItem(STORE_KEY, JSON.stringify(state)); | |
| } catch (e) { | |
| /* storage unavailable; skip streak tracking */ | |
| } | |
| } | |
| // Toggling display forces a synchronous relayout, which makes WebKit | |
| // re-resolve the containing block against the now-known flexed size. | |
| function nudge(frame) { | |
| if (!frame || !frame.isConnected) return; | |
| const slot = frame.parentElement; | |
| if (!slot || !slot.offsetWidth || !slot.offsetHeight) return; | |
| if (frame.offsetWidth > 0 && frame.offsetHeight > 0) return; | |
| const count = nudged.get(frame) || 0; | |
| if (count >= MAX_NUDGES) return; | |
| nudged.set(frame, count + 1); | |
| const previous = frame.style.display; | |
| frame.style.display = 'none'; | |
| void frame.offsetHeight; | |
| frame.style.display = previous; | |
| // The bug is still here. Reset the streak and retract any banner already | |
| // shown -- a late nudge (an artifact version swap, say) contradicts it. | |
| if (nudgesThisLoad++ === 0) { | |
| const state = readState(); | |
| if (state.cleanLoads) { | |
| state.cleanLoads = 0; | |
| writeState(state); | |
| } | |
| } | |
| removeBanner(); | |
| } | |
| function sweep() { | |
| document.querySelectorAll('#frame-slot > iframe').forEach(nudge); | |
| } | |
| function removeBanner() { | |
| const banner = document.getElementById('caff-banner'); | |
| if (banner) banner.remove(); | |
| } | |
| function showBanner(cleanLoads) { | |
| // The shell can itself be embedded (see html[data-embedded] in their CSS). | |
| // Fix the frame wherever we run, but only ever surface UI at the top level. | |
| if (window.top !== window || document.getElementById('caff-banner')) return; | |
| const banner = document.createElement('div'); | |
| banner.id = 'caff-banner'; | |
| banner.style.cssText = [ | |
| 'position:fixed', 'bottom:16px', 'right:16px', 'z-index:2147483647', | |
| 'max-width:340px', 'padding:14px 16px', 'border-radius:10px', | |
| 'background:#1c1c1e', 'color:#f5f5f7', 'border:1px solid #3a3a3c', | |
| 'box-shadow:0 8px 28px rgba(0,0,0,.35)', 'font:13px/1.45 -apple-system,' + | |
| 'BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif', | |
| ].join(';'); | |
| const text = document.createElement('div'); | |
| text.textContent = | |
| 'Claude artifacts have rendered correctly ' + cleanLoads + | |
| ' loads in a row without this workaround doing anything. The Safari ' + | |
| 'layout bug looks fixed — you can probably turn this userscript off.'; | |
| text.style.marginBottom = '12px'; | |
| const row = document.createElement('div'); | |
| row.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap'; | |
| const mkButton = (label, primary) => { | |
| const b = document.createElement('button'); | |
| b.textContent = label; | |
| b.style.cssText = [ | |
| 'padding:5px 11px', 'border-radius:6px', 'cursor:pointer', | |
| 'font:inherit', 'font-weight:500', | |
| primary ? 'background:#f5f5f7' : 'background:transparent', | |
| primary ? 'color:#1c1c1e' : 'color:#a1a1a6', | |
| primary ? 'border:1px solid #f5f5f7' : 'border:1px solid #48484a', | |
| ].join(';'); | |
| return b; | |
| }; | |
| const stop = mkButton('Stop checking', true); | |
| stop.addEventListener('click', () => { | |
| const state = readState(); | |
| state.dismissedForever = true; | |
| writeState(state); | |
| removeBanner(); | |
| }); | |
| const later = mkButton('Remind me later', false); | |
| later.addEventListener('click', () => { | |
| const state = readState(); | |
| state.snoozedUntil = Date.now() + SNOOZE_MS; | |
| state.cleanLoads = 0; | |
| writeState(state); | |
| removeBanner(); | |
| }); | |
| const link = document.createElement('a'); | |
| link.href = GIST_URL; | |
| link.target = '_blank'; | |
| link.rel = 'noreferrer'; | |
| link.textContent = 'What is this?'; | |
| link.style.cssText = 'color:#a1a1a6;margin-left:auto;font-size:12px'; | |
| row.append(stop, later, link); | |
| banner.append(text, row); | |
| document.body.appendChild(banner); | |
| } | |
| // Decide, well after the retry window, whether this load actually needed us. | |
| function recordVerdict() { | |
| const state = readState(); | |
| if (state.dismissedForever) return; | |
| if (nudgesThisLoad > 0) return; // nudge() already reset the streak | |
| const frame = document.querySelector('#frame-slot > iframe'); | |
| if (!frame || !frame.offsetWidth || !frame.offsetHeight) return; // inconclusive | |
| state.cleanLoads = (state.cleanLoads || 0) + 1; | |
| writeState(state); | |
| const snoozed = state.snoozedUntil && Date.now() < state.snoozedUntil; | |
| if (state.cleanLoads >= CLEAN_LOADS_BEFORE_BANNER && !snoozed) { | |
| showBanner(state.cleanLoads); | |
| } | |
| } | |
| sweep(); | |
| window.addEventListener('load', sweep); | |
| // The frame is sized asynchronously once it reports ready, so re-check for a | |
| // few seconds after load rather than assuming a single pass is enough. | |
| [0, 50, 150, 400, 1000, 2500, 5000].forEach((delay) => setTimeout(sweep, delay)); | |
| setTimeout(recordVerdict, VERDICT_DELAY); | |
| const slot = document.getElementById('frame-slot'); | |
| if (slot) { | |
| // Catches artifact version swaps, which insert a fresh iframe into the slot. | |
| // Watch only class/childList -- watching style would refire on our own nudge. | |
| new MutationObserver(sweep).observe(slot, { | |
| childList: true, | |
| subtree: true, | |
| attributes: true, | |
| attributeFilter: ['class'], | |
| }); | |
| if (window.ResizeObserver) new ResizeObserver(sweep).observe(slot); | |
| } | |
| })(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Claude Artifact Blank Frame Fix
A userscript that works around a WebKit layout bug which makes Claude artifacts render as a blank page in Safari.
The bug
Open a
claude.ai/code/artifact/...link in Safari and you get the header and Share button over empty space. The page reports itself as fully loaded —document.readyState === "complete", the iframe hasclass="ready"andopacity: 1— there's just nothing in it.claude.ai sizes the artifact iframe like this:
#frame-slotmeasures 1365×1019, but#frame-contentcomputes to 0px × 0px.Because
#frame-slotis a flex item withflex-basis: 0%, WebKit resolves the absolutely-positioned containing block as zero-sized during first layout — before flex resolution has given the slot its real size — and then never invalidates it. The iframe is laid out once, against nothing, and stays collapsed.It is not a network, auth, or content-blocker problem. The frame URL returns
200and loads in ~276ms.Confirming it yourself
Paste into the Web Inspector console on a blank artifact:
Now resize the Safari window. It snaps in and stays — any relayout fixes it permanently.
The fix
Force exactly one relayout, which is all the window resize was doing:
Notably, overriding
width/heighttoautodoes not work (still 0×0) even though it also forces a reflow — the abspos containing block itself is stale, not just the percentage resolution.position: relativedoes work, but changing the positioning scheme risks breaking the crossfade animation used when an artifact updates in place. The display toggle leaves claude.ai's own styling completely untouched.Features
#frame-slothas real dimensions — a no-op on healthy loadsdocument-end,load, timed re-checks out to 5sMutationObserver+ResizeObserveron#frame-slotto catch artifact version swaps, which insert a fresh iframeclass/childListrather thanstyle— watchingstylewould refire on its own nudgeInstallation
Requires a userscript manager. On Safari, Userscripts (free, open source).
Or just open the raw file in Safari with a userscript manager installed.
After installing
If you dropped the file in via the command line, click the Userscripts toolbar icon once to trigger a directory rescan — the extension won't pick up files written behind its back, and the script will silently not run until it does. Then reload the artifact.
Also confirm the extension has host permission for
claude.ai. If it's set per-site rather than "Allow on Every Website", it fails silently.Dependencies
None. No
@grant, no external libraries.Matches
Verifying it works
Self-retirement banner
The script knows when it has become unnecessary. If the upstream bug is fixed, the frame sizes itself and the nudge never fires — so a load that needed no nudge is evidence the bug is gone.
After 10 consecutive clean loads, a small banner offers to turn the script off, with Stop checking (permanent) and Remind me later (30-day snooze).
It is deliberately hard to trigger by accident:
html[data-embedded]appears in their CSS)State lives in one namespaced
localStoragekey,claudeArtifactFrameFix, and every access is wrapped in try/catch — strict storage-blocking settings can makelocalStoragethrow outright, and the fix still works without it.To reset:
localStorage.removeItem('claudeArtifactFrameFix')Notes
Tested on macOS 26.6 / Safari 26.6. The bug is not perfectly deterministic — most loads render blank, but some come up fine, so judge it over several loads rather than one. This is an upstream claude.ai CSS bug; the real fix is giving
#frame-slota definite size so WebKit resolves the containing block correctly on first layout. This script should become unnecessary if that lands.