Examples.

17 screens from the Expo app in the repository, recorded on an iPhone, with all of their code. To run them, clone the repository and run bun run ios in examples/native.

Showcases

Best-of-three series in the teams' colours. Open a team for its roster and the bracket makes room, play a series map by map, or play the whole bracket and crown a champion.

Esports, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import {
  allMatches,
  Bracket,
  getMatch,
  type Match,
  reportResult,
  type Stage,
  singleElimination,
} from '@react-brackets/native';
import { type ReactNode, useEffect, useState } from 'react';
import { Text, useColorScheme, View } from 'react-native';
import { Pressable } from 'react-native-gesture-handler';
import Animated, { FadeIn } from 'react-native-reanimated';

const roles = ['Top', 'Jungle', 'Mid', 'Bot', 'Support'];

/** Five players in role order, each with their KDA. */
const lineup = (players: string[], kdas: number[]) =>
  players.map((player, index) => ({ player, role: roles[index], kda: kdas[index] ?? 0 }));

/** Listed by seed, so the favourites meet as late as possible. */
const teams = [
  {
    id: 'ember',
    name: 'Ember Kings',
    region: 'KR',
    color: '#f97316',
    roster: lineup(['Haneul', 'Jisoo', 'Raon', 'Dowon', 'Sena'], [3.8, 4.6, 6.1, 5.4, 4.9]),
  },
  {
    id: 'frost',
    name: 'Frostline',
    region: 'EU',
    color: '#38bdf8',
    roster: lineup(['Vesper', 'Kodiak', 'Lumen', 'Arrow', 'Halcyon'], [3.2, 4.1, 5.7, 6.3, 4.4]),
  },
  {
    id: 'crown',
    name: 'Crown Theory',
    region: 'CN',
    color: '#eab308',
    roster: lineup(['Yuheng', 'Shan', 'Lingxi', 'Bai', 'Qiu'], [3.5, 3.9, 5.2, 5.9, 4.1]),
  },
  {
    id: 'phantom',
    name: 'Phantom Unit',
    region: 'NA',
    color: '#a78bfa',
    roster: lineup(['Rook', 'Wisp', 'Cipher', 'Nova', 'Atlas'], [2.9, 3.6, 4.8, 5.1, 3.9]),
  },
  {
    id: 'summit',
    name: 'Summit',
    region: 'EU',
    color: '#34d399',
    roster: lineup(['Brann', 'Fjord', 'Kaito', 'Rune', 'Ilse'], [3.1, 3.3, 4.6, 4.9, 4.2]),
  },
  {
    id: 'tide',
    name: 'Riptide',
    region: 'BR',
    color: '#22d3ee',
    roster: lineup(['Tubarão', 'Mata', 'Raio', 'Flecha', 'Escudo'], [2.7, 3.4, 4.4, 4.7, 3.6]),
  },
  {
    id: 'orbit',
    name: 'Orbit',
    region: 'NA',
    color: '#f472b6',
    roster: lineup(['Comet', 'Drift', 'Quasar', 'Ion', 'Halo'], [2.6, 3.1, 4.2, 4.5, 3.3]),
  },
  {
    id: 'grave',
    name: 'Gravewalkers',
    region: 'VN',
    color: '#94a3b8',
    roster: lineup(['Tuan', 'Bao', 'Khoa', 'Minh', 'Linh'], [2.4, 2.9, 3.8, 4.1, 3.2]),
  },
].map((team, index) => ({ ...team, seed: index + 1 }));

type Team = (typeof teams)[number];

const teamById = new Map(teams.map((team) => [team.id, team]));
const start = singleElimination(teams.map(({ id, name }) => ({ id, name })));

/** Plays one map of a best-of-three. The better seed is a little more likely to take it. */
function playMap(stage: Stage, matchId: string): Stage {
  const match = getMatch(stage, matchId);
  const [first, second] = match.slots.map((slot) => teamById.get(slot.participantId ?? ''));
  const edge = 0.5 + ((second?.seed ?? 0) - (first?.seed ?? 0)) * 0.04;
  const scores: [number, number] = [match.slots[0].score ?? 0, match.slots[1].score ?? 0];
  scores[Math.random() < edge ? 0 : 1] += 1;
  return reportResult(stage, matchId, { scores, live: Math.max(...scores) < 2 });
}

/** Opens or closes one team's roster, as a new set for React state. */
function toggled(open: Set<string>, key: string): Set<string> {
  const next = new Set(open);
  if (next.has(key)) next.delete(key);
  else next.add(key);
  return next;
}

export default function Esports(): ReactNode {
  const dark = useColorScheme() === 'dark';
  const [stage, setStage] = useState(start);
  const [playing, setPlaying] = useState<string | null>(null);
  const [auto, setAuto] = useState(false);
  const [page, setPage] = useState(0);
  // Rosters open, by match and slot, so a team can be open in one round and closed in the next.
  const [open, setOpen] = useState(() => new Set<string>());
  const matches = allMatches(stage);
  const final = matches.at(-1);
  const champion =
    final?.winner != null ? teamById.get(final.slots[final.winner].participantId ?? '') : undefined;

  // A series in progress: one map at a time.
  useEffect(() => {
    if (!playing) return;
    const timer = setTimeout(() => {
      const next = playMap(stage, playing);
      setStage(next);
      if (getMatch(next, playing).status === 'completed') setPlaying(null);
    }, 700);
    return () => clearTimeout(timer);
  }, [stage, playing]);

  // Playing the whole bracket: the next series starts once one ends.
  useEffect(() => {
    if (!auto || playing) return;
    const next = matches.find((match) => match.status === 'ready');
    if (next) setPlaying(next.id);
    else setAuto(false);
  }, [auto, playing, matches]);

  // The view follows the series being played.
  useEffect(() => {
    const index = start.groups[0]?.rounds.findIndex((round) =>
      round.matches.some((match) => match.id === playing),
    );
    if (index !== undefined && index >= 0) setPage(index);
  }, [playing]);

  return (
    <View style={{ gap: 16 }}>
      <View style={{ flexDirection: 'row', gap: 8 }}>
        <Button
          primary
          label="Play all"
          disabled={auto || champion !== undefined}
          onPress={() => setAuto(true)}
        />
        <Button
          label="Replay"
          onPress={() => {
            setStage(start);
            setPlaying(null);
            setAuto(false);
            setPage(0);
            setOpen(new Set());
          }}
        />
      </View>
      {champion && (
        <View
          style={{
            padding: 14,
            borderRadius: 14,
            borderWidth: 1.5,
            borderColor: champion.color,
            backgroundColor: `${champion.color}1f`,
          }}
        >
          <Text style={{ fontSize: 15, color: dark ? '#fafafa' : '#18181b' }}>
            🏆 <Text style={{ fontWeight: '700' }}>{champion.name}</Text> are the Rift Masters
            champions.
          </Text>
        </View>
      )}
      <Bracket
        stage={stage}
        page={page}
        onPageChange={setPage}
        theme={{ radius: 14, connectorRadius: 10, colors: { accent: '#7c3aed' } }}
        renderMatch={(match) => (
          <SeriesCard
            match={match}
            dark={dark}
            open={open}
            onToggle={(key) => setOpen(toggled(open, key))}
            onPlay={() => setPlaying(match.id)}
          />
        )}
      />
    </View>
  );
}

