FIFA 2026 World Cup
  • Home

FIFA 2026 World Cup

Schedule and results

This page rebuilds about every 10 minutes during match windows, with live scores from football-data.org’s paid API tier. An in-progress match shows a running scoreline with the match clock (e.g. “1–0 (live 67’)”). Because the page is a periodic snapshot, the scoreline and clock can trail the live match by a few minutes. There is an Update Scores button below that uses JavaScript to fetch up-to-date scores if a match is live you want to check.

Last rebuilt: July 4, 2026 at 19:27 EDT

import {computeScoreDisplay, mergeLiveScores, knockoutRounds} from "./live-scores.js"

teams   = transpose(teams_data).sort((a, b) => a.name.localeCompare(b.name))

// Build-time snapshot from R. The optional "Update scores" button overlays
// fresh scores onto this via `liveEntries` (see below); when the button isn't
// used (or isn't configured) `matches === baseMatches`.
baseMatches = transpose(matches_data)
  .map(d => ({...d, utc_date: new Date(d.utc_date_str)}))
  .sort((a, b) => a.utc_date - b.utc_date)

now = new Date()

// Public proxy URL injected at render time (empty when the feature is off).
WORKER_URL = (typeof score_proxy_url === "string" ? score_proxy_url : "").trim()

// Live overlay, set by the button. Empty by default, so `matches` matches the
// build-time snapshot until the visitor asks for an update.
mutable liveEntries = []

matches = mergeLiveScores(baseMatches, liveEntries, now)
  .slice()
  .sort((a, b) => a.utc_date - b.utc_date)
localTz = Intl.DateTimeFormat().resolvedOptions().timeZone
tzOptions = new Map([
  [`Your local time (${localTz})`, localTz],
  ["UTC", "UTC"],
  ["US Eastern",  "America/New_York"],
  ["US Central",  "America/Chicago"],
  ["US Mountain", "America/Denver"],
  ["US Pacific",  "America/Los_Angeles"],
  ["Mexico City", "America/Mexico_City"],
  ["São Paulo",   "America/Sao_Paulo"],
  ["London",      "Europe/London"],
  ["Central Europe (Paris/Berlin/Madrid)", "Europe/Paris"],
  ["Athens / Cairo", "Europe/Athens"],
  ["Moscow",      "Europe/Moscow"],
  ["Dubai",       "Asia/Dubai"],
  ["India",       "Asia/Kolkata"],
  ["Tokyo / Seoul", "Asia/Tokyo"],
  ["Beijing / Singapore", "Asia/Shanghai"],
  ["Sydney",      "Australia/Sydney"],
  ["Auckland",    "Pacific/Auckland"]
])
viewof selectedTz = Inputs.select(tzOptions, {label: "Time zone", value: localTz})
tz = selectedTz

Pick your time zone above — both tabs update. (Defaults to your device’s time zone.)

// "Update scores" button — only rendered when a proxy URL was configured at
// render time (WORLDCUP26_SCORE_PROXY_URL). It fetches current scores from the
// Cloudflare Worker (which keeps the API key secret) and overlays them onto the
// page via `liveEntries`, no site rebuild needed. Returns null (renders
// nothing) when the feature is off.
updateBar = {
  if (!WORKER_URL) return null;

  const status = html`<span class="update-status"></span>`;
  const btn = html`<button class="update-btn" type="button">Update scores</button>`;

  btn.onclick = async () => {
    btn.disabled = true;
    status.classList.remove("error");
    status.textContent = "Updating…";
    try {
      const resp = await fetch(WORKER_URL, {headers: {Accept: "application/json"}});
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      const data = await resp.json();
      mutable liveEntries = Array.isArray(data.matches) ? data.matches : [];
      status.textContent = `Updated ${new Date().toLocaleTimeString()}`;
    } catch (err) {
      status.classList.add("error");
      status.textContent = "Couldn't reach live scores — try again.";
    } finally {
      btn.disabled = false;
    }
  };

  return html`<div class="update-bar">${btn}${status}</div>`;
}
  • By team
  • By date
  • Knockout stage
  • Standings
