Skip to main content
Tournament brackets in 5Stack are interactive, real-time visualizations that update automatically as matches complete. This guide covers the bracket system, navigation features, and how to use them effectively.

Bracket Types

Different tournament formats use different bracket visualizations:
Displays teams grouped by win-loss records in a column-based layout.Features:
  • Record pools (0-0, 1-0, 0-1, 1-1, etc.)
  • Advancement/elimination tracking
  • Final results column

Interactive Navigation

Pan and Zoom

All brackets support interactive navigation:
1

Pan (Drag)

Click and drag anywhere on the bracket to pan.
  • Mouse: Click and drag
  • Touch: Touch and drag
  • Momentum: Release while moving for inertia scrolling
2

Zoom

Use zoom controls or keyboard shortcuts:
  • Zoom In: Click + button or Ctrl/Cmd + Scroll Up
  • Zoom Out: Click - button or Ctrl/Cmd + Scroll Down
  • Reset: Click percentage button to return to 75% zoom
  • Range: 50% to 300% zoom
3

Fullscreen

Click the fullscreen button for immersive view:
  • Expands bracket to full screen
  • All navigation features remain available
  • Press Esc or click minimize to exit

Minimap Navigation

For large brackets (>4 rounds), a minimap appears in the bottom-right:
<template>
  <div class="minimap-container">
    <!-- Minimap preview showing bracket structure -->
    <div class="minimap-preview">
      <div v-for="round in rounds" class="minimap-column">
        <div v-for="match in round" class="minimap-match" />
      </div>
    </div>
    
    <!-- Blue viewport indicator -->
    <div class="viewport-indicator" />
  </div>
</template>
Minimap Features:
  • Shows entire bracket structure
  • Blue rectangle indicates current viewport
  • Click/drag to navigate quickly
  • Auto-hides when not needed
  • Opacity increases on hover

Bracket Components

Match Cards

Each match is displayed as an interactive card:
interface MatchCard {
  bracket_id: string;
  round: number;
  match_number?: number;
  
  // Teams
  team_1?: {
    name: string;
    seed?: number;
  };
  team_2?: {
    name: string;
    seed?: number;
  };
  
  // Match details
  match?: {
    id: string;
    status: string;
    score: {
      team_1: number;
      team_2: number;
    };
  };
  
  // Scheduling
  scheduled_at?: string;
  scheduled_eta?: string;
}

Match States

Matches display different states:
Teams not yet determined. Shows placeholder “TBD” text.
<div class="match-team tbd">
  <span class="text-muted">TBD</span>
</div>
Teams assigned, match not started. Shows team names and scheduled time.
<div class="match-scheduled">
  <div class="teams">
    <span>Team A</span>
    <span>vs</span>
    <span>Team B</span>
  </div>
  <span class="schedule-time">2h 30m</span>
</div>
Match in progress. Shows current map scores and status.
<div class="match-live">
  <div class="team winning">Team A (2)</div>
  <div class="team">Team B (1)</div>
  <Badge variant="destructive">LIVE</Badge>
</div>
Match complete. Shows final score and winner highlight.
<div class="match-finished">
  <div class="team winner">Team A (2)</div>
  <div class="team loser">Team B (1)</div>
</div>
Team receives automatic advancement. Shows single team.
<div class="match-bye">
  <div class="team">Team A</div>
  <span class="bye-indicator">BYE</span>
</div>

Elimination Bracket Display

Single and Double Elimination brackets use a tree structure:

Round Structure

<template>
  <div class="bracket-content">
    <div v-for="round in rounds" class="bracket-column">
      <!-- Round Label -->
      <div class="round-label">
        <Badge>{{ getRoundLabel(round) }}</Badge>
      </div>
      
      <!-- Matches in this round -->
      <div class="round-matches">
        <TournamentMatch
          v-for="bracket in getRoundBrackets(round)"
          :key="bracket.id"
          :bracket="bracket"
        />
      </div>
    </div>
  </div>
</template>

Connection Lines

SVG lines show progression between rounds:
function drawConnectingLines() {
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  
  brackets.forEach(bracket => {
    // Draw line to parent bracket (winner advances)
    if (bracket.parent_bracket) {
      drawLine(svg, bracket.id, bracket.parent_bracket.id, 'winner');
    }
    
    // Draw line to loser bracket (loser drops) - Double Elim only
    if (bracket.loser_bracket) {
      drawLine(svg, bracket.id, bracket.loser_bracket.id, 'loser');
    }
  });
  
  return svg;
}

function drawLine(
  svg: SVGElement,
  sourceId: string,
  targetId: string,
  type: 'winner' | 'loser'
) {
  const sourceEl = document.getElementById(`bracket-${sourceId}`);
  const targetEl = document.getElementById(`bracket-${targetId}`);
  
  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  path.setAttribute('stroke', type === 'winner' ? 'white' : 'rgba(255, 100, 100, 0.7)');
  path.setAttribute('stroke-width', '2');
  path.setAttribute('fill', 'none');
  
  // Calculate path with proper offsets
  const d = calculatePath(sourceEl, targetEl, type);
  path.setAttribute('d', d);
  
  svg.appendChild(path);
}
Line Colors:
  • White: Winner advancement paths
  • Red: Loser drops to lower bracket (Double Elim)

Round Labels

Rounds are labeled contextually:
function getRoundLabel(
  round: number,
  matchCount: number,
  isLoserBracket: boolean,
  isFinal: boolean
): string {
  if (isFinal) {
    return isLoserBracket ? 'Losers Final' : 'Grand Final';
  }
  
  if (isLoserBracket) {
    return `Losers Round ${round}`;
  }
  
  // Named rounds based on remaining teams
  if (matchCount === 1) return 'Grand Final';
  if (matchCount === 2) return 'Semifinals';
  if (matchCount === 4) return 'Quarterfinals';
  
  return `Round ${round}`;
}

Swiss Bracket Display

Swiss format uses a unique column-based display:

Record Pool Layout

<template>
  <div class="swiss-bracket">
    <!-- Round columns -->
    <div v-for="roundData in roundsData" class="round-column">
      <div class="round-label">Round {{ roundData.round }}</div>
      
      <!-- Advanced teams pool (rounds 4+) -->
      <div v-if="roundData.round >= 4" class="advanced-pool">
        <Badge variant="success">ADVANCED</Badge>
        <div v-for="team in getAdvancedTeams(roundData.round)" class="team">
          {{ team.name }} ({{ team.wins }}-{{ team.losses }})
        </div>
      </div>
      
      <!-- Record pools -->
      <div v-for="pool in roundData.pools" class="record-pool">
        <!-- Record label (e.g., "2-1") -->
        <Badge>{{ pool.record }}</Badge>
        
        <!-- Matches in this pool -->
        <TournamentMatch
          v-for="bracket in pool.brackets"
          :bracket="bracket"
        />
      </div>
      
      <!-- Eliminated teams pool (rounds 4+) -->
      <div v-if="roundData.round >= 4" class="eliminated-pool">
        <Badge variant="destructive">ELIMINATED</Badge>
        <div v-for="team in getEliminatedTeams(roundData.round)" class="team">
          {{ team.name }} ({{ team.wins }}-{{ team.losses }})
        </div>
      </div>
    </div>
    
    <!-- Final results column -->
    <div class="final-results-column">
      <div class="round-label">Final Results</div>
      
      <div class="advanced-pool">
        <Badge variant="success">ADVANCED</Badge>
        <div v-for="team in finalAdvancedTeams" class="team">
          {{ team.name }} (3-X)
        </div>
      </div>
      
      <div class="eliminated-pool">
        <Badge variant="destructive">ELIMINATED</Badge>
        <div v-for="team in finalEliminatedTeams" class="team">
          {{ team.name }} (X-3)
        </div>
      </div>
    </div>
  </div>
</template>

Record Tracking

Swiss brackets track team records in real-time:
function calculateTeamRecords(brackets: Bracket[]): Map<string, TeamRecord> {
  const records = new Map<string, TeamRecord>();
  
  // Sort brackets by round to process in order
  const sortedBrackets = brackets.sort((a, b) => a.round - b.round);
  
  sortedBrackets.forEach(bracket => {
    if (bracket.match?.status === 'Finished') {
      const winningLineupId = bracket.match.winning_lineup_id;
      
      // Update team 1 record
      if (bracket.team_1) {
        const record = records.get(bracket.team_1.id) || { wins: 0, losses: 0 };
        if (winningLineupId === bracket.match.lineup_1_id) {
          record.wins++;
        } else {
          record.losses++;
        }
        records.set(bracket.team_1.id, record);
      }
      
      // Update team 2 record
      if (bracket.team_2) {
        const record = records.get(bracket.team_2.id) || { wins: 0, losses: 0 };
        if (winningLineupId === bracket.match.lineup_2_id) {
          record.wins++;
        } else {
          record.losses++;
        }
        records.set(bracket.team_2.id, record);
      }
    }
  });
  
  return records;
}

Pool Sorting

Record pools are sorted for clear progression:
// Sort pools: highest wins on top, then lowest losses
const sortedPools = pools.sort((a, b) => {
  // First by wins (descending)
  if (b.wins !== a.wins) {
    return b.wins - a.wins;
  }
  // Then by losses (ascending)
  return a.losses - b.losses;
});

// Example ordering:
// 2-0 (top)
// 2-1
// 1-1
// 1-2
// 0-2 (bottom)

Round Robin Display

Round Robin uses a table instead of bracket visualization:
<template>
  <Card>
    <CardHeader>
      <CardTitle>{{ stage.groups > 1 ? `Group ${groupLetter}` : 'Standings' }}</CardTitle>
    </CardHeader>
    <CardContent>
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Team</TableHead>
            <TableHead class="text-center">Games Played</TableHead>
            <TableHead class="text-center">Wins</TableHead>
            <TableHead class="text-center">Losses</TableHead>
            <TableHead class="text-center">Rounds Won</TableHead>
            <TableHead class="text-center">Rounds Lost</TableHead>
            <TableHead class="text-center">Matches Remaining</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          <TableRow v-for="result in sortedResults" :key="result.team.id">
            <TableCell>
              <div class="team-info">
                <span class="font-medium">{{ result.team.name }}</span>
                <div class="roster">
                  <PlayerDisplay
                    v-for="player in result.team.roster"
                    :player="player"
                    :show-flag="true"
                  />
                </div>
              </div>
            </TableCell>
            <TableCell class="text-center">{{ result.games_played }}</TableCell>
            <TableCell class="text-center">{{ result.wins }}</TableCell>
            <TableCell class="text-center">{{ result.losses }}</TableCell>
            <TableCell class="text-center">{{ result.rounds_won }}</TableCell>
            <TableCell class="text-center">{{ result.rounds_lost }}</TableCell>
            <TableCell class="text-center">{{ result.matches_remaining }}</TableCell>
          </TableRow>
        </TableBody>
      </Table>
    </CardContent>
  </Card>
</template>
Sorting Priority:
  1. Match wins (primary)
  2. Round differential (tiebreaker)
  3. Head-to-head record (tiebreaker)

Match Scheduling

Organizers can schedule bracket matches:
1

Click Match Card

Click any unscheduled match in the bracket to open scheduling dialog.
2

Set Schedule Time

Choose date and time for match to begin.
<FormField name="scheduled_at">
  <Calendar v-model="scheduleDate" />
  <Input type="time" v-model="scheduleTime" />
</FormField>
3

Save Schedule

Match is marked as scheduled and displays countdown.

Schedule Display

Scheduled matches show time until start:
<template>
  <div v-if="bracket.scheduled_at" class="match-schedule">
    <Clock class="h-3 w-3" />
    <span>{{ getTimeUntil(bracket.scheduled_at) }}</span>
  </div>
</template>

<script>
function getTimeUntil(scheduledAt: string): string {
  const now = new Date();
  const scheduled = new Date(scheduledAt);
  const diff = scheduled.getTime() - now.getTime();
  
  if (diff < 0) return 'Starting soon';
  
  const hours = Math.floor(diff / (1000 * 60 * 60));
  const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
  
  if (hours > 0) return `${hours}h ${minutes}m`;
  return `${minutes}m`;
}
</script>

Real-Time Updates

Brackets update automatically via GraphQL subscriptions:
const TOURNAMENT_SUBSCRIPTION = gql`
  subscription WatchTournament($tournamentId: uuid!) {
    tournaments_by_pk(id: $tournamentId) {
      stages {
        brackets {
          id
          round
          team_1 { id name }
          team_2 { id name }
          match {
            id
            status
            lineup_1_score
            lineup_2_score
            winning_lineup_id
          }
        }
      }
    }
  }
`;
Update Triggers:
  • Match status changes (Scheduled → Live → Finished)
  • Score updates during live matches
  • Team assignments when matches complete
  • Bracket generation for next rounds

Bracket Redraw

Connection lines are redrawn when bracket updates:
watch(
  () => props.brackets,
  () => {
    nextTick(() => {
      clearConnectingLines();
      requestAnimationFrame(() => {
        drawConnectingLines();
        updateMinimap();
      });
    });
  },
  { deep: true }
);

Zoom and Pan Implementation

The bracket viewer uses CSS transforms for smooth navigation:
<template>
  <div
    class="bracket-container"
    @mousedown="onBracketPointerDown"
    @wheel="handleWheel"
    ref="bracketContainer"
  >
    <div
      class="bracket-content-wrapper"
      :style="{
        transform: `scale(${zoomLevel})`,
        transformOrigin: 'top left'
      }"
    >
      <!-- Bracket content -->
    </div>
  </div>
</template>

<script setup>
const zoomLevel = ref(0.75);
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 3.0;
const ZOOM_STEP = 0.1;

function zoomIn() {
  zoomLevel.value = Math.min(MAX_ZOOM, zoomLevel.value + ZOOM_STEP);
  redrawLines();
}

function zoomOut() {
  zoomLevel.value = Math.max(MIN_ZOOM, zoomLevel.value - ZOOM_STEP);
  redrawLines();
}

function handleWheel(e: WheelEvent) {
  if (e.ctrlKey || e.metaKey) {
    e.preventDefault();
    if (e.deltaY < 0) zoomIn();
    else zoomOut();
  }
}
</script>

Momentum Scrolling

Drag gestures include momentum for smooth scrolling:
let lastPositions: { x: number; y: number; t: number }[] = [];
let momentumVelocity = { x: 0, y: 0 };
const MOMENTUM_DECAY = 0.95;
const MOMENTUM_MIN_VELOCITY = 0.5;

function onBracketPointerMove(e: MouseEvent) {
  // Track positions for velocity calculation
  lastPositions.push({ 
    x: e.clientX, 
    y: e.clientY, 
    t: Date.now() 
  });
  if (lastPositions.length > 5) lastPositions.shift();
}

function onBracketPointerUp() {
  // Calculate velocity from last positions
  if (lastPositions.length >= 2) {
    const last = lastPositions[lastPositions.length - 1];
    const first = lastPositions[0];
    const dt = last.t - first.t || 1;
    
    momentumVelocity.x = ((last.x - first.x) / dt) * -1;
    momentumVelocity.y = ((last.y - first.y) / dt) * -1;
    
    startMomentumScroll();
  }
}

function startMomentumScroll() {
  function step() {
    scrollLeft += momentumVelocity.x * 16;
    scrollTop += momentumVelocity.y * 16;
    
    momentumVelocity.x *= MOMENTUM_DECAY;
    momentumVelocity.y *= MOMENTUM_DECAY;
    
    if (
      Math.abs(momentumVelocity.x) > MOMENTUM_MIN_VELOCITY ||
      Math.abs(momentumVelocity.y) > MOMENTUM_MIN_VELOCITY
    ) {
      requestAnimationFrame(step);
    }
  }
  step();
}

Performance Optimization

Virtualization

For very large brackets, consider viewport culling:
function getVisibleMatches(viewport: Rect, allMatches: Bracket[]): Bracket[] {
  return allMatches.filter(match => {
    const matchEl = document.getElementById(`bracket-${match.id}`);
    if (!matchEl) return false;
    
    const rect = matchEl.getBoundingClientRect();
    return (
      rect.right > viewport.left &&
      rect.left < viewport.right &&
      rect.bottom > viewport.top &&
      rect.top < viewport.bottom
    );
  });
}

Debounced Updates

Debounce expensive operations:
import { debounce } from 'lodash-es';

const updateMinimap = debounce(() => {
  // Update minimap preview
}, 100);

const redrawLines = debounce(() => {
  clearConnectingLines();
  drawConnectingLines();
}, 50);

Accessibility

Keyboard Navigation

Screen Reader Support

Add ARIA labels for accessibility:
<template>
  <div 
    role="application"
    aria-label="Tournament Bracket"
    aria-describedby="bracket-instructions"
  >
    <div id="bracket-instructions" class="sr-only">
      Navigate the tournament bracket by clicking and dragging. 
      Use zoom controls or Ctrl+Scroll to zoom in and out.
    </div>
    
    <div 
      v-for="bracket in brackets"
      :key="bracket.id"
      role="article"
      :aria-label="getMatchLabel(bracket)"
    >
      <!-- Match content -->
    </div>
  </div>
</template>

Next Steps

Managing Tournaments

Learn tournament administration and match management

Tournament Formats

Understand different bracket formats in detail

Match Scheduling

Schedule and manage tournament matches

Team Management

Add, remove, and seed teams

Build docs developers (and LLMs) love