/** A best-of-three: two maps win it. */
function SeriesCard({
  match,
  dark,
  open,
  onToggle,
  onPlay,
}: {
  match: Match;
  dark: boolean;
  open: Set<string>;
  onToggle: (key: string) => void;
  onPlay: () => void;
}): ReactNode {
  const maps = (match.slots[0].score ?? 0) + (match.slots[1].score ?? 0);
  const muted = dark ? '#a1a1aa' : '#71717a';
  return (
    <Bracket.Match
      style={({ live }) => ({
        borderRadius: 14,
        borderWidth: 1.5,
        borderColor: live ? '#7c3aed' : dark ? '#27272a' : '#e4e4e7',
        backgroundColor: dark ? '#111113' : '#ffffff',
      })}
    >
      <View
        style={{
          flexDirection: 'row',
          justifyContent: 'space-between',
          paddingHorizontal: 12,
          paddingTop: 8,
        }}
      >
        <Text style={{ fontSize: 11, color: muted }}>Best of 3</Text>
        {match.status === 'ready' ? (
          <Pressable onPress={onPlay} hitSlop={8}>
            <Text style={{ fontSize: 11, fontWeight: '700', color: '#7c3aed' }}>▶ Play series</Text>
          </Pressable>
        ) : (
          <Text
            style={{
              fontSize: 11,
              fontWeight: match.status === 'live' ? '700' : '400',
              color: match.status === 'live' ? '#7c3aed' : muted,
            }}
          >
            {match.status === 'live'
              ? `Map ${maps + 1}`
              : match.status === 'completed'
                ? 'Final'
                : 'Upcoming'}
          </Text>
        )}
      </View>
      {([0, 1] as const).map((slot) => {
        const team = teamById.get(match.slots[slot].participantId ?? '');
        const key = `${match.id}.${slot}`;
        return (
          <View key={slot}>
            <Bracket.Participant slot={slot} style={{ paddingVertical: 7 }}>
              {({ slot: { score }, loser }) => (
                <>
                  <Text style={{ width: 12, fontSize: 11, color: muted }}>{team?.seed}</Text>
                  <Logo team={team} dark={dark} />
                  <Text
                    numberOfLines={1}
                    style={{
                      flex: 1,
                      fontSize: 14,
                      fontWeight: '600',
                      color: loser || !team ? muted : dark ? '#fafafa' : '#18181b',
                    }}
                  >
                    {team?.name ?? 'To be decided'}
                  </Text>
                  <Maps won={score ?? 0} color={team?.color ?? muted} dark={dark} />
                  {/* Gesture Handler's Pressable, so a tap here wins over the row's own press. */}
                  {team && (
                    <Pressable
                      onPress={() => onToggle(key)}
                      hitSlop={6}
                      accessibilityRole="button"
                      accessibilityLabel={`${team.name} roster`}
                      accessibilityState={{ expanded: open.has(key) }}
                    >
                      <Chevron open={open.has(key)} color={muted} dark={dark} />
                    </Pressable>
                  )}
                </>
              )}
            </Bracket.Participant>
            {team && open.has(key) && <Roster team={team} dark={dark} />}
          </View>
        );
      })}
    </Bracket.Match>
  );
}

function Logo({ team, dark }: { team: Team | undefined; dark: boolean }): ReactNode {
  return (
    <View
      style={{
        width: 22,
        height: 22,
        borderRadius: 7,
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: team ? team.color : dark ? '#27272a' : '#e4e4e7',
      }}
    >
      <Text style={{ fontSize: 11, fontWeight: '800', color: '#ffffff' }}>{team?.name[0]}</Text>
    </View>
  );
}

/** A round button with a drawn chevron that turns to point up while the roster is open. */
function Chevron({
  open,
  color,
  dark,
}: {
  open: boolean;
  color: string;
  dark: boolean;
}): ReactNode {
  return (
    <View
      style={{
        width: 28,
        height: 28,
        borderRadius: 14,
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: open ? (dark ? '#27272a' : '#f4f4f5') : 'transparent',
      }}
    >
      <View
        style={{
          width: 8,
          height: 8,
          marginTop: open ? 3 : -3,
          borderRightWidth: 2,
          borderBottomWidth: 2,
          borderColor: color,
          transform: [{ rotate: open ? '225deg' : '45deg' }],
        }}
      />
    </View>
  );
}

/** Two pips: a filled one for each map won. */
function Maps({ won, color, dark }: { won: number; color: string; dark: boolean }): ReactNode {
  return (
    <View style={{ flexDirection: 'row', gap: 3 }}>
      {[0, 1].map((map) => (
        <View
          key={map}
          style={{
            width: 7,
            height: 7,
            borderRadius: 4,
            backgroundColor: map < won ? color : dark ? '#3f3f46' : '#d4d4d8',
          }}
        />
      ))}
    </View>
  );
}

/** A team's five players, faded in as the card opens; the bracket makes room around it. */
function Roster({ team, dark }: { team: Team; dark: boolean }): ReactNode {
  return (
    <Animated.View
      entering={FadeIn.duration(260)}
      style={{
        marginHorizontal: 12,
        marginBottom: 8,
        paddingVertical: 4,
        borderTopWidth: 1,
        borderColor: dark ? '#27272a' : '#f4f4f5',
      }}
    >
      <Text style={{ paddingVertical: 4, fontSize: 11, color: dark ? '#a1a1aa' : '#71717a' }}>
        From the {team.region} league
      </Text>
      {team.roster.map(({ player, role, kda }) => (
        <View
          key={player}
          style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
        >
          <Text style={{ width: 52, fontSize: 11, color: team.color, fontWeight: '700' }}>
            {role}
          </Text>
          <Text style={{ flex: 1, fontSize: 13, color: dark ? '#e4e4e7' : '#27272a' }}>
            {player}
          </Text>
          <Text
            style={{
              fontSize: 11,
              color: dark ? '#a1a1aa' : '#71717a',
              fontVariant: ['tabular-nums'],
            }}
          >
            {kda.toFixed(1)} KDA
          </Text>
        </View>
      ))}
    </Animated.View>
  );
}

function Button({
  label,
  primary,
  disabled,
  onPress,
}: {
  label: string;
  primary?: boolean;
  disabled?: boolean;
  onPress: () => void;
}): ReactNode {
  return (
    <Pressable
      disabled={disabled}
      onPress={onPress}
      style={{
        paddingHorizontal: 16,
        paddingVertical: 9,
        borderRadius: 999,
        opacity: disabled ? 0.45 : 1,
        backgroundColor: primary ? '#7c3aed' : 'rgba(127, 127, 127, 0.16)',
      }}
    >
      <Text style={{ fontWeight: '600', color: primary ? '#ffffff' : '#7c3aed' }}>{label}</Text>
    </Pressable>
  );
}

Your own cards with flags, seeds, kick-off times and a live match that breathes on the UI thread. Tap a match to see when it kicks off.

Nations, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import {
  Bracket,
  type Match,
  participantMap,
  reportResult,
  type Stage,
  singleElimination,
} from '@react-brackets/native';
import { type ReactNode, useEffect, useState } from 'react';
import { Text, useColorScheme, View } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from 'react-native-reanimated';

const nations = [
  { id: 'bra', name: 'Brazil', flag: '🇧🇷', seed: 1 },
  { id: 'arg', name: 'Argentina', flag: '🇦🇷', seed: 2 },
  { id: 'esp', name: 'Spain', flag: '🇪🇸', seed: 3 },
  { id: 'fra', name: 'France', flag: '🇫🇷', seed: 4 },
  { id: 'jpn', name: 'Japan', flag: '🇯🇵', seed: 5 },
  { id: 'por', name: 'Portugal', flag: '🇵🇹', seed: 6 },
  { id: 'ned', name: 'Netherlands', flag: '🇳🇱', seed: 7 },
  { id: 'mar', name: 'Morocco', flag: '🇲🇦', seed: 8 },
];

const stage: Stage = [
  { match: 'main.r1.m1', scores: [2, 1] as [number, number] },
  { match: 'main.r1.m2', scores: [1, 2] as [number, number] },
  { match: 'main.r1.m3', scores: [3, 1] as [number, number] },
  { match: 'main.r1.m4', scores: [1, 0] as [number, number] },
  { match: 'main.r2.m1', scores: [1, 1] as [number, number], live: true },
].reduce<Stage>(
  (current, { match, scores, live }) => reportResult(current, match, { scores, live }),
  singleElimination(
    nations.map(({ id, name, flag, seed }) => ({ id, name, meta: { flag, seed } })),
  ),
);

const kickoff: Record<string, string> = {
  'main.r1.m1': 'Fri 18:00',
  'main.r1.m2': 'Fri 21:00',
  'main.r1.m3': 'Sat 18:00',
  'main.r1.m4': 'Sat 21:00',
  'main.r2.m1': 'Tue 20:00',
  'main.r2.m2': 'Wed 20:00',
  'main.r3.m1': 'Sun 19:00',
};

export default function Nations(): ReactNode {
  const dark = useColorScheme() === 'dark';
  const [selected, setSelected] = useState<Match | null>(null);
  const names = participantMap(stage);
  const muted = dark ? '#a1a1aa' : '#71717a';

  return (
    <View style={{ gap: 16 }}>
      <Bracket
        stage={stage}
        theme={{ colors: { accent: '#16a34a', accentSoft: 'rgba(22, 163, 74, 0.14)' } }}
        onMatchPress={setSelected}
        renderMatch={(match) => (
          <Card match={match} selected={match.id === selected?.id} dark={dark} />
        )}
      />
      <Text style={{ color: muted, fontSize: 14 }}>
        {selected
          ? `${selected.slots.map((slot) => names.get(slot.participantId ?? '')?.name ?? 'TBD').join(' v ')}, ${kickoff[selected.id]}`
          : 'Tap a match to see when it kicks off.'}
      </Text>
    </View>
  );
}

function Card({ match, selected, dark }: { match: Match; selected: boolean; dark: boolean }) {
  return (
    <Bracket.Match
      style={({ live }) => ({
        borderRadius: 16,
        borderWidth: 1.5,
        borderColor: selected ? '#16a34a' : live ? '#ef4444' : dark ? '#27272a' : '#e4e4e7',
        backgroundColor: live ? (dark ? '#2a1214' : '#fff5f5') : dark ? '#111113' : '#ffffff',
      })}
    >
      <View
        style={{
          flexDirection: 'row',
          justifyContent: 'space-between',
          alignItems: 'center',
          paddingHorizontal: 12,
          paddingTop: 8,
        }}
      >
        <Text style={{ fontSize: 11, color: dark ? '#a1a1aa' : '#71717a' }}>
          {kickoff[match.id]}
        </Text>
        {match.status === 'live' ? (
          <LiveDot />
        ) : (
          <Text style={{ fontSize: 11, color: dark ? '#a1a1aa' : '#71717a' }}>
            {match.status === 'completed' ? 'Full time' : 'Upcoming'}
          </Text>
        )}
      </View>
      {([0, 1] as const).map((slot) => (
        <Bracket.Participant key={slot} slot={slot} style={{ paddingVertical: 7 }}>
          {({ participant, slot: { score }, winner, loser }) => (
            <>
              <Text style={{ width: 12, fontSize: 11, color: '#a1a1aa' }}>
                {String(participant?.meta?.seed ?? '')}
              </Text>
              <Text style={{ fontSize: 17 }}>{String(participant?.meta?.flag ?? '🏳️')}</Text>
              <Text
                numberOfLines={1}
                style={{
                  flex: 1,
                  fontSize: 15,
                  fontWeight: winner ? '700' : '400',
                  color: loser || !participant ? '#a1a1aa' : dark ? '#fafafa' : '#18181b',
                }}
              >
                {participant?.name ?? 'To be decided'}
              </Text>
              <Text
                style={{
                  fontSize: 15,
                  fontWeight: '700',
                  fontVariant: ['tabular-nums'],
                  color: winner ? '#16a34a' : dark ? '#d4d4d8' : '#3f3f46',
                }}
              >
                {score ?? ''}
              </Text>
            </>
          )}
        </Bracket.Participant>
      ))}
    </Bracket.Match>
  );
}

/** A dot that breathes while the match is on, on the UI thread. */
function LiveDot(): ReactNode {
  const pulse = useSharedValue(0);
  useEffect(() => {
    pulse.value = withRepeat(withTiming(1, { duration: 900, easing: Easing.out(Easing.quad) }), -1);
  }, [pulse]);
  const ring = useAnimatedStyle(() => ({
    opacity: 1 - pulse.value,
    transform: [{ scale: 1 + pulse.value * 1.6 }],
  }));
  return (
    <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
      <View style={{ width: 8, height: 8 }}>
        <Animated.View
          style={[
            { position: 'absolute', inset: 0, borderRadius: 4, backgroundColor: '#ef4444' },
            ring,
          ]}
        />
        <View style={{ flex: 1, borderRadius: 4, backgroundColor: '#ef4444' }} />
      </View>
      <Text style={{ fontSize: 11, fontWeight: '700', color: '#ef4444' }}>Live 67'</Text>
    </View>
  );
}

Start

A stage from a list of teams, passed to one component. On a phone it pages through one round at a time.

Quick start, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import { Bracket, reportResult, type Stage, singleElimination } from '@react-brackets/native';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

// The quarterfinals are played and the first semifinal is done.
const results: [string, [number, number]][] = [
  ['main.r1.m1', [3, 1]],
  ['main.r1.m2', [0, 2]],
  ['main.r1.m3', [2, 1]],
  ['main.r1.m4', [1, 3]],
  ['main.r2.m1', [2, 0]],
];

const stage = results.reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

export default function QuickStart() {
  return <Bracket stage={stage} />;
}

Tap a team, or pick one from the chips, to light up its path. Both stay in step through highlighted and onHighlightChange.

Follow a team, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import { Bracket, reportResult, type Stage, singleElimination } from '@react-brackets/native';
import { useState } from 'react';
import { Pressable, ScrollView, Text, View } from 'react-native';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const stage = (
  [
    ['main.r1.m1', [3, 1]],
    ['main.r1.m2', [0, 2]],
    ['main.r1.m3', [2, 1]],
    ['main.r1.m4', [1, 3]],
    ['main.r2.m1', [2, 0]],
    ['main.r2.m2', [1, 2]],
  ] as [string, [number, number]][]
).reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

export default function FollowATeam() {
  const [team, setTeam] = useState<string | null>('Falcons');

  return (
    <View style={{ gap: 16 }}>
      <ScrollView horizontal showsHorizontalScrollIndicator={false}>
        <View style={{ flexDirection: 'row', gap: 8 }}>
          {teams.map((name) => {
            const selected = name === team;
            return (
              <Pressable
                key={name}
                onPress={() => setTeam(selected ? null : name)}
                style={{
                  paddingHorizontal: 14,
                  paddingVertical: 8,
                  borderRadius: 999,
                  backgroundColor: selected ? '#2563eb' : '#e4e4e7',
                }}
              >
                <Text style={{ fontWeight: '600', color: selected ? '#ffffff' : '#27272a' }}>
                  {name}
                </Text>
              </Pressable>
            );
          })}
        </View>
      </ScrollView>
      {/* Controlled: the chips decide whose path lights up, and tapping a team in the bracket does too. */}
      <Bracket stage={stage} highlighted={team} onHighlightChange={setTeam} />
    </View>
  );
}

Formats

The winners bracket with its grand final, and the losers bracket below it, each paged on its own.

Double elimination, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import { Bracket, doubleElimination, reportResult, type Stage } from '@react-brackets/native';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const stage = (
  [
    ['main.wb.r1.m1', [2, 0]],
    ['main.wb.r1.m2', [1, 2]],
    ['main.wb.r1.m3', [2, 1]],
    ['main.wb.r1.m4', [0, 2]],
    ['main.wb.r2.m1', [2, 1]],
    ['main.lb.r1.m1', [2, 0]],
    ['main.lb.r1.m2', [1, 2]],
  ] as [string, [number, number]][]
).reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  doubleElimination(teams.map((name) => ({ id: name, name }))),
);

export default function DoubleElimination() {
  // Every group of the stage, each swiped on its own: the winners bracket runs into the grand final.
  return <Bracket stage={stage} />;
}

Two halves that meet in the final, kept whole on a phone and scrolled sideways at full size.

Two-sided bracket, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import { Bracket, reportResult, type Stage, singleElimination } from '@react-brackets/native';
import type { ReactNode } from 'react';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const stage = (
  [
    ['main.r1.m1', [3, 1]],
    ['main.r1.m2', [0, 2]],
    ['main.r1.m3', [2, 1]],
    ['main.r1.m4', [1, 3]],
    ['main.r2.m1', [2, 0]],
  ] as [string, [number, number]][]
).reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

export default function TwoSided(): ReactNode {
  // The halves meet at the final in the middle. Mirroring is for the whole tree, so it stays whole on a
  // phone too, scrolled sideways at full size.
  return (
    <Bracket
      stage={stage}
      layout="mirrored"
      compactBelow={0}
      theme={{ matchWidth: 150, columnGap: 28 }}
    />
  );
}

Each round is paired from the standings so far. Pair a round, play it, and watch the table move.

Swiss, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import {
  allMatches,
  Bracket,
  nextSwissRound,
  participantMap,
  reportResult,
  type Stage,
  standings,
  swiss,
  swissRoundsLeft,
} from '@react-brackets/native';
import { type ReactNode, useState } from 'react';
import { Pressable, Text, useColorScheme, View } from 'react-native';

const players = ['Ana', 'Ben', 'Chloe', 'Dev', 'Eli', 'Farah', 'Gus', 'Hana', 'Ivo'];

const start = swiss(players.map((name) => ({ id: name, name })));

/** Every open board gets a result, a coin toss each. */
const play = (stage: Stage): Stage =>
  allMatches(stage)
    .filter((match) => match.status === 'ready')
    .reduce(
      (current, match) =>
        reportResult(current, match.id, { scores: Math.random() < 0.5 ? [1, 0] : [0, 1] }),
      stage,
    );

export default function Swiss(): ReactNode {
  const dark = useColorScheme() === 'dark';
  const [stage, setStage] = useState(start);
  const [page, setPage] = useState(0);
  const rounds = stage.groups[0]?.rounds.length ?? 0;
  const open = allMatches(stage).some((match) => match.status === 'ready');
  const left = swissRoundsLeft(stage);
  const finished = !open && left === 0;
  const names = participantMap(stage);
  const text = dark ? '#fafafa' : '#18181b';
  const muted = dark ? '#a1a1aa' : '#71717a';

  const next = () => {
    if (open) return setStage(play(stage));
    setStage(nextSwissRound(stage));
    setPage(rounds);
  };

  return (
    <View style={{ gap: 16 }}>
      <Text style={{ color: muted }}>
        {finished
          ? 'All rounds played.'
          : `Round ${rounds} of ${rounds + left}. Players on the same score meet next.`}
      </Text>
      <View style={{ flexDirection: 'row', gap: 8 }}>
        <Pressable
          disabled={finished}
          onPress={next}
          style={{
            paddingHorizontal: 16,
            paddingVertical: 9,
            borderRadius: 999,
            backgroundColor: '#2563eb',
            opacity: finished ? 0.45 : 1,
          }}
        >
          <Text style={{ fontWeight: '600', color: '#ffffff' }}>
            {finished ? 'Finished' : open ? `Play round ${rounds}` : `Pair round ${rounds + 1}`}
          </Text>
        </Pressable>
        <Pressable
          onPress={() => {
            setStage(start);
            setPage(0);
          }}
          style={{
            paddingHorizontal: 16,
            paddingVertical: 9,
            borderRadius: 999,
            backgroundColor: 'rgba(127, 127, 127, 0.16)',
          }}
        >
          <Text style={{ fontWeight: '600', color: '#2563eb' }}>Reset</Text>
        </Pressable>
      </View>
      <View style={{ gap: 2 }}>
        {standings(stage).map((row, index) => (
          <View
            key={row.participantId}
            style={{ flexDirection: 'row', gap: 12, paddingVertical: 5, alignItems: 'center' }}
          >
            <Text style={{ width: 18, color: muted, fontVariant: ['tabular-nums'] }}>
              {index + 1}
            </Text>
            <Text style={{ flex: 1, color: text, fontWeight: index === 0 ? '700' : '400' }}>
              {names.get(row.participantId)?.name}
            </Text>
            <Text style={{ color: muted, fontVariant: ['tabular-nums'] }}>
              {row.wins}–{row.losses}
            </Text>
          </View>
        ))}
      </View>
      <Bracket stage={stage} compact="list" page={page} onPageChange={setPage} />
    </View>
  );
}

Two round robin groups with their tables, every group's match days, and a knockout with a third-place match.

Groups to playoffs, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import {
  allMatches,
  Bracket,
  type Group,
  participantMap,
  qualifiers,
  reportResult,
  roundRobin,
  type Stage,
  singleElimination,
  standings,
} from '@react-brackets/native';
import { type ReactNode, useState } from 'react';
import { Pressable, Text, useColorScheme, View } from 'react-native';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const draw = roundRobin(
  teams.map((name) => ({ id: name, name })),
  { groups: 2 },
);

const groups = allMatches(draw).reduce<Stage>(
  (current, match, index) =>
    reportResult(current, match.id, { scores: [index % 3, (index + 1) % 3] }),
  draw,
);

// Group winners meet the other group's runner-up in the semifinals.
const playoffs = singleElimination(qualifiers(groups, 2), { id: 'playoffs', thirdPlace: true });

export default function GroupsToPlayoffs(): ReactNode {
  const dark = useColorScheme() === 'dark';
  const [view, setView] = useState<'Groups' | 'Matches' | 'Playoffs'>('Groups');

  return (
    <View style={{ gap: 16 }}>
      <View
        style={{
          flexDirection: 'row',
          alignSelf: 'flex-start',
          padding: 3,
          borderRadius: 999,
          backgroundColor: 'rgba(127, 127, 127, 0.14)',
        }}
      >
        {(['Groups', 'Matches', 'Playoffs'] as const).map((name) => (
          <Pressable
            key={name}
            onPress={() => setView(name)}
            style={{
              paddingHorizontal: 14,
              paddingVertical: 7,
              borderRadius: 999,
              backgroundColor: name === view ? '#ffffff' : 'transparent',
            }}
          >
            <Text style={{ fontWeight: '600', color: name === view ? '#18181b' : '#71717a' }}>
              {name}
            </Text>
          </Pressable>
        ))}
      </View>
      {view === 'Groups' &&
        groups.groups.map((group) => <Table key={group.id} group={group} dark={dark} />)}
      {/* Every group's match days, as lists; a pool has no lines to draw. */}
      {view === 'Matches' && <Bracket stage={groups} compact="list" />}
      {view === 'Playoffs' && <Bracket stage={playoffs} />}
    </View>
  );
}

/** A group's league table. The top two go through. */
function Table({ group, dark }: { group: Group; dark: boolean }): ReactNode {
  const names = participantMap(groups);
  const text = dark ? '#fafafa' : '#18181b';
  const muted = dark ? '#a1a1aa' : '#71717a';
  const cell = { width: 28, textAlign: 'right', fontVariant: ['tabular-nums'] } as const;
  return (
    <View
      style={{
        padding: 14,
        gap: 6,
        borderRadius: 14,
        borderWidth: 1,
        borderColor: dark ? '#27272a' : '#e4e4e7',
        backgroundColor: dark ? '#111113' : '#ffffff',
      }}
    >
      <View style={{ flexDirection: 'row' }}>
        <Text style={{ flex: 1, fontWeight: '700', color: text }}>{group.name}</Text>
        {['W', 'D', 'L', 'Pts'].map((label) => (
          <Text key={label} style={[cell, { fontSize: 12, color: muted }]}>
            {label}
          </Text>
        ))}
      </View>
      {standings(groups, group.id).map((row, index) => (
        <View key={row.participantId} style={{ flexDirection: 'row', alignItems: 'center' }}>
          <View
            style={{
              width: 6,
              height: 6,
              marginEnd: 8,
              borderRadius: 3,
              backgroundColor: index < 2 ? '#16a34a' : 'transparent',
            }}
          />
          <Text style={{ flex: 1, color: index < 2 ? text : muted }}>
            {names.get(row.participantId)?.name}
          </Text>
          {[row.wins, row.draws, row.losses].map((value, column) => (
            // biome-ignore lint/suspicious/noArrayIndexKey: the columns are fixed
            <Text key={column} style={[cell, { color: muted }]}>
              {value}
            </Text>
          ))}
          <Text style={[cell, { fontWeight: '700', color: text }]}>{row.points}</Text>
        </View>
      ))}
    </View>
  );
}

Styling

The bracket follows your Unistyles themes, so it turns light or dark with the rest of your app.

Theme and dark mode, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
npx expo install react-native-unistyles
npx expo install react-native-nitro-modules
npx expo install react-native-edge-to-edge
import { Bracket, reportResult, type Stage, singleElimination } from '@react-brackets/native';
import type { ReactNode } from 'react';
import { Pressable, Text, View } from 'react-native';
import { StyleSheet, UnistylesRuntime, withUnistyles } from 'react-native-unistyles';

// The bracket takes its theme from Unistyles, and follows it when the theme changes.
const ThemedBracket = withUnistyles(Bracket, (theme) => ({
  theme: { colors: theme.bracket, radius: 14 },
}));

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const cup = (
  [
    ['main.r1.m1', [3, 1]],
    ['main.r1.m2', [0, 2]],
    ['main.r1.m3', [2, 1]],
    ['main.r1.m4', [1, 3]],
  ] as [string, [number, number]][]
).reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

export default function Unistyles(): ReactNode {
  return (
    <View style={styles.panel}>
      <View style={styles.header}>
        <Text style={styles.title}>Unistyles</Text>
        <View style={styles.switch}>
          {(['light', 'dark'] as const).map((name) => (
            <Pressable
              key={name}
              accessibilityRole="button"
              onPress={() => {
                UnistylesRuntime.setAdaptiveThemes(false);
                UnistylesRuntime.setTheme(name);
              }}
              style={styles.option}
            >
              <Text style={styles.label}>{name === 'light' ? 'Light' : 'Dark'}</Text>
            </Pressable>
          ))}
        </View>
      </View>
      <ThemedBracket stage={cup} />
    </View>
  );
}

const styles = StyleSheet.create((theme) => ({
  panel: { gap: 16, padding: 16, borderRadius: 20, backgroundColor: theme.colors.background },
  header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
  title: { fontSize: 18, fontWeight: '700', color: theme.colors.text },
  switch: { flexDirection: 'row', gap: 6 },
  option: {
    paddingHorizontal: 12,
    paddingVertical: 6,
    borderRadius: 999,
    backgroundColor: theme.colors.card,
  },
  label: { fontWeight: '600', color: theme.colors.text },
}));

Cards styled with classes through Uniwind, keyed off their state.

Tailwind, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
npx expo install uniwind
npx expo install tailwindcss
import { Bracket, reportResult, type Stage, singleElimination } from '@react-brackets/native';
import type { ReactNode } from 'react';
import { Text, View } from 'react-native';
import { withUniwind } from 'uniwind';

// Library components take a className once wrapped; React Native's own take one as they are.
const Match = withUniwind(Bracket.Match);

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const cup = (
  [
    ['main.r1.m1', [3, 1]],
    ['main.r1.m2', [0, 2]],
    ['main.r1.m3', [2, 1]],
    ['main.r1.m4', [1, 3]],
  ] as [string, [number, number]][]
).reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

export default function Tailwind(): ReactNode {
  return (
    <View className="gap-4 rounded-3xl bg-indigo-50 p-4 dark:bg-indigo-950">
      <Text className="text-lg font-bold text-indigo-950 dark:text-indigo-50">
        Tailwind, through Uniwind
      </Text>
      <Bracket
        stage={cup}
        theme={{ colors: { line: '#a5b4fc', accent: '#4f46e5' } }}
        renderMatch={() => (
          <Match className="rounded-2xl border-indigo-200 bg-white dark:border-indigo-800 dark:bg-indigo-900">
            {([0, 1] as const).map((slot) => (
              <Bracket.Participant
                key={slot}
                slot={slot}
                style={{ paddingHorizontal: 0, paddingVertical: 0 }}
              >
                {({ participant, slot: { score }, winner }) => (
                  <View className="flex-1 flex-row items-center px-3 py-2.5">
                    <Text
                      numberOfLines={1}
                      className={
                        winner
                          ? 'flex-1 font-bold text-indigo-950 dark:text-white'
                          : 'flex-1 text-indigo-900/60 dark:text-indigo-200/60'
                      }
                    >
                      {participant?.name ?? 'To be decided'}
                    </Text>
                    <Text className="font-semibold text-indigo-600 dark:text-indigo-300">
                      {score}
                    </Text>
                  </View>
                )}
              </Bracket.Participant>
            ))}
          </Match>
        )}
      />
    </View>
  );
}

Each round's date and how many of its matches are played, above the round.

Round titles, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import {
  Bracket,
  type Round,
  reportResult,
  type Stage,
  singleElimination,
} from '@react-brackets/native';
import type { ReactNode } from 'react';
import { Text, useColorScheme, View } from 'react-native';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const stage = (
  [
    ['main.r1.m1', [3, 1]],
    ['main.r1.m2', [0, 2]],
    ['main.r1.m3', [2, 1]],
    ['main.r1.m4', [1, 3]],
  ] as [string, [number, number]][]
).reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

const dates: Record<string, string> = {
  'main.r1': 'Sat 14 March',
  'main.r2': 'Sat 21 March',
  'main.r3': 'Sun 29 March',
};

export default function RoundTitles(): ReactNode {
  const dark = useColorScheme() === 'dark';
  return (
    <Bracket
      stage={stage}
      // Taller titles need the room: titleHeight sets it for every round.
      theme={{ titleHeight: 52 }}
      renderRoundTitle={(round) => <Title round={round} dark={dark} />}
    />
  );
}

function Title({ round, dark }: { round: Round; dark: boolean }): ReactNode {
  const played = round.matches.filter((match) => match.status === 'completed').length;
  const done = played === round.matches.length;
  return (
    <View style={{ alignItems: 'center', gap: 3 }}>
      <Text style={{ fontSize: 15, fontWeight: '700', color: dark ? '#fafafa' : '#18181b' }}>
        {round.name}
      </Text>
      <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
        <Text style={{ fontSize: 12, color: dark ? '#a1a1aa' : '#71717a' }}>{dates[round.id]}</Text>
        <Text
          style={{
            fontSize: 11,
            fontWeight: '600',
            paddingHorizontal: 7,
            paddingVertical: 1,
            borderRadius: 999,
            overflow: 'hidden',
            color: done ? '#16a34a' : '#2563eb',
            backgroundColor: done ? 'rgba(22, 163, 74, 0.12)' : 'rgba(37, 99, 235, 0.12)',
          }}
        >
          {done ? 'Played' : `${played} of ${round.matches.length}`}
        </Text>
      </View>
    </View>
  );
}

Rounded, square or bold: the lines' colour, width and corners, and the colour of a highlighted path.

Connector lines, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import {
  Bracket,
  reportResult,
  type Stage,
  singleElimination,
  type ThemeOverrides,
} from '@react-brackets/native';
import { type ReactNode, useState } from 'react';
import { Pressable, Text, View } from 'react-native';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const stage = (
  [
    ['main.r1.m1', [3, 1]],
    ['main.r1.m2', [0, 2]],
    ['main.r1.m3', [2, 1]],
    ['main.r1.m4', [1, 3]],
    ['main.r2.m1', [2, 0]],
  ] as [string, [number, number]][]
).reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

/** Lines take their colour, width and corners from the theme; the accent draws a highlighted path. */
const looks: Record<string, ThemeOverrides> = {
  Rounded: {
    lineWidth: 2,
    connectorRadius: 14,
    colors: { line: '#c4b5fd', accent: '#7c3aed', accentSoft: 'rgba(124, 58, 237, 0.12)' },
  },
  Square: {
    lineWidth: 1,
    connectorRadius: 0,
    radius: 2,
    colors: { line: '#a1a1aa', accent: '#0f766e', accentSoft: 'rgba(15, 118, 110, 0.12)' },
  },
  Bold: {
    lineWidth: 3.5,
    connectorRadius: 6,
    colors: { line: '#fed7aa', accent: '#ea580c', accentSoft: 'rgba(234, 88, 12, 0.12)' },
  },
};

export default function ConnectorLines(): ReactNode {
  const [look, setLook] = useState('Rounded');
  const [team, setTeam] = useState<string | null>('Falcons');
  return (
    <View style={{ gap: 16 }}>
      <View
        style={{
          flexDirection: 'row',
          alignSelf: 'flex-start',
          padding: 3,
          borderRadius: 999,
          backgroundColor: 'rgba(127, 127, 127, 0.14)',
        }}
      >
        {Object.keys(looks).map((name) => (
          <Pressable
            key={name}
            onPress={() => setLook(name)}
            style={{
              paddingHorizontal: 14,
              paddingVertical: 7,
              borderRadius: 999,
              backgroundColor: name === look ? '#ffffff' : 'transparent',
            }}
          >
            <Text style={{ fontWeight: '600', color: name === look ? '#18181b' : '#71717a' }}>
              {name}
            </Text>
          </Pressable>
        ))}
      </View>
      {/* Falcons' path starts highlighted; tap any team to follow theirs. */}
      <Bracket stage={stage} theme={looks[look]} highlighted={team} onHighlightChange={setTeam} />
    </View>
  );
}

An Arabic cup that starts on the right and swipes the other way. Round names and labels are yours to translate.

Right to left, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import { Bracket, reportResult, type Stage, singleElimination } from '@react-brackets/native';

const clubs = ['الصقور', 'الذئاب', 'البلشون', 'الوشق', 'الحيتان', 'الغربان', 'الثيران', 'الكوبرا'];
const rounds = ['ربع النهائي', 'نصف النهائي', 'النهائي'];

const stage = (
  [
    ['main.r1.m1', [2, 1]],
    ['main.r1.m2', [0, 1]],
    ['main.r1.m3', [3, 2]],
  ] as [string, [number, number]][]
).reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  singleElimination(
    clubs.map((name, index) => ({ id: `club-${index + 1}`, name })),
    { roundName: (index) => rounds[index] ?? `الدور ${index + 1}` },
  ),
);

export default function RightToLeft() {
  // The first round sits on the right and the bracket swipes the other way. Apps set to
  // right to left get this without the prop.
  return <Bracket stage={stage} dir="rtl" labels={{ tbd: 'لم يحدد', bye: 'تأهل مباشر' }} />;
}

Screens and data

Each round as a plain list of matches, for long draws where the fixtures matter more than the lines.

Fixtures list, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import { Bracket, reportResult, type Stage, singleElimination } from '@react-brackets/native';

const teams = [
  'Falcons',
  'Wolves',
  'Herons',
  'Lynx',
  'Orcas',
  'Ravens',
  'Bison',
  'Cobras',
  'Hawks',
  'Otters',
  'Cranes',
  'Pumas',
];

// Twelve teams: the top four seeds get a bye into the second round.
const stage = (
  [
    ['main.r1.m2', [2, 1]],
    ['main.r1.m4', [0, 1]],
    ['main.r1.m6', [3, 2]],
    ['main.r1.m8', [1, 2]],
  ] as [string, [number, number]][]
).reduce<Stage>(
  (current, [match, scores]) => reportResult(current, match, { scores }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

export default function FixturesList() {
  // Each round as a plain list of matches, swiped one round at a time.
  return <Bracket stage={stage} compact="list" />;
}

Keep the whole tree on a phone: pinch, drag, or double tap to zoom.

Full bracket on a phone, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import { Bracket, reportResult, type Stage, singleElimination } from '@react-brackets/native';

const teams = [
  'Falcons',
  'Wolves',
  'Herons',
  'Lynx',
  'Orcas',
  'Ravens',
  'Bison',
  'Cobras',
  'Hawks',
  'Otters',
  'Cranes',
  'Pumas',
  'Sharks',
  'Owls',
  'Moose',
  'Vipers',
];

const stage = Array.from({ length: 8 }, (_, index) => `main.r1.m${index + 1}`).reduce<Stage>(
  (current, match, index) => reportResult(current, match, { scores: index % 3 ? [2, 1] : [0, 2] }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

export default function FullTree() {
  // compactBelow={0} keeps the whole tree on a phone; pinch, drag or double tap to explore it.
  return <Bracket stage={stage} compactBelow={0} mode="pan-zoom" />;
}

Results stream in and winners move on as they land, and the view follows the round being played.

Live scores, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import {
  allMatches,
  Bracket,
  reportResult,
  type Stage,
  singleElimination,
} from '@react-brackets/native';
import { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from 'react-native-reanimated';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];
const target = 3;

const start = singleElimination(teams.map((name) => ({ id: name, name })));

/** One event per tick: the live match scores, or the next match kicks off. */
function tick(stage: Stage): Stage {
  const live = allMatches(stage).find((match) => match.status === 'live');
  if (!live) {
    const next = allMatches(stage).find((match) => match.status === 'ready');
    return next ? reportResult(stage, next.id, { scores: [0, 0], live: true }) : start;
  }
  const scores: [number, number] = [live.slots[0].score ?? 0, live.slots[1].score ?? 0];
  scores[Math.random() < 0.5 ? 0 : 1] += 1;
  return reportResult(stage, live.id, { scores, live: Math.max(...scores) < target });
}

/** The round the bracket shows: the one being played, so the view follows the action. */
function liveRound(stage: Stage): number {
  const rounds = stage.groups[0]?.rounds ?? [];
  const index = rounds.findIndex((round) =>
    round.matches.some((match) => match.status === 'live' || match.status === 'ready'),
  );
  return index === -1 ? rounds.length - 1 : index;
}

export default function LiveScores() {
  const [stage, setStage] = useState(start);

  useEffect(() => {
    const timer = setInterval(() => setStage(tick), 700);
    return () => clearInterval(timer);
  }, []);

  return (
    <View style={{ gap: 16 }}>
      <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
        <Pulse />
        <Text style={{ fontWeight: '700', color: '#ef4444' }}>Live</Text>
        <Text style={{ color: '#71717a' }}>First to {target} goes through</Text>
      </View>
      <Bracket stage={stage} page={liveRound(stage)} />
    </View>
  );
}

function Pulse() {
  const scale = useSharedValue(1);
  useEffect(() => {
    scale.value = withRepeat(withTiming(1.8, { duration: 800 }), -1);
  }, [scale]);
  const ring = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
    opacity: 2 - scale.value,
  }));
  return (
    <View style={{ width: 10, height: 10 }}>
      <Animated.View
        style={[
          { position: 'absolute', inset: 0, borderRadius: 5, backgroundColor: '#ef4444' },
          ring,
        ]}
      />
      <View style={{ flex: 1, borderRadius: 5, backgroundColor: '#ef4444' }} />
    </View>
  );
}

A placeholder at the bracket's real size while the draw loads, then the bracket in its place.

Loading, recorded on an iPhone
Show the code
npx expo install @react-brackets/native react-native-reanimated react-native-worklets react-native-gesture-handler
import { Bracket, reportResult, type Stage, singleElimination } from '@react-brackets/native';
import { type ReactNode, useEffect, useState } from 'react';
import { Pressable, Text, useColorScheme, View } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from 'react-native-reanimated';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

/** While the draw loads, a bracket of the same size stands in, so nothing jumps when it arrives. */
const placeholder = singleElimination(teams.map((_, index) => ({ id: `${index}`, name: '' })));

/** Stands in for a request to your server. */
const fetchDraw = () =>
  new Promise<Stage>((resolve) =>
    setTimeout(
      () =>
        resolve(
          reportResult(singleElimination(teams.map((name) => ({ id: name, name }))), 'main.r1.m1', {
            scores: [2, 1],
          }),
        ),
      1800,
    ),
  );

export default function Loading(): ReactNode {
  const [request, setRequest] = useState(0);
  return (
    <View style={{ gap: 16 }}>
      <Pressable
        onPress={() => setRequest(request + 1)}
        style={{
          alignSelf: 'flex-start',
          paddingHorizontal: 16,
          paddingVertical: 9,
          borderRadius: 999,
          backgroundColor: 'rgba(127, 127, 127, 0.16)',
        }}
      >
        <Text style={{ fontWeight: '600', color: '#2563eb' }}>Load again</Text>
      </Pressable>
      {/* A new key starts the request over. */}
      <Draw key={request} />
    </View>
  );
}

function Draw(): ReactNode {
  const [stage, setStage] = useState<Stage | null>(null);

  useEffect(() => {
    let current = true;
    fetchDraw().then((draw) => current && setStage(draw));
    return () => {
      current = false;
    };
  }, []);

  return stage ? (
    <Bracket stage={stage} />
  ) : (
    <Bracket stage={placeholder} renderMatch={() => <Skeleton />} />
  );
}

/** A card-sized block that breathes, on the UI thread. */
function Skeleton(): ReactNode {
  const dark = useColorScheme() === 'dark';
  const pulse = useSharedValue(0);
  useEffect(() => {
    pulse.value = withRepeat(
      withTiming(1, { duration: 800, easing: Easing.inOut(Easing.quad) }),
      -1,
      true,
    );
  }, [pulse]);
  const breathing = useAnimatedStyle(() => ({ opacity: 0.55 + pulse.value * 0.45 }));
  const bar = { height: 10, borderRadius: 5, backgroundColor: dark ? '#3f3f46' : '#e4e4e7' };
  return (
    <Animated.View
      style={[
        {
          height: 84,
          padding: 14,
          gap: 12,
          justifyContent: 'center',
          borderRadius: 10,
          borderWidth: 1,
          borderColor: dark ? '#27272a' : '#e4e4e7',
          backgroundColor: dark ? '#111113' : '#ffffff',
        },
        breathing,
      ]}
    >
      <View style={[bar, { width: '62%' }]} />
      <View style={[bar, { width: '48%' }]} />
    </Animated.View>
  );
}