Today I opened Safari and watched four hours of work collapse into the top-left corner of the screen.
The project management tool I am currently working on looked perfect in Chrome. Cards in columns, arranged exactly where the transforms placed them. Real HTML inside SVG – buttons, wrapped text, hover states that responded. Everything foreignObject promises you.
Then came Safari. It showed me a pile of cards in the top-left corner. Coordinates (0, 0). Stacked on top of each other.
I refreshed. Same pile. I cleared the cache. Same pile. I opened DevTools and checked my transforms – they were correct. The x and y values pointed exactly where they should. Safari just… disagreed?
The worst part wasn’t that it was broken. The worst part was that it looked so simple. Cards in columns. Transforms placing them where they should go. The code was right. I could see it in DevTools – the x and y values pointed exactly where they should. Why should Safari ignore that?
I assumed I’d made a mistake. Spent an hour checking my code. I hadn’t made a mistake. The code was fine. It turns out, the browser was broken, had been broken since 2009, and Apple had decided they didn’t care.
What foreignObject Is Supposed To Do
SVG is beautiful for graphics but terrible for text. The <text> element doesn’t wrap. It doesn’t support rich formatting. You can’t put a button inside it. If you want real interactivity inside an SVG diagram, you need HTML.
That’s what <foreignObject> is for. You carve out a rectangle in your SVG, tell it “HTML lives here now,” and drop in whatever DOM elements you want. The browser composites them together. The HTML respects the SVG’s coordinate system. Everyone’s happy.
<svg viewBox="0 0 800 600">
<g transform="translate(200, 100)">
<foreignObject width="300" height="200">
<div xmlns="http://www.w3.org/1999/xhtml">
<h2>This is real HTML</h2>
<button>Click me</button>
</div>
</foreignObject>
</g>
</svg>
Chrome and Firefox render this exactly as written. The div appears at (200, 100), takes up a 300×200 rectangle, and the button works.
Safari renders it at (0, 0). Maybe. Or maybe it renders correctly until you hover over it, then jumps. Or maybe it renders at some third location that has nothing to do with either the intended position or the origin. The behavior changes depending on what CSS properties exist anywhere in the subtree.
This is the bug. It’s simple to describe and impossible to fix from your end, because the calculation that’s failing lives inside WebKit’s rendering pipeline where you can’t reach it.
The Bug, Technically
WebKit Bug #23113. Filed January 6, 2009.
Safari calculates foreignObject content positioning relative to the SVG root element instead of the foreignObject itself. When CSS properties create GPU compositing layers inside the foreignObject – transforms, transitions, opacity changes, positioned elements – Safari’s compositor miscalculates where to paint them.
The content appears at the SVG origin. Always (0, 0). As if the foreignObject’s position in the document tree doesn’t exist, as if all those <g> transforms wrapping it are invisible.
Seventeen years old. Apple knows. The bug has hundreds of comments from developers hitting the exact same wall. No fix has ever shipped.
I didn’t know any of this when I started. I learned it the hard way, one failed fix at a time.
The Triggers
The foreignObject element itself isn’t broken. It’s specifically the combination of foreignObject and CSS that creates compositing layers. These properties, anywhere in the foreignObject’s DOM subtree, can trigger the bug:
| Property | Triggers Bug |
|---|---|
position: relative |
Yes |
position: absolute |
Yes |
transform (any value) |
Yes |
opacity (less than 1) |
Yes |
transition |
Yes |
filter |
Yes |
will-change |
Yes |
These properties create GPU compositing layers. Safari’s foreignObject doesn’t handle them. The combination is poisonous.
The cruelest part: you don’t have to add these properties yourself. I found transition: all computed on elements I’d never touched. Inherited from a CSS reset. Applied by a library. Invisible until I cracked open DevTools and checked computed styles on every element in the tree.
The Hunt
I’ll walk you through everything I tried. Not because the fixes work – none of them work – but because you’re going to try them anyway, and maybe seeing them fail here will save you the hours.
Stripping Transitions
The obvious move. Transitions are listed as triggers. Kill them.
.card-group, .card-group * {
transition: none !important;
}
I watched the Safari inspector confirm: no computed transitions on my elements. The cards still sat at (0, 0). Other properties in the subtree were still creating compositing layers. Removing transitions was necessary but not sufficient.
The Nuclear Option
If compositing layers are the problem, prevent anything from creating them. I wrote the most aggressive reset I could imagine.
.card-group, .card-group * {
transition: none !important;
transform: none !important;
opacity: 1 !important;
filter: none !important;
will-change: auto !important;
-webkit-transform-style: flat !important;
-webkit-backface-visibility: visible !important;
}
Still broken. The cards piled in the corner, mocking me. I’d missed the biggest trigger: I was using position: relative for internal card layout. Every card container was positioned. Every single one was creating a compositing layer.
Killing Positioning
I forced everything to position: static.
.card-group, .card-group * {
position: static !important;
}
Two things happened. First, the internal card layout exploded. Absolute-positioned children now positioned relative to the viewport. Buttons ended up in the corner of the screen instead of the corner of their cards. The components became unusable.
Second, the Safari bug persisted anyway. Even with nothing positioned, the miscalculation continued. Static positioning wasn’t a fix; it was just trading one broken thing for two broken things.
Forcing Repaints
Maybe Safari’s calculation was cached and needed a nudge. The standard repaint trick: toggle display, force a reflow, toggle it back.
container.style.display = 'none';
container.offsetHeight; // Force reflow
container.style.display = '';
The repaint fired. I could see it in the performance timeline. The bug didn’t care. Safari’s positioning calculation happens at a layer the repaint cycle can’t touch.
Direct Coordinates
Maybe the problem was the <g> transform wrapper. What if I skipped it and set coordinates directly on the foreignObject?
foreignObject.setAttribute('x', 538);
foreignObject.setAttribute('y', 200);
Chrome rendered this exactly where I expected. Safari rendered it hundreds of pixels away. Different browsers interpret foreignObject x/y attributes differently when compositing layers are involved. I’d traded the (0, 0) bug for a “wrong coordinates” bug. Neither was acceptable.
The translateZ Hack
The webkit-specific trick that’s supposed to force predictable GPU rendering.
.card-group {
-webkit-transform: translateZ(0);
}
This one was interesting. The content stayed inside its card boundaries. No jump to (0, 0). But the <g> wrapper’s transform was now ignored entirely. Every card rendered at the SVG origin, stacked perfectly on top of each other. The content was positioned relative to its card, but the cards themselves lost their positioning.
I’d fixed the internal problem and broken the external one. The bug is a hydra. Cut off one head, another grows.
position: fixed
After hours of trying things out and lost in despair I found a GitHub issue from someone who’d walked this road before. A developer named bkrem had documented the same bug in a D3 tree library and discovered that position: fixed on the content container stabilized Safari’s rendering.
.card-group {
position: fixed;
}
It worked. The cards rendered where they belonged and stayed there. Through hovers, through clicks, through DOM updates that previously triggered the jump.
But there was a ghost.
During interactions – any interaction that caused a DOM update – a flicker appeared on cards near the right edge of the SVG. A partial rendering, maybe 40 pixels wide, flashing for a single frame at the wrong position. A ghost of the card, appearing and disappearing faster than you could focus on it.
I spent another hour trying to kill the ghost. I adjusted viewBox boundaries. I tried different overflow settings. I moved columns away from the SVG edge. Nothing worked. The ghost is intrinsic to how position: fixed interacts with Safari’s foreignObject compositing during repaints.
The ghost didn’t break functionality. Users could click buttons, enter text, use the tool. It was purely cosmetic. One frame. Barely visible if you weren’t looking for it.
I shipped it. Told myself it was fine.
Every time I tested the tool, I saw the flicker. Every demo. Every screenshot. I knew it was there. I’d compromised with a bug and the bug was stinking the place up.
If You Can’t Refactor
Maybe you’re reading this at 2am with a deadline and you need the bandaid, not the surgery. Here’s the position: fixed approach that minimizes the damage:
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('transform', `translate(${x}, ${y})`);
const foreignObject = document.createElementNS(
'http://www.w3.org/2000/svg',
'foreignObject'
);
foreignObject.setAttribute('x', '0');
foreignObject.setAttribute('y', '0');
foreignObject.setAttribute('width', '480');
foreignObject.setAttribute('height', '2000');
const container = document.createElement('div');
container.style.position = 'fixed';
container.style.width = '480px';
Rules for survival:
- Use
position: fixedon the outer container. This anchors content correctly in Safari’s broken coordinate system. - Use flexbox for all internal layout. Flexbox doesn’t create the compositing layers that trigger the bug.
- No positioned children. No
position: relative, noposition: absolute. Static only. - No transforms. Not for animation, not for centering, not for anything. Find another way.
- No transitions. Static, instant state changes only.
- Accept the ghost. One-frame flicker during DOM updates. Cosmetic. Can’t be fixed. Ship anyway.
This keeps your content in place. It doesn’t fix the bug – nothing fixes the bug – it works around it well enough to ship.
If you have time for a real fix, read on.
The Metaphor
ForeignObject in Safari is like a rental car with a steering wheel that’s three degrees off center.
You can drive it. You adjust. You compensate without thinking about it. The car goes where you want, mostly. But the wheel is wrong, and you can feel it in your hands every second you’re driving, and no amount of adjusting the seat or the mirrors or your grip changes the fundamental fact that the wheel is wrong and the rental company doesn’t care because the car still technically drives.
You can compensate for Safari’s foreignObject. position: fixed works, mostly. The ghost exists but doesn’t break anything, mostly. Ship it and move on, mostly.
Or you can rent a different car.
The Actual Solution
I rented a different car.
The principle is simple: don’t put HTML inside SVG. Put HTML next to SVG, positioned absolutely to look like it’s inside. Let the browser do what browsers do well – render HTML – without asking Safari to do the one (cough) thing Safari refuses to do correctly.
// SVG handles only graphics
const svg = document.querySelector('svg');
const svgWrapper = svg.parentElement;
// svgWrapper must establish a containing block for absolute positioning
// (position: relative, absolute, or fixed)
svgWrapper.style.position = 'relative';
// Cards live in a normal HTML container, sibling to the SVG
const cardLayer = document.createElement('div');
cardLayer.style.position = 'absolute';
cardLayer.style.inset = '0';
cardLayer.style.pointerEvents = 'none';
svgWrapper.appendChild(cardLayer);
// Convert SVG coordinates to screen coordinates for each card
function positionCard(card, svgX, svgY) {
const point = svg.createSVGPoint();
point.x = svgX;
point.y = svgY;
// getScreenCTM gives us the transformation matrix from SVG to screen
const screenPoint = point.matrixTransform(svg.getScreenCTM());
const wrapperRect = svgWrapper.getBoundingClientRect();
card.style.position = 'absolute';
card.style.left = `${screenPoint.x - wrapperRect.left}px`;
card.style.top = `${screenPoint.y - wrapperRect.top}px`;
card.style.pointerEvents = 'auto';
}
// Recalculate when the SVG changes size
const resizeObserver = new ResizeObserver(() => {
cards.forEach(card => {
positionCard(card, card.dataset.svgX, card.dataset.svgY);
});
});
resizeObserver.observe(svg);
The cards are normal HTML divs. They support every CSS property. Transitions, transforms, filters – use whatever you want. Safari renders them perfectly because Safari is perfectly capable of rendering HTML. It’s only foreignObject that’s broken.
The coordination between SVG and HTML coordinates is the only tricky part. getScreenCTM() handles the math. A resize observer handles window changes. The complexity is real but bounded. You implement it once and it works forever.
Note: If your SVG lives inside a scrolling container, you’ll also need a scroll listener – getBoundingClientRect() returns viewport-relative coordinates that shift as you scroll. Browser zoom usually triggers resize, but test your specific setup.
No flickering. No ghosts. No unfixable browser bugs. Just HTML doing what HTML does, positioned to align with SVG that does what SVG does.
The refactor took a few hours. The time I’d spent fighting foreignObject was longer.
The Lesson
I spent a day learning something that would have taken five minutes to learn if someone had told me. That’s why this article exists.
When you find a bug this old, with hundreds of comments from developers, with no response from the vendor, with no fix in sight – stop trying to fix it. The bug is a policy decision. You can fight that decision. You’ll lose. Your time will disappear into the same hole that swallowed mine.
Or you can accept the bug for what it is – a permanent feature of the browser landscape – and architect around it from the start.
HTML overlays take more setup than foreignObject. The coordinate bridging adds complexity. But the solution works. In every browser. Without ghosts, without flickering, without workarounds for workarounds. Don’t donate your day to a bug that Apple abandoned during the Obama administration.
Sources
WebKit Bug #23113 – The original. January 2009. Seventeen years of developers pleading for a fix.
WebKit Bug #165516 – Related report on disappearing content.
react-d3-tree Issue #284 – The GitHub issue that finally explained the position: fixed workaround. This one taught me more than hours of independent debugging.
elm/virtual-dom Issue #99 – Confirms the ghost artifact isn’t your code.
GSAP Forums – Animation developers hitting the same wall.
If this saved you time, good.
