Skip to contents
On this page

Twelve recipes for team logos and headshots on charts you can hover, zoom and share: ggiraph makes a ggplot interactive and keeps every sdvplotR layer, plotly draws logos as layout images, and reactable makes a sortable table; leaflet puts logos on a map. The last two recipes save a chart as one HTML file and as a PNG for a social post. The data is one season each from MLB (baseballr, from the MLB Stats API), the NBA, the WNBA and the NHL (hoopR, wehoop and fastRhockey), and the NFL and college football (nflreadr and cfbfastR), all but MLB read from SportsDataverse release files.

library(sdvplotR)
library(ggplot2)
library(dplyr, warn.conflicts = FALSE)
library(ggiraph)
library(plotly, warn.conflicts = FALSE)

season <- 2026 # MLB and WNBA: 2026; NBA and NHL: the 2025-26 season

1. ggiraph: logos with hover details

ggiraph’s girafe() turns a ggplot into an interactive SVG, and every sdvplotR layer comes along: logos are embedded in the SVG. Logos themselves don’t respond to the pointer, so put the tooltip on a nearly transparent geom_point_interactive() at the same spot. MLB’s 2026 season, runs scored and allowed per game:

mlb_ids <- baseballr::mlb_teams(season = season, sport_ids = 1) |>
  as.data.frame() |>
  select(team_records_team_id = team_id, team_abbreviation, team_full_name)
mlb <- baseballr::mlb_standings(season = season, league_id = "103,104") |>
  as.data.frame() |>
  inner_join(mlb_ids, by = "team_records_team_id") |>
  transmute(
    team = sdv_resolve(team_abbreviation, league = "mlb"),
    name = team_full_name,
    wins = team_records_wins,
    losses = team_records_losses,
    rs = team_records_runs_scored / team_records_games_played,
    ra = team_records_runs_allowed / team_records_games_played,
    run_diff = team_records_runs_scored - team_records_runs_allowed
  ) |>
  arrange(team)
nrow(mlb)
#> [1] 30
p_mlb <- ggplot(mlb, aes(x = rs, y = ra)) +
  geom_abline(slope = 1, intercept = 0, colour = "grey60", linetype = "dashed") +
  geom_sdv_logos(aes(team = team), league = "mlb", width = 0.06) +
  geom_point_interactive(
    aes(tooltip = sprintf("%s<br>%d-%d<br>%.2f scored, %.2f allowed per game", name, wins, losses, rs, ra),
        data_id = team),
    size = 9, alpha = 0.01
  ) +
  scale_x_continuous(expand = expansion(mult = 0.08)) +
  scale_y_continuous(expand = expansion(mult = 0.08)) +
  labs(
    title = "MLB runs scored and allowed, 2026",
    subtitle = "Per game; teams below the dashed line outscored their opponents",
    x = "Runs scored per game", y = "Runs allowed per game",
    caption = "Data: baseballr::mlb_standings() (MLB Stats API)"
  ) +
  theme_minimal()

girafe(ggobj = p_mlb, width_svg = 7, height_svg = 5)

alpha = 0.01, not 0: a fully transparent point is dropped from the SVG and catches nothing.

2. ggiraph: a tooltip with a picture

A tooltip is HTML, so it can carry an image. hoopR’s player box score has each player’s ESPN headshot URL (athlete_headshot_href); the browser loads it on hover, so the page stays light. The 2025-26 season’s 25 top scorers (50+ games), each point in his team’s color:

# the regular season without the All-Star games and the NBA Cup final
nba_regular <- hoopR::load_nba_schedule(season) |>
  as.data.frame() |>
  filter(season_type == 2, type_abbreviation == "STD") |>
  pull(game_id)
players <- hoopR::load_nba_player_box(season) |>
  as.data.frame() |>
  filter(game_id %in% nba_regular, !did_not_play)

scorers <- players |>
  summarise(
    games = n(),
    ppg = mean(points),
    ts = sum(points) / (2 * (sum(field_goals_attempted) + 0.44 * sum(free_throws_attempted))),
    team = last(team_abbreviation, order_by = game_date),
    headshot = last(athlete_headshot_href, order_by = game_date),
    .by = c(athlete_id, athlete_display_name)
  ) |>
  filter(games >= 50) |>
  arrange(desc(ppg), athlete_id) |>
  slice_head(n = 25) |>
  mutate(tip = sprintf(
    "<img src='%s' height='80'><br><b>%s</b> (%s)<br>%.1f points, %.1f%% true shooting",
    headshot, athlete_display_name, team, ppg, 100 * ts
  ))

