You Might Also Like
Tournament Match Bracket — Free Single-Elimination Bracket HTML CSS JS
Tournament Match Bracket · Layouts · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Tournament Match Bracket — Connector Lines Computed From Real Layout, Not Guessed

Most CSS-only tournament brackets fake their connector lines with fixed-height pseudo-elements that only line up if every match box happens to be exactly the same height and every round's spacing math works out by coincidence. This snippet takes a more reliable approach: it renders three rounds of matches with flexbox, then measures their *actual* rendered positions with getBoundingClientRect() and draws SVG paths between them — so the connectors are always correct, in plain HTML, CSS, and vanilla JavaScript.
Winners derived from data, not hardcoded per round
The bracket starts from one array, QF, holding four quarterfinal matches with a winner index each. buildRound() takes the previous round's winners two at a time and builds the next round's matches from them — so SF (semifinals) and FINAL are *computed*, not separately authored, guaranteeing the bracket is always internally consistent: whoever the data says won a quarterfinal is exactly who appears in the semifinal slot that quarterfinal feeds.
Layout with flexbox, connectors with measured SVG
Each round is a flex column with justify-content: space-around, which gets matches visually close to their correct positions cheaply. But rather than trust that spacing to be pixel-exact — which breaks the moment match card heights vary, text wraps differently, or the browser rounds subpixel values differently — drawConnectors() reads every match element's real getBoundingClientRect() after render and draws an SVG path from the vertical center of each feeding match's right edge to the vertical center of the match it feeds into's left edge, with a horizontal-vertical-horizontal "elbow" shape (M x y H midX V targetY H targetX).
Why this connector logic is provably correct
Match i in round r always feeds match Math.floor(i / 2) in round r + 1 — that's the fixed mathematical structure of single elimination, and it's exactly the index math drawConnectors() uses to find each source match's target element. Because the coordinates come from the live DOM rather than assumed CSS math, the lines connect correctly even if you change match card height, font size, or round spacing — there's no magic number to keep in sync.
Redraws when the layout can change
The initial draw runs inside requestAnimationFrame so it measures positions only after the browser has committed layout, and a resize listener redraws the connectors whenever the viewport changes — since flex spacing (and therefore every match's pixel position) shifts with the container width.
Overflow-safe names, clear winner state
Long player names truncate with an ellipsis inside each slot, and the winning competitor in each match gets a distinct background, bold weight, and a checkmark — with the champion's final-round win additionally accented gold via a :nth-child selector on the last round.
Customizing it
Extend ROUNDS/ROUND_IDS to support 16 or 32 players (the connector math needs no changes — it already generalizes to any number of rounds), swap the winner-selection logic for real live results, or animate each connector path drawing in with stroke-dasharray. Pair it with a leaderboard table for a seeding list alongside the bracket.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Rather than debugging misaligned bracket lines by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why drawConnectors() measures real getBoundingClientRect() positions instead of relying on CSS spacing to line matches up, and how the Math.floor(i / 2) feed-index math generalizes to brackets with more rounds. The same assistant can help optimize it — for example asking whether redrawing on every resize event should be throttled/debounced for very rapid window resizing, or whether a ResizeObserver on the bracket container would be more precise than a window resize listener. It's also useful for extending the bracket: ask it to animate each connector path drawing in with stroke-dasharray as results come in, add support for a third-place consolation match, or handle byes for a non-power-of-two number of competitors. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a single-elimination "tournament match bracket" for 8 competitors (quarterfinals, semifinals, final — 3 rounds) in plain HTML, CSS, and JavaScript with no library.
Requirements:
- A base data array of 4 quarterfinal match objects, each holding two competitor names and a winner index (0 or 1); a function that takes any round's array of matches and builds the next round's matches by pairing up consecutive matches' winners, so the semifinal and final rounds are computed from the quarterfinal data rather than being separately hand-authored (this must guarantee the bracket can never show an inconsistent state where a displayed semifinalist did not actually win their quarterfinal).
- Three round columns laid out with CSS flexbox, each containing that round's match cards, with the currently winning competitor in each match visually distinguished from the loser (different background, font weight, and a checkmark or similar indicator).
- Connector lines between rounds that are computed from the real rendered positions of the match elements (using getBoundingClientRect on the actual DOM, not fixed pixel offsets or percentage-based CSS pseudo-elements assumed to line up), drawn as an SVG overlay positioned absolutely over the bracket container.
- The connector logic must correctly implement the feed relationship where match index i in one round connects to match index Math.floor(i / 2) in the next round, drawing an elbow-shaped path (horizontal, then vertical, then horizontal) from the vertical center of the source match's right edge to the vertical center of the target match's left edge.
- The initial connector drawing must happen only after the browser has committed layout (for example via requestAnimationFrame), and connectors must be redrawn whenever the window is resized, since the flexbox round columns reposition their match cards as available width changes.
- Long competitor names must truncate with an ellipsis rather than breaking a match card's fixed-width layout.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Paste HTML, CSS, and JSAn 8-player bracket renders across three rounds with winners already highlighted.
- 2Trace a connectorFollow a line from a quarterfinal match's winner into its semifinal slot — it lines up exactly.
- 3Resize the browser windowConnector lines redraw to stay attached to their matches at the new layout.
- 4Edit the QF arrayChange player names or winner indexes — SF and FINAL recompute automatically.
- 5Extend to 16 playersAdd a round and a corresponding round element/ID; the connector loop generalizes automatically.
- 6Wire in live resultsUpdate a match's winner index as real results come in, then re-render and redraw.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Instead of relying on CSS spacing math to happen to line up, drawConnectors() calls getBoundingClientRect() on every match element after the browser has committed layout, and draws each SVG path between the real measured center-points of a feeding match and the match it advances into. Because the coordinates come from the live rendered DOM rather than an assumed formula, the lines are correct regardless of match card height, font size, or how the browser rounds subpixel flex positions.
Match index i in any round always feeds match Math.floor(i / 2) in the next round — that is the fixed structure of single-elimination brackets (matches 0 and 1 feed slot 0, matches 2 and 3 feed slot 1, and so on). The drawConnectors() loop uses exactly that formula to look up each target match element, so the feed relationship is derived from index math rather than any separately maintained mapping.
buildRound() takes a round\'s matches two at a time and creates the next round\'s match from each pair\'s winner, so SF and FINAL are always internally consistent with QF — there is no way for the semifinal bracket to show a player who did not actually win their quarterfinal, because the data literally cannot represent that state.
Each round is a flexbox column using justify-content: space-around at a fixed width, so match card positions shift horizontally and vertically whenever the viewport (and therefore the flex container\'s available space) changes. Because the SVG lines are drawn from measured pixel coordinates rather than percentages, they need to be recalculated after any layout change, which is why a resize listener re-runs drawConnectors().
Add more matches to the first round\'s data array, call buildRound() one more time to generate the additional round, add a matching round container element and ID to the ROUNDS/ROUND_IDS arrays, and add a CSS column for it. The connector-drawing loop already iterates over ROUNDS.length - 1 pairs of adjacent rounds and uses the generalized floor(i / 2) feed index, so no changes to drawConnectors() itself are needed.