You build a tournament bracket with a popular React library. In Chrome it's perfect — neat columns, clean connector lines. Then you open it on an iPhone, or in Safari, or inside your Capacitor app… and every match is crammed into the top-left corner, stacked on top of the round headers.
If you've ever shipped a bracket to iOS, you've probably seen this exact bug. Here's why it happens — and a tiny library that fixes it for good.
The symptom
It looks fine everywhere Chromium runs (Chrome, Edge, Android WebView) and completely broken everywhere WebKit runs:
- Safari (macOS and iOS)
- iOS WKWebView
- Capacitor / Cordova apps
- Electron-on-WebKit
The matches don't just shift a little — they all render at coordinate (0,0) of the bracket, piling on top of each other and the headers.
The cause: SVG <foreignObject> in WebKit
Most React bracket libraries — @g-loot/react-tournament-brackets, react-tournament-bracket, and friends — render the bracket as an SVG and place each match's HTML inside a <foreignObject> positioned with x/y attributes.
WebKit has a long-standing bug: it ignores x, y, and transform on <foreignObject> and positions the content relative to the top-level <svg> instead of the foreignObject's own coordinates. Every match therefore collapses to the origin.
And there's no CSS escape hatch — x, y, and transform are all ignored on foreignObject in Safari, so you can't nudge the content back into place. I even tried patching a library to wrap each match in a <g transform="translate(x,y)"> instead of a nested <svg x y>; WebKit ignores ancestor transforms for foreignObject positioning too. The SVG approach is simply a dead end on WebKit.
The fix: don't use SVG at all
A bracket is really just columns of cards joined by connector lines — and both are expressible in plain CSS.
Here's the key insight. Put each round in a flex column where every match sits in an equal flex: 1 slot. Because each round has half the matches of the previous one, a match's slot spans exactly two feeder slots — so the two feeders land at 25% and 75% of that slot, and the match itself at 50%:
Round 1 slot Round 2 slot
┌──────────┐
│ Match A │──┐ 25%
├──────────┤ ├─►┌──────────┐
│ Match B │──┘ │ Winner │ 50%
└──────────┘ └──────────┘
┌──────────┐
│ Match C │──┐ 75%
│ ... │
Draw the connector elbow with a few absolutely-positioned, bordered <div>s at those same 25 / 50 / 75% offsets, and the tree stays perfectly aligned at any height — with zero JavaScript measuring and no SVG. Because it's all flexbox and borders, it renders identically on Chromium, Firefox, and WebKit.
bracketkit
I packaged this up as bracketkit — a headless, pure-CSS tournament bracket for React:
- 🍏 Works in Safari / WebKit — no SVG, no
foreignObject. - 🧩 Headless — you render the match card; bracketkit owns layout + connectors. No theme objects, no design lock-in.
- 🪶 ~4 KB, zero dependencies — ESM + CJS + first-class TypeScript types.
- ⚡ SSR-safe — no measurement, correct on the first server render.
- 🎨 Style it any way — plain CSS, Tailwind, or a drop-in shadcn/ui component.
Quick start
npm i bracketkit
import { Bracket, type BracketRound } from "bracketkit"
type Match = { id: string; home: string; away: string; homeScore?: number; awayScore?: number }
const rounds: BracketRound<Match>[] = [
{
id: "sf",
name: "Semi-finals",
matches: [
{ id: "sf1", home: "Lions", away: "Bears", homeScore: 2, awayScore: 1 },
{ id: "sf2", home: "Hawks", away: "Wolves", homeScore: 0, awayScore: 3 },
],
},
{ id: "f", name: "Final", matches: [{ id: "f1", home: "Lions", away: "Wolves" }] },
]
export function Playoffs() {
return (
<div style={{ overflowX: "auto", color: "#64748b" /* connector color */ }}>
<Bracket
rounds={rounds}
renderRoundHeader={(round) => <h3>{round.name}</h3>}
renderMatch={(m) => (
<div className="match-card">
<div>{m.home} — {m.homeScore ?? "–"}</div>
<div>{m.away} — {m.awayScore ?? "–"}</div>
</div>
)}
/>
</div>
)
}
Theming
bracketkit ships no visual styling beyond layout. Connectors inherit currentColor and expose two CSS variables; every part has a data-* hook:
[data-bracket-root] {
--bracket-connector-color: #64748b;
--bracket-connector-width: 2px;
}
[data-bracket-match] { /* your card wrapper */ }
Prefer shadcn/ui?
npx shadcn@latest add https://hrmasss.github.io/bracketkit/r/bracket.json
You get a styled, batteries-included <Bracket> using your shadcn tokens — and you own the code.
Try it
- 🔗 Live demo & docs: https://hrmasss.github.io/bracketkit/
- 📦 npm: https://www.npmjs.com/package/bracketkit
- ⭐ GitHub: https://github.com/hrmasss/bracketkit
If you've fought the Safari foreignObject bug, I'd love to know whether this saves you the headache. Issues and PRs welcome.

Top comments (0)