p_scorers <- ggplot(scorers, aes(x = ppg, y = ts)) +
  geom_point_interactive(aes(fill = team, tooltip = tip, data_id = athlete_id),
    shape = 21, size = 4, colour = "white", stroke = 0.6
  ) +
  scale_fill_sdv(league = "nba") +
  scale_y_continuous(labels = scales::label_percent(accuracy = 1)) +
  labs(
    title = "The NBA's 25 top scorers, 2025-26",
    subtitle = "Hover a point for the player",
    x = "Points per game", y = "True shooting %",
    caption = "Data: hoopR::load_nba_player_box(); headshots: ESPN"
  ) +
  theme_minimal()

girafe(
  ggobj = p_scorers, width_svg = 7, height_svg = 4.5,
  options = list(opts_tooltip(css = "background:white;color:black;padding:6px;border-radius:6px;"))
)

A player traded mid-season shows his latest team here, the one in his last game.

3. ggiraph: hover one line to pick it out

With fifteen teams, a line chart is a tangle. Give each line a data_id and ggiraph highlights the one under the pointer; opts_hover_inv() fades the rest. The WNBA’s 2026 regular season as cumulative wins:

# standard games only: no All-Star Game or Commissioner's Cup final
wnba_regular <- wehoop::load_wnba_schedule(season) |>
  as.data.frame() |>
  filter(season_type == 2, type_abbreviation == "STD") |>
  pull(game_id)
wnba_runs <- wehoop::load_wnba_team_box(season) |>
  as.data.frame() |>
  filter(game_id %in% wnba_regular) |>
  arrange(team_abbreviation, game_date, game_id) |>
  mutate(game_no = row_number(), wins = cumsum(team_winner), .by = team_abbreviation) |>
  rename(team = team_abbreviation)

p_wnba <- ggplot(wnba_runs, aes(x = game_no, y = wins, colour = team)) +
  geom_line_interactive(aes(data_id = team, tooltip = team_display_name), linewidth = 1) +
  scale_colour_sdv(league = "wnba") +
  labs(
    title = "The WNBA's 2026 season, win by win",
    subtitle = "Hover a line to pick out a team",
    x = "Game", y = "Wins",
    caption = "Data: wehoop::load_wnba_team_box()"
  ) +
  theme_minimal()

girafe(
  ggobj = p_wnba, width_svg = 7, height_svg = 4.5,
  options = list(
    opts_hover(css = "stroke-width:3;"),
    opts_hover_inv(css = "opacity:0.15;")
  )
)

opts_selection() keeps a line highlighted on click, for comparing two teams.

4. ggiraph: interactive bars with logos on the axis

Axis logos from scale_x_sdv() and theme_x_sdv() work in ggiraph too, since the SVG embeds the images. The bars carry the tooltip. NBA three-point volume, 2025-26:

threes <- hoopR::load_nba_team_box(season) |>
  as.data.frame() |>
  filter(game_id %in% nba_regular) |>
  summarise(
    games = n(),
    tpa = mean(three_point_field_goals_attempted),
    tp_pct = sum(three_point_field_goals_made) / sum(three_point_field_goals_attempted),
    .by = team_abbreviation
  ) |>
  arrange(desc(tpa), team_abbreviation) |>
  mutate(team = factor(team_abbreviation, levels = team_abbreviation))

p_threes <- ggplot(threes, aes(x = team, y = tpa)) +
  geom_col_interactive(
    aes(fill = team_abbreviation, data_id = team_abbreviation,
        tooltip = sprintf("%s: %.1f attempts, %.1f%% made", team_abbreviation, tpa, 100 * tp_pct)),
    width = 0.75
  ) +
  scale_x_sdv(league = "nba", size = 16) +
  scale_fill_sdv(league = "nba") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.05))) +
  labs(
    title = "NBA three-point attempts per game, 2025-26",
    x = NULL, y = "3PA per game",
    caption = "Data: hoopR::load_nba_team_box()"
  ) +
  theme_minimal() +
  theme_x_sdv() +
  theme(panel.grid.major.x = element_blank())

girafe(ggobj = p_threes, width_svg = 8, height_svg = 4.5)

