Skip to contents
On this page

A tier list ranks teams into colored bands, best band on top, each band a row of logos. gt_tiers() draws one from a gt table, but it needs the data in a particular shape: one row per tier, a tier column naming it, and one column per slot holding an image URL. This article builds two:

  1. The 2025 NFL season in four tiers by EPA per play margin, from nflfastR’s play-by-play, on a light ground.
  2. The ACC’s 2025-26 men’s basketball season in three tiers by efficiency margin, from hoopR’s ESPN box scores, on the default dark ground.

gt_tiers() and gt_theme_tier() come from Andrew Weatherman’s gtUtils, and this article follows his tier-list example, which ranks the ACC by 2024 KenPom rating:

gtUtils' example tier list: ACC basketball teams' logos in Elite, Average and Poor bands by 2024 KenPom rating.

1. NFL teams by EPA per play margin

A team’s EPA per play margin is the expected points it added per offensive snap minus the expected points it allowed per defensive snap, over pass and run plays. One season of play-by-play holds both ends.

pbp <- nflreadr::load_pbp(2025) |>
  filter(season_type == "REG", !is.na(epa), pass == 1 | rush == 1)

nfl <- inner_join(
  summarise(pbp, off_epa = mean(epa), .by = posteam),
  summarise(pbp, def_epa = mean(epa), .by = defteam),
  by = c(posteam = "defteam")
) |>
  rename(team = posteam) |>
  mutate(margin = off_epa - def_epa)

Binning into tiers

Four tiers: the top eight, the next eight, and so on. row_number() ranks the teams with the abbreviation as a tiebreaker, so a rebuild bins them the same way, and integer division by eight turns the rank into a tier. tier_order is each team’s place within its tier, which becomes its column.

nfl_tiers <- c(Contenders = "#1B7837", Solid = "#7FBF7B", Shaky = "#F1B6A8", Rebuilding = "#B2182B")

nfl <- nfl |>
  arrange(desc(margin), team) |>
  mutate(
    rank = row_number(),
    tier = factor(names(nfl_tiers)[(rank - 1) %/% 8 + 1], levels = names(nfl_tiers)),
    tier_order = (rank - 1) %% 8 + 1
  )

nfl_tiers is a named vector of tier = color. gt_tiers() takes that shape directly, and so does gt_legend_discrete(), so one object can drive both.

The image columns

The images come from sdvplotR’s own team table: sdv_logo_url() resolves the same nflverse abbreviations the play-by-play uses to each team’s logo, the SportsDataverse logo archive’s copy of it.

nfl_wide <- nfl |>
  mutate(logo = sdv_logo_url(team, league = "nfl")) |>
  pivot_wider(id_cols = tier, names_from = tier_order, values_from = logo, names_sort = TRUE) |>
  arrange(tier)

arrange(tier) sorts by the factor levels, so the best tier is on top rather than whichever comes first alphabetically. Each row now holds a tier name and eight logo URLs:

nfl_wide[, 1:3]
#> # A tibble: 4 × 3
#>   tier       `1`                                                           `2`  
#>   <fct>      <chr>                                                         <chr>
#> 1 Contenders https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sh… http…
#> 2 Solid      https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sh… http…
#> 3 Shaky      https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sh… http…
#> 4 Rebuilding https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sh… http…

Plotting

gt_tiers() applies gt_theme_tier() (dark unless style = "light"), renders every column other than tier as an image, clears the column labels, and fills each tier cell with its color, with black or white text by whichever contrasts more.

nfl_wide |>
  gt() |>
  gt_tiers(nfl_tiers, style = "light", img_height = "46px") |>
  tab_header(
    title = "The 2025 NFL season in four tiers",
    subtitle = "Ranked by EPA per play margin: offense minus defense, pass and run plays, regular season"
  ) |>
  tab_source_note("Data: nflverse play-by-play via nflfastR")