viewof selectedTeamName = Inputs.select(
  teams.map(t => t.name),
  {label: "Team", value: "United States"}
)
selectedTeam = teams.find(t => t.name === selectedTeamName)
teamMatches  = matches.filter(
  m => m.home_team_id === selectedTeam.id || m.away_team_id === selectedTeam.id
)
nextMatch    = teamMatches.find(
  m => (m.utc_date >= now || liveStatuses.has(m.status)) &&
       !finishedStatuses.has(m.status) &&
       !inactiveStatuses.has(m.status)
)
pastMatches  = teamMatches.filter(
  m => m.utc_date < now &&
       !liveStatuses.has(m.status) &&
       !inactiveStatuses.has(m.status)
)
upcoming     = teamMatches.filter(
  m => (m.utc_date >= now || liveStatuses.has(m.status)) &&
       !finishedStatuses.has(m.status) &&
       !inactiveStatuses.has(m.status)
)
html`<div class="team-header">
  <img src="${selectedTeam.crest_url}" alt="${selectedTeam.name} crest" class="crest-lg">
  <div>
    <h2 class="team-name">${selectedTeam.name}</h2>
    <div class="team-meta">${selectedTeam.tla} · ${groupLabelFor(selectedTeam)}</div>
  </div>
</div>`

Next match

nextMatchCard(nextMatch, selectedTeam)

Upcoming matches

upcoming.length > 0
  ? scheduleTable(upcoming, selectedTeam)
  : html`<p class="muted">No remaining matches on the schedule.</p>`

Past results

pastMatches.length > 0
  ? scheduleTable(pastMatches, selectedTeam)
  : html`<p class="muted">${selectedTeam.name} has not played any matches yet.</p>`
matchesByDay = d3.groups(
  matches,
  m => dayKey(m.utc_date)
).sort(([a], [b]) => a.localeCompare(b))
dayOptions = ["All days", ...matchesByDay.map(([d]) => d)]
today = dayKey(now)

viewof dayChoice = Inputs.select(dayOptions, {
  label: "Day",
  value: dayOptions.includes(today) ? today : "All days"
})
dayList = dayChoice === "All days"
  ? matchesByDay
  : matchesByDay.filter(([d]) => d === dayChoice)
html`${dayList.map(([day, dayMatches]) => html`
  <section class="day-block">
    <h3 class="day-heading">${formatDayHeading(day)}</h3>
    ${matchListTable(dayMatches)}
  </section>
`)}`
ko = knockoutRounds(matches)
ko.rounds.every(r => r.matches.length === 0)
  ? html`<p class="muted">The knockout bracket fills in here once the group
      stage finishes — check back after the final group matches.</p>`
  : html`<p class="ko-note muted">Dates and kick-off times are official, but
      next-round matchups can lag the results, so an upcoming game may read “TBD”
      here even after the feeding match is decided. For the latest confirmed
      fixtures, see the
      <a href="https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/scores-fixtures"
         target="_blank" rel="noopener">official FIFA fixtures page</a>.</p>
      ${bracketColumns(ko)}`
standings  = transpose(standings_data)
thirdPlace = transpose(third_place_data)
groupLetters = Array.from(new Set(standings.map(d => d.group_letter)))
  .filter(g => g != null).sort()
groupLetters.length === 0
  ? html`<p class="muted">No group-stage standings yet — check back once matches kick off.</p>`
  : html`<div class="standings-grid">
      ${groupLetters.map(g => standingsTable(g, standings.filter(d => d.group_letter === g)))}
    </div>`

Third-place race

thirdPlace.length === 0
  ? html`<p class="muted">No third-place standings yet.</p>`
  : thirdPlaceTable(thirdPlace)
liveStatuses     = new Set(["IN_PLAY","PAUSED","EXTRA_TIME","PENALTY_SHOOTOUT"])
finishedStatuses = new Set(["FINISHED","AWARDED"])
inactiveStatuses = new Set(["CANCELLED","POSTPONED","SUSPENDED"])

teamById = new Map(teams.map(t => [t.id, t]))

function crestFor(name, id) {
  const t = teamById.get(id);
  return t
    ? html`<img src="${t.crest_url}" alt="" class="crest-sm"> ${name}`
    : html`<span class="tbd">${name ?? "TBD"}</span>`;
}

function groupLabelFor(team) {
  const m = matches.find(
    x => (x.home_team_id === team.id || x.away_team_id === team.id) &&
         x.stage === "GROUP_STAGE"
  );
  return m && m.group ? `Group ${m.group.replace("GROUP_", "")}` : "—";
}

function stageLabel(stage, group) {
  if (!stage) return "";
  if (stage === "GROUP_STAGE") return group ? `Group ${group.replace("GROUP_", "")}` : "Group stage";
  return stage.replace(/_/g, " ").toLowerCase()
    .replace(/\b\w/g, c => c.toUpperCase());
}

// All times follow the selected `tz` (see the time-zone picker). Building a
// fresh formatter per call keeps things reactive when `tz` changes.
function dayKey(d) {
  // e.g. "2026-06-12" — the calendar date in the selected zone.
  return new Intl.DateTimeFormat("en-CA", {timeZone: tz}).format(d);
}

function formatDate(d) {
  return d.toLocaleString("en-US", {
    weekday: "long", month: "long", day: "numeric", year: "numeric",
    hour: "numeric", minute: "2-digit", timeZone: tz, timeZoneName: "short"
  });
}
function formatDateShort(d) {
  return d.toLocaleDateString("en-US", {weekday: "short", month: "short", day: "numeric", timeZone: tz});
}
function formatTime(d) {
  return d.toLocaleTimeString("en-US", {hour: "numeric", minute: "2-digit", timeZone: tz, timeZoneName: "short"});
}
function formatDayHeading(isoDay) {
  // isoDay is already the selected-zone calendar date; format it as UTC midday
  // so the weekday/month/day read back out without a second timezone shift.
  const d = new Date(isoDay + "T12:00:00Z");
  return d.toLocaleDateString("en-US", {weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC"});
}

function statusBadge(m) {
  if (liveStatuses.has(m.status))            return html`<span class="badge live">LIVE</span>`;
  if (m.status === "POSTPONED")              return html`<span class="badge postponed">Postponed</span>`;
  if (m.status === "CANCELLED")              return html`<span class="badge cancelled">Cancelled</span>`;
  if (m.status === "SUSPENDED")              return html`<span class="badge suspended">Suspended</span>`;
  return "";
}

function nextMatchCard(m, team) {
  if (!m) {
    return html`<div class="next-card empty">No upcoming matches on the schedule.</div>`;
  }
  const isHome = m.home_team_id === team.id;
  const opp_id = isHome ? m.away_team_id : m.home_team_id;
  const opp_t  = teamById.get(opp_id);
  const oppName = isHome ? m.away_team : m.home_team;
  return html`
    <div class="next-card">
      <div class="next-date">${formatDate(m.utc_date)}</div>
      <div class="next-matchup">
        <div class="opponent">
          ${opp_t ? html`<img src="${opp_t.crest_url}" class="crest-md" alt="">` : ""}
          <div>
            <div class="vs-label">${isHome ? "vs" : "at"}</div>
            <div class="opponent-name">${oppName ?? "TBD"}</div>
          </div>
        </div>
        <div class="next-stage">${stageLabel(m.stage, m.group)}${statusBadge(m)}</div>
      </div>
      ${m.venue ? html`<div class="next-venue">${m.venue}</div>` : ""}
    </div>`;
}

// `score_display` from R is always home–away. On a team page we want the
// selected team's goals first, so the same match reads as a win for one
// side and a loss for the other. Non-numeric strings ("in progress",
// "in progress (80')", "no score available yet", "postponed", "") pass
// through unchanged.
function flipScore(s) {
  if (!s) return s;
  // Final with penalties: "1–1 (4–3 PK)" — swap both pairs.
  let m = s.match(/^(\d+)–(\d+)\s*\((\d+)–(\d+)\s*PK\)$/);
  if (m) return `${m[2]}–${m[1]} (${m[4]}–${m[3]} PK)`;
  // Live line, optionally with a clock: "1–0 (live)" / "1–0 (live 45+2')" —
  // swap the goals, keep the suffix verbatim (it has no per-team numbers).
  m = s.match(/^(\d+)–(\d+)(\s*\(live[^)]*\))$/);
  if (m) return `${m[2]}–${m[1]}${m[3]}`;
  // Plain full-time line: "2–1".
  m = s.match(/^(\d+)–(\d+)$/);
  if (m) return `${m[2]}–${m[1]}`;
  return s;
}

function scheduleTable(rows, team) {
  return html`<table class="schedule">
    <thead>
      <tr>
        <th>Date</th><th>Kick-off</th><th>Opponent</th><th>H/A</th>
        <th>Stage</th><th>Score</th>
      </tr>
    </thead>
    <tbody>
    ${rows.map(m => {
      const isHome = m.home_team_id === team.id;
      const opp_id = isHome ? m.away_team_id : m.home_team_id;
      const oppName = isHome ? m.away_team : m.home_team;
      const score = isHome ? m.score_display : flipScore(m.score_display);
      return html`<tr>
        <td>${formatDateShort(m.utc_date)}</td>
        <td>${formatTime(m.utc_date)}</td>
        <td>${crestFor(oppName, opp_id)}</td>
        <td>${isHome ? "H" : "A"}</td>
        <td>${stageLabel(m.stage, m.group)}</td>
        <td class="score">${score || ""} ${statusBadge(m)}</td>
      </tr>`;
    })}
    </tbody>
  </table>`;
}

function matchListTable(rows) {
  return html`<table class="day-table">
    <tbody>
    ${rows.map(m => html`<tr>
      <td class="time">${formatTime(m.utc_date)}</td>
      <td class="team home">${crestFor(m.home_team, m.home_team_id)}</td>
      <td class="vs">vs</td>
      <td class="team away">${crestFor(m.away_team, m.away_team_id)}</td>
      <td class="stage">${stageLabel(m.stage, m.group)}</td>
      <td class="score">${m.score_display || ""} ${statusBadge(m)}</td>
    </tr>`)}
    </tbody>
  </table>`;
}

// ---- Knockout bracket -------------------------------------------------------

// Which side has advanced, for a finished knockout match: "home", "away", or
// null (not finished, or level with no shoot-out data). Penalties break a draw.
function koWinnerSide(m) {
  if (!finishedStatuses.has(m.status)) return null;
  if (m.home_score != null && m.away_score != null && m.home_score !== m.away_score) {
    return m.home_score > m.away_score ? "home" : "away";
  }
  if (m.home_pk != null && m.away_pk != null && m.home_pk !== m.away_pk) {
    return m.home_pk > m.away_pk ? "home" : "away";
  }
  return null;
}

// One knockout match: date/time (in the selected tz), both teams (TBD when a
// slot isn't decided yet), the scoreline, and any status badge. The advancing
// side is bolded once the match is final.
function koCard(m) {
  const winner = koWinnerSide(m);
  const sideClass = side => winner === side ? "ko-team winner" : "ko-team";
  return html`<div class="ko-card">
    <div class="ko-date">${formatDateShort(m.utc_date)} · ${formatTime(m.utc_date)}</div>
    <div class="${sideClass("home")}">${crestFor(m.home_team, m.home_team_id)}</div>
    <div class="${sideClass("away")}">${crestFor(m.away_team, m.away_team_id)}</div>
    <div class="ko-score">${m.score_display || ""} ${statusBadge(m)}</div>
  </div>`;
}

// One bracket column: a round heading and its match cards (a placeholder when
// the round has no fixtures yet).
function bracketRound(round, headingTag = "h3") {
  const heading = headingTag === "h4"
    ? html`<h4 class="round-subheading">${round.label}</h4>`
    : html`<h3 class="round-heading">${round.label}</h3>`;
  return html`<div class="bracket-round">
    ${heading}
    ${round.matches.length > 0
      ? round.matches.map(koCard)
      : html`<div class="ko-card empty"><span class="tbd">To be determined</span></div>`}
  </div>`;
}

// The full bracket: the five main rounds as left→right columns, with the
// third-place playoff tucked beneath the Final.
function bracketColumns(ko) {
  return html`<div class="bracket-grid">
    ${ko.rounds.map(r => {
      if (r.stage === "FINAL" && ko.thirdPlace) {
        return html`<div class="bracket-round final-col">
          <h3 class="round-heading">${r.label}</h3>
          ${r.matches.length > 0
            ? r.matches.map(koCard)
            : html`<div class="ko-card empty"><span class="tbd">To be determined</span></div>`}
          ${bracketRound(ko.thirdPlace, "h4")}
        </div>`;
      }
      return bracketRound(r);
    })}
  </div>`;
}

// Goal difference with an explicit + for positive values.
function fmtGd(gd) {
  return gd > 0 ? `+${gd}` : `${gd}`;
}

// One group's standings table. Rows arrive already ordered by rank. The top two
// (who advance directly) are highlighted; teams level on every available
// tiebreaker carry a "tie" flag (the deciding criteria — cards, FIFA ranking —
// aren't in the data).
function standingsTable(letter, rows) {
  const complete = rows.length > 0 && rows[0].group_complete;
  return html`<section class="standings-group">
    <h3 class="standings-heading">Group ${letter}
      <span class="standings-state">${complete ? "Final" : "In progress"}</span>
    </h3>
    <table class="schedule standings">
      <thead>
        <tr><th>#</th><th>Team</th><th>P</th><th>W</th><th>D</th><th>L</th>
            <th>GF</th><th>GA</th><th>GD</th><th>Pts</th></tr>
      </thead>
      <tbody>
        ${rows.map(r => html`<tr class="${r.rank <= 2 ? "qualified" : ""}">
          <td>${r.rank}</td>
          <td>${crestFor(r.team, r.team_id)}${r.tie_unresolved
            ? html` <span class="tie-flag" title="Level on every available tiebreaker">tie</span>`
            : ""}</td>
          <td>${r.played}</td><td>${r.won}</td><td>${r.drawn}</td><td>${r.lost}</td>
          <td>${r.gf}</td><td>${r.ga}</td><td>${fmtGd(r.gd)}</td>
          <td class="pts">${r.points}</td>
        </tr>`)}
      </tbody>
    </table>
  </section>`;
}

// The cross-group race for the eight best third-placed teams. A line after
// position 8 marks the qualification cut; while groups are in progress the
// ordering is provisional.
function thirdPlaceTable(rows) {
  const provisional = rows.length > 0 && rows[0].provisional;
  return html`<div class="third-place">
    <table class="schedule standings">
      <thead>
        <tr><th>Pos</th><th>Group</th><th>Team</th><th>P</th><th>Pts</th>
            <th>GD</th><th>GF</th></tr>
      </thead>
      <tbody>
        ${rows.map(r => html`<tr class="${r.currently_advancing ? "qualified" : "out"}${r.position === 8 ? " cut-line" : ""}">
          <td>${r.position}</td>
          <td>${r.group_letter}</td>
          <td>${crestFor(r.team, r.team_id)}</td>
          <td>${r.played}</td>
          <td class="pts">${r.points}</td>
          <td>${fmtGd(r.gd)}</td>
          <td>${r.gf}</td>
        </tr>`)}
      </tbody>
    </table>
    <p class="muted">${provisional
      ? "Top eight are currently advancing — provisional while groups are in progress; both the third-placed team and its record can still change."
      : "The top eight advance to the Round of 32."}</p>
  </div>`;
}

Data: football-data.org · Code: worldcup26 R package