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:
-
The 2025 NFL season in four tiers by EPA per play
margin, from
nflfastR’s play-by-play, on a light ground. -
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:

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 | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
| Solid | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
| Shaky | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
| Rebuilding | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
| 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 | ![]() |
![]() |
![]() |
![]() |
![]() |
|||
| Average | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
| Poor | ![]() |
![]() |
![]() |
![]() |
![]() |
|||
| 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.
Related SportsDataverse packages
- sdvplot - the Python plotting package
- sdvplot-js - the JavaScript / TypeScript plotting packages
- cfbfastR - college football
- hoopR - men’s basketball
- wehoop - women’s basketball
- baseballr - baseball
- fastRhockey - hockey
- oddsapiR - betting odds
- sportyR - playing surfaces
- sportsdataverse-py - the Python package
- sportsdataverse-R - the R meta-package


















