The 2025 NFL season in four tiers
Ranked by EPA per play margin: offense minus defense, pass and run plays, regular season
Contenders Los Angeles Rams New England Patriots Seattle Seahawks Buffalo Bills Jacksonville Jaguars Denver Broncos Houston Texans Philadelphia Eagles
Solid Green Bay Packers Indianapolis Colts Detroit Lions Los Angeles Chargers Kansas City Chiefs Chicago Bears San Francisco 49ers Baltimore Ravens
Shaky Pittsburgh Steelers Minnesota Vikings Tampa Bay Buccaneers Atlanta Falcons New Orleans Saints Dallas Cowboys New York Giants Carolina Panthers
Rebuilding Cleveland Browns Arizona Cardinals Miami Dolphins Cincinnati Bengals Washington Commanders Las Vegas Raiders Tennessee Titans New York Jets
Data: nflverse play-by-play via nflfastR

2. The ACC by efficiency margin

For the second list we rank the ACC’s men’s basketball teams by efficiency margin: points scored minus points allowed per 100 possessions over the 2025-26 season, tournaments included. sdv_teams() lists the conference’s members, with their ESPN ids. The box scores hold one row per team per game; joining each row to its opponent’s row gives the possessions on both sides.

acc <- sdv_teams("mbb") |>
  filter(conference_id == "mbb:acc")

# possessions estimated from the box score
box <- hoopR::load_mbb_team_box(seasons = 2026) |>
  mutate(poss = field_goals_attempted - offensive_rebounds + total_turnovers + 0.475 * free_throws_attempted)

acc_margin <- box |>
  filter(team_id %in% acc$team_id) |>
  inner_join(
    select(box, game_id, opponent_team_id = team_id, opp_poss = poss),
    by = c("game_id", "opponent_team_id")
  ) |>
  summarise(
    eff_margin = 100 * sum(team_score - opponent_team_score) / sum((poss + opp_poss) / 2),
    .by = team_id
  ) |>
  mutate(team = acc$abbr[match(team_id, acc$team_id)])

This time the tiers are uneven: the top quarter, the middle half and the bottom quarter. cut() divides the ranks at their 25th and 75th percentiles.

acc_tiers <- c(Elite = "#FF7F7F", Average = "#FFDF7F", Poor = "#BFFF7F")

acc_margin <- acc_margin |>
  arrange(desc(eff_margin), team) |>
  mutate(
    rank = row_number(),
    tier = cut(rank,
      breaks = quantile(rank, probs = c(0, 0.25, 0.75, 1)),
      labels = names(acc_tiers),
      include.lowest = TRUE
    )
  ) |>
  mutate(tier_order = row_number(), .by = tier)

A dark table wants dark-mode logos: variant = "dark", which gives the primary for a team with no dark mark (every team has one; most conferences don’t).

acc_wide <- acc_margin |>
  mutate(logo = sdv_logo_url(team, league = "mbb", variant = "dark")) |>
  pivot_wider(id_cols = tier, names_from = tier_order, values_from = logo, names_sort = TRUE) |>
  arrange(tier)
acc_wide |>
  gt() |>
  gt_tiers(acc_tiers, img_height = "48px") |>
  tab_header(
    title = "The ACC in three tiers",
    subtitle = "Efficiency margin per 100 possessions, 2025-26 men's basketball"
  ) |>
  tab_source_note("Data: ESPN box scores via hoopR")
The ACC in three tiers
Efficiency margin per 100 possessions, 2025-26 men's basketball
Elite Duke Blue Devils Virginia Cavaliers Louisville Cardinals Miami Hurricanes North Carolina Tar Heels


Average Clemson Tigers NC State Wolfpack SMU Mustangs California Golden Bears Virginia Tech Hokies Stanford Cardinal Florida State Seminoles Wake Forest Demon Deacons
Poor Syracuse Orange Notre Dame Fighting Irish Pittsburgh Panthers Boston College Eagles Georgia Tech Yellow Jackets


Data: ESPN box scores via hoopR

The middle tier holds half the conference, so it runs the widest. gt_tiers() does not wrap a long row; shrink img_height or split the tier when a row gets too wide for where the table is going.

Citation

To cite the sdvplotR R package in publications, use:

BibTeX Citation

@misc{gilani_carl_lee_kay_weatherman_sdvplotR,
  author = {Gilani, Saiem and Carl, Sebastian and Lee, Jared and Kay, Camden and Weatherman, Andrew},
  title = {sdvplotR: The SportsDataverse's R Package for Multi-League Sports Plotting.},
  url = {https://sdvplotR.sportsdataverse.org},
  year = {2026}
}