Skip to content

Instantly share code, notes, and snippets.

@ericboehs
Last active August 19, 2026 19:50
Show Gist options
  • Select an option

  • Save ericboehs/1d2983db0b2f784f5dde3fc9707c1fce to your computer and use it in GitHub Desktop.

Select an option

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)
// ==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);
}
})();
@ericboehs

ericboehs commented Aug 19, 2026

Copy link
Copy Markdown
Author

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 has class="ready" and opacity: 1 — there's just nothing in it.

claude.ai sizes the artifact iframe like this:

#frame-slot    { flex: 1 1 0%; min-width: 0; position: relative; }
#frame-content { position: absolute; inset: 0; width: 100%; height: 100%; }

#frame-slot measures 1365×1019, but #frame-content computes to 0px × 0px.

Because #frame-slot is a flex item with flex-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 200 and loads in ~276ms.

Confirming it yourself

Paste into the Web Inspector console on a blank artifact:

const f = document.getElementById('frame-content');
console.log(f.offsetWidth, f.offsetHeight);              // 0 0
console.log(f.parentElement.offsetWidth, f.parentElement.offsetHeight); // 1365 1019

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:

frame.style.display = 'none';
void frame.offsetHeight;   // force synchronous relayout
frame.style.display = previous;

Notably, overriding width/height to auto does 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: relative does 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

  • Fires only when the iframe is actually 0×0 and #frame-slot has real dimensions — a no-op on healthy loads
  • Capped at 5 nudges per frame, so it can't loop
  • Layered triggers, since the frame sizes asynchronously after it reports ready: document-end, load, timed re-checks out to 5s
  • MutationObserver + ResizeObserver on #frame-slot to catch artifact version swaps, which insert a fresh iframe
  • Observes class/childList rather than style — watching style would refire on its own nudge

Installation

Requires a userscript manager. On Safari, Userscripts (free, open source).

curl -o ~/Library/Containers/com.userscripts.macos.Userscripts-Extension/Data/Documents/scripts/'Claude Artifact Blank Frame Fix.user.js' \
  'https://gist.githubusercontent.com/ericboehs/1d2983db0b2f784f5dde3fc9707c1fce/raw/Claude%20Artifact%20Blank%20Frame%20Fix.user.js'

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

https://claude.ai/code/artifact/*
https://claude.ai/public/artifacts/*

Verifying it works

document.getElementById('frame-content').offsetWidth   // should be non-zero

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:

  • Any nudge resets the streak to zero and retracts an already-visible banner — a late nudge from an artifact version swap contradicts the verdict
  • The verdict is only recorded 7s after load, well past the retry window
  • A load where the frame is missing or still 0×0 counts as inconclusive, neither clean nor dirty
  • The banner only renders in the top-level frame, since the shell can itself be embedded (html[data-embedded] appears in their CSS)

State lives in one namespaced localStorage key, claudeArtifactFrameFix, and every access is wrapped in try/catch — strict storage-blocking settings can make localStorage throw 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-slot a definite size so WebKit resolves the containing block correctly on first layout. This script should become unnecessary if that lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment