DEV Community

Cover image for Why your React tournament bracket breaks in Safari (and a 4 KB pure-CSS fix)
Hojayfa Rahman
Hojayfa Rahman

Posted on

Why your React tournament bracket breaks in Safari (and a 4 KB pure-CSS fix)

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%
│   ...    │
Enter fullscreen mode Exit fullscreen mode

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.
  • 🧩 Headlessyou 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
Enter fullscreen mode Exit fullscreen mode
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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 */ }
Enter fullscreen mode Exit fullscreen mode

Prefer shadcn/ui?

npx shadcn@latest add https://hrmasss.github.io/bracketkit/r/bracket.json
Enter fullscreen mode Exit fullscreen mode

You get a styled, batteries-included <Bracket> using your shadcn tokens — and you own the code.

Try it

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)