Why your React tournament bracket breaks in Safari (and a 4 KB pure-CSS fix)
Beyond SVG: Building Cross-Platform Tournament Brackets with Pure CSS Flexbox
Current Situation Analysis
Tournament bracket visualizations are a staple in sports applications, gaming platforms, and event dashboards. Yet, developers consistently encounter a silent failure mode when targeting iOS, Safari, or WebKit-based application wrappers like Capacitor, Cordova, or Electron-WebKit. The symptom is immediate and severe: instead of a structured tree, every match card collapses to the top-left coordinate (0,0) of the viewport, stacking directly on top of round headers and each other.
This problem is frequently overlooked because the dominant UI libraries default to SVG for scalable graphics. The <foreignObject> element appears to be the ideal bridge: it allows developers to embed standard HTML/CSS match cards inside an SVG coordinate system, leveraging x and y attributes for precise placement. During development on Chromium or Firefox, the layout renders flawlessly, creating a false sense of cross-browser parity.
The root cause lies in a long-standing deviation within WebKit's rendering engine. WebKit does not fully implement the SVG specification for <foreignObject>. It systematically ignores x, y, and transform attributes applied to the element, anchoring all nested content to the root SVG's origin. CSS workarounds fail because WebKit also disregards ancestor <g transform="translate(x,y)"> wrappers when calculating foreignObject positioning. The SVG approach is not just suboptimal on WebKit; it is fundamentally broken.
WOW Moment: Key Findings
Abandoning SVG in favor of a pure CSS flexbox architecture resolves the WebKit collapse while simultaneously unlocking performance and developer experience improvements that SVG-based libraries cannot match. The mathematical alignment of tournament brackets maps directly to flexbox's equal distribution model, eliminating the need for coordinate math, JavaScript measurement, or hydration-time layout fixes.
| Approach | WebKit Compatibility | Bundle Overhead | SSR Readiness | Layout Strategy |
|---|---|---|---|---|
| SVG + foreignObject | β Broken (collapses to 0,0) | ~15-25 KB | β Requires hydration | Coordinate-based (x/y/transform) |
| Pure CSS Flexbox | β Native | ~4 KB | β First-render correct | Percentage-based (25/50/75%) |
| Canvas/Measurement | β Works | ~30+ KB | β Flickers on load | JS-driven (getBoundingClientRect) |
This finding matters because it shifts bracket rendering from a measurement-heavy, engine-dependent paradigm to a declarative, CSS-native one. By leveraging flexbox's flex: 1 distribution, each match slot naturally occupies an equal vertical space. Since a winner bracket spans exactly two feeder matches, the feeder slots align at 25% and 75% of the parent slot, while the winner sits at 50%. Connector lines can be drawn using absolutely positioned <div> elements anchored to these exact percentages. The result is a layout that requires zero JavaScript calculation, renders correctly on the first server pass, and behaves identically across Chromium, Firefox, and WebKit.
Core Solution
Building a WebKit-safe bracket requires shifting from coordinate-based positioning to percentage-based flex distribution. The implementation follows a strict separation of concerns: layout logic handles spacing and connectors, while render props delegate match card styling to the consuming application.
Step-by-Step Implementation
- Define the Data Contract: Establish a type-safe structure for rounds and matches. Each round contains an array of matches, and matches reference teams, scores, and progression state.
- Create the Flex Container: Each round renders as a flex column. Setting
flex: 1on match containers ensures equal vertical distribution regardless of content height. - Calculate Connector Offsets: Instead of pixel coordinates, use percentage-based positioning. The top connector spans from the feeder match to the horizontal bridge. The horizontal bridge spans between the two feeders. The bottom connector drops to the winner.
- Implement Headless Rendering: Expose
renderMatchandrenderHeaderprops. The layout component never injects visual styles into the match card, preventing design system conflicts. - Apply CSS Variables for Theming: Expose connector color, width, and border radius via CSS custom properties scoped to the bracket root.
Architecture Rationale
- Why Flexbox over Grid? Tournament brackets are hierarchical trees with dynamic row counts per round. Flexbox's
flex: 1naturally handles variable round sizes without requiring explicitgrid-template-rowscalculations. - Why Absolute Positioning for Connectors? Connectors must bridge specific percentage points without disrupting the flex flow. Absolute positioning removes them from the document flow while allowing precise
top,left, andrightpercentage alignment. - Why Headless? Visual styling varies wildly across applications. By owning only layout and connectors, the component remains framework-agnostic and integrates cleanly with Tailwind, CSS modules, or design token systems.
TypeScript Implementation
import React, { type ReactNode } from "react";
export interface MatchData {
id: string;
homeTeam: string;
awayTeam: string;
homeScore?: number | null;
awayScore?: number | null;
isCompleted?: boolean;
}
export interface RoundConfig {
id: string;
label: string;
matches: MatchData[];
}
interface BracketProps {
rounds: RoundConfig[];
renderHeader: (round: RoundConfig) => ReactNode;
renderMatch: (match: MatchData) => ReactNode;
}
export const TournamentBracket: React.FC<BracketProps> = ({
rounds,
renderHeader,
renderMatch,
}) => {
return (
<div className="bracket-root" data-bracket-root>
{rounds.map((round, roundIndex) => (
<div key={round.id} className="bracket-round" data-round-id={round.id}>
<div className="round-header">{renderHeader(round)}</div>
<div className="round-matches">
{round.matches.map((match, matchIndex) => (
<div key={match.id} className="match-slot" data-match-id={match.id}>
{renderMatch(match)}
{roundIndex < rounds.length - 1 && (
<Connector
isLastInRound={matchIndex === round.matches.length - 1}
/>
)}
</div>
))}
</div>
</div>
))}
</div>
);
};
interface ConnectorProps {
isLastInRound: boolean;
}
const Connector: React.FC<ConnectorProps> = ({ isLastInRound }) => {
return (
<div className="connector-wrapper" aria-hidden="true">
<div className="connector-top" />
{!isLastInRound && <div className="connector-horizontal" />}
<div className="connector-bottom" />
</div>
);
};
The layout relies on CSS to handle the mathematical alignment. The match-slot uses flex: 1 1 0% to guarantee equal spacing. Connectors use position: absolute with top: 50% and percentage-based horizontal offsets to bridge slots precisely at the 25/50/75% marks.
Pitfall Guide
1. The SVG Transform Trap
Explanation: Developers attempt to patch WebKit by wrapping <foreignObject> in <g transform="translate(x,y)"> or applying CSS transform: translate(). WebKit ignores ancestor transforms for foreignObject positioning, making this approach futile.
Fix: Remove SVG entirely. Switch to a flexbox + absolute positioning model that does not rely on coordinate attributes.
2. Hardcoded Pixel Heights
Explanation: Assuming match cards have fixed heights causes misalignment when content varies (e.g., long team names, multi-line scores). Flex containers will stretch unevenly, breaking connector alignment.
Fix: Use flex: 1 1 0% on match slots and allow content to dictate height. Set min-height: 0 on flex children to prevent overflow clipping.
3. Connector Overflow Clipping
Explanation: Absolute positioned connector lines get clipped when parent containers use overflow: hidden or overflow: auto. This severs the visual tree and breaks bracket readability.
Fix: Apply overflow: visible to round containers. If horizontal scrolling is required, wrap the entire bracket in a scroll container rather than individual rounds.
4. CSS Variable Scoping Conflicts
Explanation: Using global CSS variables for connector colors causes bleeding when multiple brackets render on the same page. Theme changes in one bracket affect all others.
Fix: Scope variables to the bracket root using [data-bracket-root] or CSS modules. Pass theme tokens via props if dynamic switching is required.
5. Ignoring Flex Basis on Containers
Explanation: Forgetting to set flex-basis: 0 or min-height: 0 on flex children causes the browser to calculate sizes based on content, leading to uneven slot distribution and connector misalignment.
Fix: Explicitly declare flex: 1 1 0% on all match slots and round containers to enforce equal distribution regardless of content size.
6. Dynamic Score Updates Breaking Layout
Explanation: Re-rendering scores triggers flex recalculation, causing layout thrashing and visual flicker during live updates.
Fix: Reserve fixed space for score containers using min-height or line-height. Use CSS transitions on height changes instead of relying on flex recalculation.
7. Assuming Universal 2:1 Ratio
Explanation: The 25/50/75% connector math assumes single-elimination brackets. Double-elimination, round-robin, or Swiss formats break this alignment, causing connectors to overlap or misfire. Fix: Detect bracket type at initialization. Fall back to a CSS grid or canvas-based renderer for non-tree formats, or implement a dynamic connector routing engine.
Production Bundle
Action Checklist
- Audit existing bracket libraries for
<foreignObject>usage and WebKit compatibility reports - Replace SVG layout with flexbox columns and percentage-based absolute connectors
- Implement headless render props to decouple layout from visual styling
- Scope CSS variables to bracket root to prevent theme bleeding across instances
- Test rendering on iOS Safari, WKWebView, and Capacitor/Cordova wrappers
- Verify SSR output matches client hydration without layout shift
- Add
min-height: 0andflex: 1 1 0%to all flex children to prevent overflow clipping - Implement fallback routing for double-elimination or non-tree bracket formats
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Single-elimination tournament | Pure CSS Flexbox | Native 25/50/75% alignment, zero JS measurement, SSR-safe | Low (minimal bundle, fast render) |
| Double-elimination / Swiss | CSS Grid or Canvas | 2:1 ratio math breaks; requires dynamic routing or pixel-perfect control | Medium (higher complexity, larger bundle) |
| Mobile-heavy / Capacitor app | Pure CSS Flexbox | WebKit compatibility guaranteed, no foreignObject collapse | Low (reduces crash reports, improves UX) |
| Design system integration | Headless Flexbox + CSS vars | Decouples layout from tokens, allows Tailwind/shadcn compatibility | Low (maintainable, theme-agnostic) |
| Real-time score updates | Flexbox + CSS transitions | Prevents layout thrashing, maintains connector alignment during hydration | Low (smooth UX, no flicker) |
Configuration Template
/* Base bracket layout & connector system */
[data-bracket-root] {
--bracket-connector-color: #cbd5e1;
--bracket-connector-width: 2px;
--bracket-connector-radius: 4px;
--bracket-slot-gap: 1.5rem;
display: flex;
flex-direction: row;
gap: var(--bracket-slot-gap);
padding: 1rem;
overflow-x: auto;
}
.bracket-round {
display: flex;
flex-direction: column;
min-width: 200px;
}
.round-matches {
display: flex;
flex-direction: column;
flex: 1;
gap: 0;
}
.match-slot {
flex: 1 1 0%;
display: flex;
align-items: center;
position: relative;
min-height: 0;
}
.connector-wrapper {
position: absolute;
right: calc(var(--bracket-slot-gap) * -0.5);
top: 50%;
width: var(--bracket-slot-gap);
height: 100%;
pointer-events: none;
}
.connector-top,
.connector-bottom {
position: absolute;
width: var(--bracket-connector-width);
background-color: var(--bracket-connector-color);
left: 0;
}
.connector-top {
top: 0;
height: 50%;
border-radius: 0 0 var(--bracket-connector-radius) 0;
}
.connector-bottom {
bottom: 0;
height: 50%;
border-radius: 0 var(--bracket-connector-radius) 0 0;
}
.connector-horizontal {
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: var(--bracket-connector-width);
background-color: var(--bracket-connector-color);
transform: translateY(-50%);
}
Quick Start Guide
- Install or scaffold the layout component: Copy the
TournamentBracketandConnectorcomponents into your project. No external dependencies are required. - Define your bracket data: Structure rounds and matches using the
RoundConfigandMatchDatainterfaces. Ensure each round contains half the matches of the previous round. - Wire up render props: Pass
renderHeaderandrenderMatchfunctions to inject your design system's card components. Keep match cards lightweight to avoid flex recalculation overhead. - Apply base CSS: Paste the configuration template into your global stylesheet or CSS module. Adjust
--bracket-connector-colorand--bracket-slot-gapto match your theme. - Verify cross-browser rendering: Open the bracket in Chrome, Firefox, and Safari/iOS. Confirm that connectors align at 25/50/75% and no content collapses to
(0,0).
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