Every logo is embedded in the page, as a small copy sized for the axis label, so thirty of them add about 100 KB; recipe 2’s URL-in-tooltip approach adds nothing.

5. plotly: ggplotly() drops the logos, so add them back

ggplotly() converts most layers, but not sdvplotR’s image geoms: it warns and leaves them out. Draw the logos as plotly layout images instead, one per team, positioned in data units. A small helper builds them from sdv_logo_url(). The NFL’s 2025 offenses and defenses:

logo_images <- function(teams, x, y, sport, sizex, sizey = sizex, xref = "x", yref = "y") {
  urls <- sdv_logo_url(teams, league = sport)
  lapply(seq_along(urls), function(i) {
    list(
      source = urls[i], xref = xref, yref = yref, x = x[i], y = y[i],
      sizex = sizex, sizey = sizey, xanchor = "center", yanchor = "middle", layer = "above"
    )
  })
}
nfl_weeks <- nflreadr::load_team_stats(2025, summary_level = "week") |>
  as.data.frame() |>
  filter(season_type == "REG") |>
  mutate(plays = attempts + sacks_suffered + carries, epa = passing_epa + rushing_epa)
nfl <- inner_join(
  summarise(nfl_weeks, off_epa = sum(epa) / sum(plays), .by = team),
  nfl_weeks |>
    mutate(team = opponent_team) |>
    summarise(def_epa = sum(epa) / sum(plays), .by = team),
  by = "team"
) |>
  arrange(team)

p_nfl <- ggplot(nfl, aes(x = off_epa, y = def_epa)) +
  geom_point(aes(text = sprintf("%s<br>offense %+.3f<br>defense %+.3f", team, off_epa, def_epa)),
    alpha = 0
  ) +
  labs(
    title = "NFL offense vs defense, 2025",
    x = "Offense: EPA per play", y = "Defense: EPA per play allowed"
  ) +
  theme_minimal()

ggplotly(p_nfl, tooltip = "text") |>
  layout(
    images = logo_images(nfl$team, nfl$off_epa, nfl$def_epa, "nfl",
      sizex = 0.025, sizey = 0.025
    ),
    yaxis = list(autorange = "reversed") # good defense at the top
  )

text is an aesthetic only ggplotly reads, so ggplot2 warns that it is unknown (the chunk hides that warning). sizex and sizey are in axis units, and the image keeps its shape inside that box; here both axes run in EPA, so one size fits. Reverse an axis in layout() rather than with scale_y_reverse(), which ggplotly turns into negative values.

6. plotly: logos as markers, built natively

Without ggplot2, plot_ly() draws a transparent marker trace that carries the hover text, and the logos go on as layout images. Set the axis ranges yourself and give the logos a margin, since plotly sizes its autorange from the markers only. NHL goals for and against per game, 2025-26:

nhl <- fastRhockey::load_nhl_team_box(season) |>
  as.data.frame() |>
  filter(substr(game_id, 5, 6) == "02") |>
  mutate(team = sdv_resolve(team_abbrev, league = "nhl")) |>
  summarise(games = n(), gf = mean(goals), ga = mean(goals_against), .by = team) |>
  arrange(team)

pad <- 0.15
plot_ly(
  nhl, x = ~gf, y = ~ga, type = "scatter", mode = "markers",
  marker = list(size = 28, opacity = 0),
  text = ~sprintf("%s: %.2f for, %.2f against", team, gf, ga), hoverinfo = "text"
) |>
  layout(
    title = list(text = "NHL goals per game, 2025-26 regular season", x = 0.02),
    xaxis = list(title = "Goals for per game", range = range(nhl$gf) + c(-pad, pad)),
    yaxis = list(title = "Goals against per game", range = rev(range(nhl$ga) + c(-pad, pad))),
    images = logo_images(nhl$team, nhl$gf, nhl$ga, "nhl", sizex = 0.12, sizey = 0.12)
  )

A range given high-to-low reverses the axis, here so good defense is at the top.

add_sdv_logos() does the same placement for you, in data units, so there is no helper to write. It takes the positions and the teams, and its size is the logo height as a share of the y-axis span:

plot_ly(
  nhl, x = ~gf, y = ~ga, type = "scatter", mode = "markers",
  marker = list(size = 28, opacity = 0),
  text = ~sprintf("%s: %.2f for, %.2f against", team, gf, ga), hoverinfo = "text"
) |>
  layout(
    xaxis = list(title = "Goals for per game", range = range(nhl$gf) + c(-pad, pad)),
    yaxis = list(title = "Goals against per game", range = rev(range(nhl$ga) + c(-pad, pad)))
  ) |>
  add_sdv_logos(nhl$gf, nhl$ga, nhl$team, "nhl", size = 0.08)

ggplotly() is the wrong tool for this because it drops layout images, so build the chart with plot_ly() and add the marks with these helpers.

7. plotly: logos on a category axis

Put each logo under its bar by giving the image yref = "paper" (0 is the bottom of the plot area) and yanchor = "top", then hide the tick labels and grow the bottom margin to make room. On a category axis, bars sit at positions 0, 1, 2, and so on. MLB’s 2026 run differential:

mlb_diff <- mlb |>
  arrange(desc(run_diff), team) |>
  mutate(fill = unname(sdv_team_colors("mlb", team)))

plot_ly(
  mlb_diff, x = ~factor(team, levels = team), y = ~run_diff, type = "bar",
  marker = list(color = ~fill),
  text = ~sprintf("%s: %+d runs", name, run_diff), hoverinfo = "text", textposition = "none"
) |>
  layout(
    title = list(text = "MLB run differential, 2026 regular season", x = 0.02),
    xaxis = list(title = "", showticklabels = FALSE),
    yaxis = list(title = "Run differential"),
    images = logo_images(mlb_diff$team, seq_len(nrow(mlb_diff)) - 1, rep(0, nrow(mlb_diff)), "mlb",
      sizex = 0.9, sizey = 0.08, yref = "paper"
    ) |> lapply(modifyList, list(yanchor = "top")),
    margin = list(b = 50, t = 50)
  )

sizey is now a share of the plot’s height (paper units), and sizex a share of one category’s slot.

sdv_axis_logos() does all of that in one call, hiding the tick labels and placing a logo under each category:

plot_ly(
  mlb_diff, x = ~factor(team, levels = team), y = ~run_diff, type = "bar",
  marker = list(color = ~fill)
) |>
  layout(xaxis = list(title = ""), yaxis = list(title = "Run differential"), margin = list(b = 50)) |>
  sdv_axis_logos(mlb_diff$team, "mlb")

8. reactable: a sortable, searchable table

For a table rather than a chart, reactable sorts, searches and pages in the browser, and sdvplotR supplies the cell renderers: reactable_sdv_logos() for a logo column and reactable_sdv_team_color_bar() for a bar in each row’s team color. The SEC’s 2025 football season from ESPN’s schedule file:

sec <- sdv_teams("cfb") |>
  filter(conference_id == "cfb:sec") |>
  select(team = abbr, school = location)

games <- cfbfastR::load_espn_cfb_schedules(2025) |> as.data.frame()
sec_table <- bind_rows(
  transmute(games, team = home_abbreviation, pf = home_score, pa = away_score),
  transmute(games, team = away_abbreviation, pf = away_score, pa = home_score)
) |>
  inner_join(sec, by = "team") |>
  summarise(
    W = sum(pf > pa), L = sum(pf < pa),
    PF = round(mean(pf), 1), PA = round(mean(pa), 1), Margin = round(mean(pf - pa), 1),
    .by = c(team, school)
  ) |>
  arrange(desc(Margin), team)

reactable::reactable(
  sec_table,
  searchable = TRUE, defaultPageSize = 8, highlight = TRUE, compact = TRUE,
  columns = list(
    team = reactable::colDef(name = "", cell = reactable_sdv_logos("cfb", height = 26), html = TRUE, width = 50),
    school = reactable::colDef(name = "School", minWidth = 120),
    Margin = reactable::colDef(
      style = reactable_sdv_team_color_bg(sec_table, "team", "cfb", alpha = 0.25)
    )
  )
)

The reactable integration article covers the other renderers: headshots, logo column headers and team-colored rows.

9. ggiraph: one call for the images and the hover

geom_sdv_logos_interactive() is geom_sdv_logos() plus the hover layer of recipe 1, so tooltip and data_id go in the same aes() as team. The wordmark and headshot geoms have _interactive() versions too. A small hand-made table of NFL teams:

demo <- data.frame(x = 1:3, y = c(2, 3, 1), team = c("KC", "BUF", "BAL"))
p_demo <- ggplot(demo, aes(x, y)) +
  geom_sdv_logos_interactive(
    aes(team = team, tooltip = team, data_id = team),
    league = "nfl", width = 0.15
  ) +
  xlim(0.5, 3.5) + ylim(0.5, 3.5) +
  theme_minimal()
girafe(ggobj = p_demo, width_svg = 5, height_svg = 4)

10. leaflet: logos as map markers

addSdvLogoMarkers() puts a team’s logo on each marker; lng, lat and team take formulas that read the map’s data. A team without a logo keeps leaflet’s default marker, with a warning, so no point goes missing.

stadiums <- data.frame(
  team = c("KC", "BUF", "GB"),
  lon = c(-94.48, -78.79, -88.06),
  lat = c(39.05, 42.77, 44.50)
)
leaflet::leaflet(stadiums) |>
  leaflet::addTiles() |>
  addSdvLogoMarkers(~lon, ~lat, ~team, league = "nfl", label = ~team)

11. Share a chart as one HTML file

htmlwidgets::saveWidget() writes any of these widgets to a file. selfcontained = TRUE inlines the JavaScript, so the file opens anywhere with no folder beside it (it needs pandoc, which ships with RStudio). ggiraph’s logos travel inside the SVG; plotly’s layout images and the reactable logos stay links, so those need the network when the file is opened.

out <- tempfile("widgets-")
dir.create(out)
htmlwidgets::saveWidget(girafe(ggobj = p_mlb), file.path(out, "mlb.html"), selfcontained = TRUE)
htmlwidgets::saveWidget(girafe(ggobj = p_scorers), file.path(out, "scorers.html"), selfcontained = TRUE)
data.frame(
  file = c("mlb.html (30 logos inside)", "scorers.html (headshots by link)"),
  kb = round(file.size(file.path(out, c("mlb.html", "scorers.html"))) / 1024)
)
#>                               file   kb
#> 1       mlb.html (30 logos inside) 9934
#> 2 scorers.html (headshots by link) 8540

The gap between the two files is mostly the 30 embedded logos; the bulk of each is ggiraph’s own JavaScript and fonts, inlined. With selfcontained = FALSE the libraries go in a folder beside the HTML instead, which a web server can share across pages.

12. Export a static PNG for social

A ggiraph chart is a ggplot underneath, so ggsave() makes the static version with no browser involved: 8 x 4.5 in at 150 dpi is a 1200 x 675 px card. The interactive layers draw as their plain counterparts.

png_file <- file.path(out, "mlb.png")
ggsave(png_file, p_mlb, width = 8, height = 4.5, dpi = 150, bg = "white")
magick::image_info(magick::image_read(png_file))[c("width", "height")]
#> # A tibble: 1 × 2
#>   width height
#>   <int>  <int>
#> 1  1200    675
p_mlb

Static version of the MLB runs scored and allowed chart for 2026: each team's logo placed by runs scored and allowed per game, with a dashed line where they are equal.

plotly has no ggsave(); render its HTML in a headless browser with webshot2. That needs Chrome or Chromium on the machine, and a browser started from a scheduled build can stall, so this chunk is shown but not run when the site builds:

p_plotly <- plot_ly(
  nhl, x = ~gf, y = ~ga, type = "scatter", mode = "markers", marker = list(opacity = 0)
) |>
  layout(
    title = "NHL goals per game, 2025-26",
    xaxis = list(range = range(nhl$gf) + c(-0.15, 0.15)),
    yaxis = list(range = rev(range(nhl$ga) + c(-0.15, 0.15))),
    images = logo_images(nhl$team, nhl$gf, nhl$ga, "nhl", sizex = 0.12, sizey = 0.12)
  ) |>
  config(staticPlot = TRUE)
htmlwidgets::saveWidget(p_plotly, file.path(out, "nhl.html"), selfcontained = TRUE)
options(chromote.timeout = 60) # a loaded machine can take over chromote's 10-second default to start Chrome
webshot2::webshot(file.path(out, "nhl.html"), file.path(out, "nhl.png"),
  vwidth = 1200, vheight = 675, delay = 2
)
magick::image_info(magick::image_read(file.path(out, "nhl.png")))[c("width", "height")]

delay gives the logos time to load before the screenshot; raise it on a slow connection.

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}
}