Prof. Marc Los Huertos • Pomona College
What this module covers. A polished table communicates results far better than a wall of console output. This module covers three packages for publication-quality tables — kableExtra (fast, HTML/PDF, integrates with knitr), gt (grammar of tables, highly customisable HTML), and flextable (Word-ready) — plus the art of weaving computed values directly into your prose with inline R so numbers never go stale.
knitr::kable()
| Feature | kableExtra | gt | flextable |
|---|---|---|---|
| HTML output | ✓ | ✓ | ✓ |
| PDF / LaTeX | ✓ | — | — |
| Word / .docx | — | limited | ✓ |
| Colour fills | ✓ | ✓ | ✓ |
| Sparklines | — | ✓ | — |
| Spanning headers | ✓ | ✓ | ✓ |
results='asis' needed |
✓ | ✓ | ✓ |
| Learning curve | low | medium | medium |
💡 Always set results=‘asis’ for HTML tables Any
chunk that outputs HTML table markup — kable(),
gt(), flextable() — must have
results=‘asis’ in its chunk header, otherwise the raw HTML
appears as text in your document rather than rendering as a table. This
is the single most common table-related error in R Markdown.
knitr::kable() converts a data frame to a markdown or
HTML table. kableExtra adds a pipe-based API on top to
style it.
aq |>
select(site, income_group, pm25, temp_c) |>
kable(
digits = 1,
caption = "PM2.5 and temperature by site and income group",
col.names = c("Site", "Income group", "PM2.5 (µg/m³)", "Temp (°C)")
) |>
kable_styling(
bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE
)
| Site | Income group | PM2.5 (µg/m³) | Temp (°C) |
|---|---|---|---|
| School | Low-income | 14.1 | 34.8 |
| Park | Low-income | 11.3 | 33.1 |
| Commercial | Low-income | 17.6 | 36.2 |
| Parking Lot | Low-income | 19.4 | 37.5 |
| Playground | Low-income | 12.8 | 33.9 |
| School | Middle-income | 13.2 | 34.1 |
| Park | Middle-income | 10.5 | 32.4 |
| Commercial | Middle-income | 16.1 | 35.5 |
| Parking Lot | Middle-income | 18.7 | 36.8 |
| Playground | Middle-income | 11.9 | 33.2 |
| School | High-income | 11.8 | 33.0 |
| Park | High-income | 9.4 | 31.5 |
| Commercial | High-income | 14.8 | 34.6 |
| Parking Lot | High-income | 17.2 | 35.9 |
| Playground | High-income | 10.7 | 32.1 |
column_spec() controls width, bold, italic, colour, and
background for individual columns:
aq |>
select(site, income_group, canopy_pct, pm25, temp_c) |>
kable(
digits = 1,
caption = "Column formatting with column_spec()",
col.names = c("Site", "Income", "Canopy (%)", "PM2.5", "Temp (°C)")
) |>
kable_styling(full_width = FALSE, font_size = 13) |>
column_spec(1, bold = TRUE, color = "#1e3a2f") |>
column_spec(3, width = "8em",
background = spec_color(aq$canopy_pct,
option = "YlGn", scale_from = c(0,100))) |>
column_spec(4, color = spec_color(aq$pm25,
option = "RdYlGn",
direction = -1,
scale_from = c(8, 22)))
| Site | Income | Canopy (%) | PM2.5 | Temp (°C) |
|---|---|---|---|---|
| School | Low-income | 38.2 | 14.1 | 34.8 |
| Park | Low-income | 52.4 | 11.3 | 33.1 |
| Commercial | Low-income | 21.8 | 17.6 | 36.2 |
| Parking Lot | Low-income | 12.3 | 19.4 | 37.5 |
| Playground | Low-income | 44.7 | 12.8 | 33.9 |
| School | Middle-income | 46.1 | 13.2 | 34.1 |
| Park | Middle-income | 58.9 | 10.5 | 32.4 |
| Commercial | Middle-income | 29.4 | 16.1 | 35.5 |
| Parking Lot | Middle-income | 18.6 | 18.7 | 36.8 |
| Playground | Middle-income | 51.2 | 11.9 | 33.2 |
| School | High-income | 55.3 | 11.8 | 33.0 |
| Park | High-income | 67.2 | 9.4 | 31.5 |
| Commercial | High-income | 38.1 | 14.8 | 34.6 |
| Parking Lot | High-income | 25.7 | 17.2 | 35.9 |
| Playground | High-income | 59.8 | 10.7 | 32.1 |
Rather than relying on row_spec() (which requires xml2
HTML parsing that varies by kableExtra version), the most reliable
approach is to encode row-level signals directly in the data before
passing to kable() — for example, prepending a symbol to a
column’s value:
aq_ranked <- aq |>
arrange(desc(pm25)) |>
select(site, income_group, pm25, canopy_pct) |>
mutate(
# Encode rank visually in the PM2.5 column itself
pm25_label = case_when(
row_number() <= 3 ~ paste0("▲ ", round(pm25, 1)),
pm25 < 11 ~ paste0("✓ ", round(pm25, 1)),
TRUE ~ as.character(round(pm25, 1))
)
) |>
select(site, income_group, pm25_label, canopy_pct)
aq_ranked |>
kable(
digits = 1,
caption = "Sites ranked by PM2.5 — ▲ top 3 highest · ✓ below 11 µg/m³",
col.names = c("Site", "Income", "PM2.5 (µg/m³)", "Canopy (%)")
) |>
kable_styling(full_width = FALSE,
bootstrap_options = c("striped", "hover", "condensed"))
| Site | Income | PM2.5 (µg/m³) | Canopy (%) |
|---|---|---|---|
| Parking Lot | Low-income | ▲ 19.4 | 12.3 |
| Parking Lot | Middle-income | ▲ 18.7 | 18.6 |
| Commercial | Low-income | ▲ 17.6 | 21.8 |
| Parking Lot | High-income | 17.2 | 25.7 |
| Commercial | Middle-income | 16.1 | 29.4 |
| Commercial | High-income | 14.8 | 38.1 |
| School | Low-income | 14.1 | 38.2 |
| School | Middle-income | 13.2 | 46.1 |
| Playground | Low-income | 12.8 | 44.7 |
| Playground | Middle-income | 11.9 | 51.2 |
| School | High-income | 11.8 | 55.3 |
| Park | Low-income | 11.3 | 52.4 |
| Playground | High-income | ✓ 10.7 | 59.8 |
| Park | Middle-income | ✓ 10.5 | 58.9 |
| Park | High-income | ✓ 9.4 | 67.2 |
aq |>
group_by(income_group, site) |>
summarise(
`PM2.5 (µg/m³)` = round(mean(pm25), 1),
`Canopy (%)` = round(mean(canopy_pct), 1),
`Temp (°C)` = round(mean(temp_c), 1),
.groups = "drop"
) |>
select(-income_group) |>
kable(caption = "Summary by income group and urban setting") |>
kable_styling(full_width = FALSE) |>
pack_rows("Low-income", 1, 5, bold = TRUE, color = "#c0392b") |>
pack_rows("Middle-income", 6, 10, bold = TRUE, color = "#e67e22") |>
pack_rows("High-income", 11, 15, bold = TRUE, color = "#27ae60") |>
add_header_above(c(" " = 1, "Pollution & Heat Metrics" = 3))
| site | PM2.5 (µg/m³) | Canopy (%) | Temp (°C) |
|---|---|---|---|
| Low-income | |||
| School | 14.1 | 38.2 | 34.8 |
| Park | 11.3 | 52.4 | 33.1 |
| Commercial | 17.6 | 21.8 | 36.2 |
| Parking Lot | 19.4 | 12.3 | 37.5 |
| Playground | 12.8 | 44.7 | 33.9 |
| Middle-income | |||
| School | 13.2 | 46.1 | 34.1 |
| Park | 10.5 | 58.9 | 32.4 |
| Commercial | 16.1 | 29.4 | 35.5 |
| Parking Lot | 18.7 | 18.6 | 36.8 |
| Playground | 11.9 | 51.2 | 33.2 |
| High-income | |||
| School | 11.8 | 55.3 | 33.0 |
| Park | 9.4 | 67.2 | 31.5 |
| Commercial | 14.8 | 38.1 | 34.6 |
| Parking Lot | 17.2 | 25.7 | 35.9 |
| Playground | 10.7 | 59.8 | 32.1 |
The cleanest cross-version approach is to embed notes directly in the
caption argument using paste(). This works
regardless of kableExtra version and renders identically in HTML and
PDF:
aq |>
filter(income_group == "Low-income") |>
select(site, pm25, canopy_pct, temp_c) |>
kable(
digits = 1,
caption = paste("Low-income sites (n = 36 per site).",
"(a) PM2.5 is the 24-hour mean; EPA NAAQS annual standard = 9 µg/m³.",
"(b) Canopy cover estimated from 1-m resolution aerial imagery."),
col.names = c("Site", "PM2.5 (a)", "Canopy % (b)", "Temp (°C)")
) |>
kable_styling(full_width = FALSE)
| Site | PM2.5 (a) | Canopy % (b) | Temp (°C) |
|---|---|---|---|
| School | 14.1 | 38.2 | 34.8 |
| Park | 11.3 | 52.4 | 33.1 |
| Commercial | 17.6 | 21.8 | 36.2 |
| Parking Lot | 19.4 | 12.3 | 37.5 |
| Playground | 12.8 | 44.7 | 33.9 |
# Useful when n > ~20 rows — wraps in a scrollable div
aq |>
arrange(income_group, desc(pm25)) |>
kable(
digits = 1,
caption = "All 15 site-income combinations (scrollable)"
) |>
kable_styling(full_width = TRUE) |>
scroll_box(height = "280px")
| site | income_group | canopy_pct | pm25 | temp_c | n |
|---|---|---|---|---|---|
| Parking Lot | Low-income | 12.3 | 19.4 | 37.5 | 36 |
| Commercial | Low-income | 21.8 | 17.6 | 36.2 | 36 |
| School | Low-income | 38.2 | 14.1 | 34.8 | 36 |
| Playground | Low-income | 44.7 | 12.8 | 33.9 | 36 |
| Park | Low-income | 52.4 | 11.3 | 33.1 | 36 |
| Parking Lot | Middle-income | 18.6 | 18.7 | 36.8 | 36 |
| Commercial | Middle-income | 29.4 | 16.1 | 35.5 | 36 |
| School | Middle-income | 46.1 | 13.2 | 34.1 | 36 |
| Playground | Middle-income | 51.2 | 11.9 | 33.2 | 36 |
| Park | Middle-income | 58.9 | 10.5 | 32.4 | 36 |
| Parking Lot | High-income | 25.7 | 17.2 | 35.9 | 36 |
| Commercial | High-income | 38.1 | 14.8 | 34.6 | 36 |
| School | High-income | 55.3 | 11.8 | 33.0 | 36 |
| Playground | High-income | 59.8 | 10.7 | 32.1 | 36 |
| Park | High-income | 67.2 | 9.4 | 31.5 | 36 |
gt structures a table as a set of named
parts: the spanner row, the column labels, the body,
the stub (row labels), and the source note. You add formatting by
specifying which part, which columns, and which rows to style.
aq |>
select(site, income_group, canopy_pct, pm25, temp_c) |>
gt() |>
tab_header(
title = md("**Urban Heat and Air Quality by Site**"),
subtitle = "Canopy cover, PM2.5, and mean air temperature"
) |>
cols_label(
site = "Site",
income_group = "Income group",
canopy_pct = html("Canopy<br>(%)"),
pm25 = html("PM2.5<br>(µg/m³)"),
temp_c = html("Temp<br>(°C)")
) |>
fmt_number(columns = c(canopy_pct, pm25, temp_c), decimals = 1) |>
tab_source_note(
source_note = md("Data collected Spring 2026 · EA030 field campaign")
)
| Urban Heat and Air Quality by Site | ||||
| Canopy cover, PM2.5, and mean air temperature | ||||
| Site | Income group | Canopy (%) |
PM2.5 (µg/m³) |
Temp (°C) |
|---|---|---|---|---|
| School | Low-income | 38.2 | 14.1 | 34.8 |
| Park | Low-income | 52.4 | 11.3 | 33.1 |
| Commercial | Low-income | 21.8 | 17.6 | 36.2 |
| Parking Lot | Low-income | 12.3 | 19.4 | 37.5 |
| Playground | Low-income | 44.7 | 12.8 | 33.9 |
| School | Middle-income | 46.1 | 13.2 | 34.1 |
| Park | Middle-income | 58.9 | 10.5 | 32.4 |
| Commercial | Middle-income | 29.4 | 16.1 | 35.5 |
| Parking Lot | Middle-income | 18.6 | 18.7 | 36.8 |
| Playground | Middle-income | 51.2 | 11.9 | 33.2 |
| School | High-income | 55.3 | 11.8 | 33.0 |
| Park | High-income | 67.2 | 9.4 | 31.5 |
| Commercial | High-income | 38.1 | 14.8 | 34.6 |
| Parking Lot | High-income | 25.7 | 17.2 | 35.9 |
| Playground | High-income | 59.8 | 10.7 | 32.1 |
| Data collected Spring 2026 · EA030 field campaign | ||||
data_color()aq |>
select(site, income_group, canopy_pct, pm25, temp_c) |>
gt() |>
tab_header(title = md("**Colour-coded PM2.5 and Canopy Cover**")) |>
cols_label(site = "Site", income_group = "Income",
canopy_pct = "Canopy (%)", pm25 = "PM2.5", temp_c = "Temp (°C)") |>
fmt_number(columns = c(canopy_pct, pm25, temp_c), decimals = 1) |>
data_color(
columns = pm25,
method = "numeric",
palette = c("#27ae60","#f6c90e","#c0392b")
) |>
data_color(
columns = canopy_pct,
method = "numeric",
palette = c("#fffde7","#1b5e20")
)
| Colour-coded PM2.5 and Canopy Cover | ||||
| Site | Income | Canopy (%) | PM2.5 | Temp (°C) |
|---|---|---|---|---|
| School | Low-income | 38.2 | 14.1 | 34.8 |
| Park | Low-income | 52.4 | 11.3 | 33.1 |
| Commercial | Low-income | 21.8 | 17.6 | 36.2 |
| Parking Lot | Low-income | 12.3 | 19.4 | 37.5 |
| Playground | Low-income | 44.7 | 12.8 | 33.9 |
| School | Middle-income | 46.1 | 13.2 | 34.1 |
| Park | Middle-income | 58.9 | 10.5 | 32.4 |
| Commercial | Middle-income | 29.4 | 16.1 | 35.5 |
| Parking Lot | Middle-income | 18.6 | 18.7 | 36.8 |
| Playground | Middle-income | 51.2 | 11.9 | 33.2 |
| School | High-income | 55.3 | 11.8 | 33.0 |
| Park | High-income | 67.2 | 9.4 | 31.5 |
| Commercial | High-income | 38.1 | 14.8 | 34.6 |
| Parking Lot | High-income | 25.7 | 17.2 | 35.9 |
| Playground | High-income | 59.8 | 10.7 | 32.1 |
aq |>
select(site, income_group, canopy_pct, pm25, temp_c) |>
gt() |>
tab_header(title = md("**Site-Level Environmental Metrics**")) |>
cols_label(
site = "Site", income_group = "Income",
canopy_pct = "Canopy (%)", pm25 = "PM2.5 (µg/m³)", temp_c = "Temp (°C)"
) |>
fmt_number(columns = c(canopy_pct, pm25, temp_c), decimals = 1) |>
tab_spanner(
label = md("🌳 **Vegetation**"),
columns = canopy_pct
) |>
tab_spanner(
label = md("🌡️ **Heat & Air Quality**"),
columns = c(pm25, temp_c)
) |>
tab_style(
style = cell_fill(color = "#e8f5e9"),
locations = cells_column_spanners()
)
| Site-Level Environmental Metrics | ||||
| Site | Income |
🌳 Vegetation
|
🌡️ Heat & Air Quality
|
|
|---|---|---|---|---|
| Canopy (%) | PM2.5 (µg/m³) | Temp (°C) | ||
| School | Low-income | 38.2 | 14.1 | 34.8 |
| Park | Low-income | 52.4 | 11.3 | 33.1 |
| Commercial | Low-income | 21.8 | 17.6 | 36.2 |
| Parking Lot | Low-income | 12.3 | 19.4 | 37.5 |
| Playground | Low-income | 44.7 | 12.8 | 33.9 |
| School | Middle-income | 46.1 | 13.2 | 34.1 |
| Park | Middle-income | 58.9 | 10.5 | 32.4 |
| Commercial | Middle-income | 29.4 | 16.1 | 35.5 |
| Parking Lot | Middle-income | 18.6 | 18.7 | 36.8 |
| Playground | Middle-income | 51.2 | 11.9 | 33.2 |
| School | High-income | 55.3 | 11.8 | 33.0 |
| Park | High-income | 67.2 | 9.4 | 31.5 |
| Commercial | High-income | 38.1 | 14.8 | 34.6 |
| Parking Lot | High-income | 25.7 | 17.2 | 35.9 |
| Playground | High-income | 59.8 | 10.7 | 32.1 |
aq |>
filter(income_group %in% c("Low-income","High-income")) |>
select(income_group, site, pm25, canopy_pct) |>
arrange(income_group, desc(pm25)) |>
gt(groupname_col = "income_group") |>
tab_header(title = md("**PM2.5 Ranking: Low vs High-income Sites**")) |>
cols_label(site = "Site", pm25 = "PM2.5 (µg/m³)", canopy_pct = "Canopy (%)") |>
fmt_number(columns = c(pm25, canopy_pct), decimals = 1) |>
opt_row_striping() |>
tab_style(
style = list(cell_text(weight = "bold"),
cell_fill(color = "#1e3a2f"),
cell_text(color = "white")),
locations = cells_row_groups()
) |>
tab_style(
style = cell_text(color = "#c0392b", weight = "bold"),
locations = cells_body(columns = pm25,
rows = pm25 > 17)
)
| PM2.5 Ranking: Low vs High-income Sites | ||
| Site | PM2.5 (µg/m³) | Canopy (%) |
|---|---|---|
| Low-income | ||
| Parking Lot | 19.4 | 12.3 |
| Commercial | 17.6 | 21.8 |
| School | 14.1 | 38.2 |
| Playground | 12.8 | 44.7 |
| Park | 11.3 | 52.4 |
| High-income | ||
| Parking Lot | 17.2 | 25.7 |
| Commercial | 14.8 | 38.1 |
| School | 11.8 | 55.3 |
| Playground | 10.7 | 59.8 |
| Park | 9.4 | 67.2 |
aq |>
select(income_group, site, pm25, canopy_pct, temp_c) |>
gt(groupname_col = "income_group") |>
tab_header(title = md("**Group Summaries with summary_rows()**")) |>
cols_label(site = "Site", pm25 = "PM2.5", canopy_pct = "Canopy (%)",
temp_c = "Temp (°C)") |>
fmt_number(columns = c(pm25, canopy_pct, temp_c), decimals = 1) |>
summary_rows(
groups = TRUE,
columns = c(pm25, canopy_pct, temp_c),
fns = list(Mean = ~ mean(., na.rm = TRUE)),
fmt = ~ fmt_number(., decimals = 1),
side = "bottom"
)
| Group Summaries with summary_rows() | |||
| Site | PM2.5 | Canopy (%) | Temp (°C) |
|---|---|---|---|
| Low-income | |||
| School | 14.1 | 38.2 | 34.8 |
| Park | 11.3 | 52.4 | 33.1 |
| Commercial | 17.6 | 21.8 | 36.2 |
| Parking Lot | 19.4 | 12.3 | 37.5 |
| Playground | 12.8 | 44.7 | 33.9 |
| Middle-income | |||
| School | 13.2 | 46.1 | 34.1 |
| Park | 10.5 | 58.9 | 32.4 |
| Commercial | 16.1 | 29.4 | 35.5 |
| Parking Lot | 18.7 | 18.6 | 36.8 |
| Playground | 11.9 | 51.2 | 33.2 |
| High-income | |||
| School | 11.8 | 55.3 | 33.0 |
| Park | 9.4 | 67.2 | 31.5 |
| Commercial | 14.8 | 38.1 | 34.6 |
| Parking Lot | 17.2 | 25.7 | 35.9 |
| Playground | 10.7 | 59.8 | 32.1 |
aq |>
select(site, income_group, canopy_pct, pm25, temp_c) |>
flextable() |>
set_header_labels(
site = "Site",
income_group = "Income group",
canopy_pct = "Canopy (%)",
pm25 = "PM2.5 (µg/m³)",
temp_c = "Temp (°C)"
) |>
colformat_double(digits = 1) |>
set_caption("Basic flextable output — renders in HTML and Word") |>
autofit()
Site | Income group | Canopy (%) | PM2.5 (µg/m³) | Temp (°C) |
|---|---|---|---|---|
School | Low-income | 38.2 | 14.1 | 34.8 |
Park | Low-income | 52.4 | 11.3 | 33.1 |
Commercial | Low-income | 21.8 | 17.6 | 36.2 |
Parking Lot | Low-income | 12.3 | 19.4 | 37.5 |
Playground | Low-income | 44.7 | 12.8 | 33.9 |
School | Middle-income | 46.1 | 13.2 | 34.1 |
Park | Middle-income | 58.9 | 10.5 | 32.4 |
Commercial | Middle-income | 29.4 | 16.1 | 35.5 |
Parking Lot | Middle-income | 18.6 | 18.7 | 36.8 |
Playground | Middle-income | 51.2 | 11.9 | 33.2 |
School | High-income | 55.3 | 11.8 | 33.0 |
Park | High-income | 67.2 | 9.4 | 31.5 |
Commercial | High-income | 38.1 | 14.8 | 34.6 |
Parking Lot | High-income | 25.7 | 17.2 | 35.9 |
Playground | High-income | 59.8 | 10.7 | 32.1 |
flextable ships with several pre-built themes that apply a complete visual style in one function call:
aq |>
select(site, income_group, pm25, canopy_pct, temp_c) |>
flextable() |>
set_header_labels(
site = "Site", income_group = "Income",
pm25 = "PM2.5", canopy_pct = "Canopy (%)", temp_c = "Temp (°C)"
) |>
colformat_double(digits = 1) |>
theme_booktabs() |> # clean academic style
autofit()
Site | Income | PM2.5 | Canopy (%) | Temp (°C) |
|---|---|---|---|---|
School | Low-income | 14.1 | 38.2 | 34.8 |
Park | Low-income | 11.3 | 52.4 | 33.1 |
Commercial | Low-income | 17.6 | 21.8 | 36.2 |
Parking Lot | Low-income | 19.4 | 12.3 | 37.5 |
Playground | Low-income | 12.8 | 44.7 | 33.9 |
School | Middle-income | 13.2 | 46.1 | 34.1 |
Park | Middle-income | 10.5 | 58.9 | 32.4 |
Commercial | Middle-income | 16.1 | 29.4 | 35.5 |
Parking Lot | Middle-income | 18.7 | 18.6 | 36.8 |
Playground | Middle-income | 11.9 | 51.2 | 33.2 |
School | High-income | 11.8 | 55.3 | 33.0 |
Park | High-income | 9.4 | 67.2 | 31.5 |
Commercial | High-income | 14.8 | 38.1 | 34.6 |
Parking Lot | High-income | 17.2 | 25.7 | 35.9 |
Playground | High-income | 10.7 | 59.8 | 32.1 |
Other themes worth knowing: theme_vanilla(),
theme_alafoli(), theme_zebra(),
theme_apa().
ft <- aq |>
select(site, income_group, pm25, canopy_pct, temp_c) |>
flextable() |>
set_header_labels(
site = "Site", income_group = "Income",
pm25 = "PM2.5", canopy_pct = "Canopy (%)", temp_c = "Temp (°C)"
) |>
colformat_double(digits = 1) |>
# Header background
bg(part = "header", bg = "#1e3a2f") |>
color(part = "header", color = "white") |>
bold(part = "header") |>
# Highlight high PM2.5 rows
bg(i = ~ pm25 > 17, bg = "#fff3e0") |>
color(i = ~ pm25 > 17, j = "pm25", color = "#c8832a") |>
bold(i = ~ pm25 > 17, j = "pm25") |>
# Shade alternating rows
bg(i = seq(2, nrow(aq), 2), bg = "#f4f9f6", part = "body") |>
autofit()
ft
Site | Income | PM2.5 | Canopy (%) | Temp (°C) |
|---|---|---|---|---|
School | Low-income | 14.1 | 38.2 | 34.8 |
Park | Low-income | 11.3 | 52.4 | 33.1 |
Commercial | Low-income | 17.6 | 21.8 | 36.2 |
Parking Lot | Low-income | 19.4 | 12.3 | 37.5 |
Playground | Low-income | 12.8 | 44.7 | 33.9 |
School | Middle-income | 13.2 | 46.1 | 34.1 |
Park | Middle-income | 10.5 | 58.9 | 32.4 |
Commercial | Middle-income | 16.1 | 29.4 | 35.5 |
Parking Lot | Middle-income | 18.7 | 18.6 | 36.8 |
Playground | Middle-income | 11.9 | 51.2 | 33.2 |
School | High-income | 11.8 | 55.3 | 33.0 |
Park | High-income | 9.4 | 67.2 | 31.5 |
Commercial | High-income | 14.8 | 38.1 | 34.6 |
Parking Lot | High-income | 17.2 | 25.7 | 35.9 |
Playground | High-income | 10.7 | 59.8 | 32.1 |
aq |>
select(site, income_group, pm25, canopy_pct, temp_c) |>
flextable() |>
set_header_labels(
site = "Site", income_group = "Income",
pm25 = "PM2.5", canopy_pct = "Canopy", temp_c = "Temp"
) |>
add_header_row(
values = c("", "", "Heat & Air Quality", "", ""),
colwidths = c(1, 1, 1, 1, 1)
) |>
# Merge the spanner label across three columns
merge_at(i = 1, j = 3:5, part = "header") |>
align(i = 1, j = 3, align = "center", part = "header") |>
colformat_double(digits = 1) |>
theme_booktabs() |>
autofit()
Heat & Air Quality | ||||
|---|---|---|---|---|
Site | Income | PM2.5 | Canopy | Temp |
School | Low-income | 14.1 | 38.2 | 34.8 |
Park | Low-income | 11.3 | 52.4 | 33.1 |
Commercial | Low-income | 17.6 | 21.8 | 36.2 |
Parking Lot | Low-income | 19.4 | 12.3 | 37.5 |
Playground | Low-income | 12.8 | 44.7 | 33.9 |
School | Middle-income | 13.2 | 46.1 | 34.1 |
Park | Middle-income | 10.5 | 58.9 | 32.4 |
Commercial | Middle-income | 16.1 | 29.4 | 35.5 |
Parking Lot | Middle-income | 18.7 | 18.6 | 36.8 |
Playground | Middle-income | 11.9 | 51.2 | 33.2 |
School | High-income | 11.8 | 55.3 | 33.0 |
Park | High-income | 9.4 | 67.2 | 31.5 |
Commercial | High-income | 14.8 | 38.1 | 34.6 |
Parking Lot | High-income | 17.2 | 25.7 | 35.9 |
Playground | High-income | 10.7 | 59.8 | 32.1 |
⚠️ flextable in PDF output flextable produces HTML
or Word natively. For PDF output you need either
save_as_docx() to create a Word file, or switch to
kableExtra (which uses LaTeX for PDF). Don’t mix flextable and PDF in
the same Rmd unless you have a specific workflow for it.
Inline R is the single most important reporting habit you can develop. It ensures that every number in your prose is derived from the same computation as your tables and figures — so when data change or you fix an error, the prose updates automatically.
The syntax is a backtick, the letter r, a space, an R
expression, and a closing backtick. It must appear on a single line in
your prose.
Raw numeric output is often too many decimal places for prose. Use
round(), format(), or
scales::comma() to format inline:
# Best practice: pre-compute complex values in a chunk, then reference
# the named objects inline — easier to debug than long inline expressions
pm25_low <- aq |> filter(income_group == "Low-income") |> pull(pm25) |> mean()
pm25_high <- aq |> filter(income_group == "High-income") |> pull(pm25) |> mean()
pm25_diff <- pm25_low - pm25_high
pct_diff <- pm25_diff / pm25_high * 100
# Pre-compute statistical test results for inline reporting
aov_fit <- aov(pm25 ~ income_group, data = aq)
aov_tidy <- tidy(aov_fit)
f_val <- round(aov_tidy$statistic[1], 2)
p_val <- aov_tidy$p.value[1]
p_fmt <- ifelse(p_val < 0.001, "< 0.001",
paste0("= ", round(p_val, 3)))
df1 <- aov_tidy$df[1]
df2 <- aov_tidy$df[2]
# Correlation
r_val <- cor(aq$canopy_pct, aq$pm25)
⚠️ Keep inline expressions short and debuggable
Inline R is evaluated at knit time. A long, complex expression that
fails produces an unhelpful error with no chunk label to find it. The
pattern to follow: compute in a named chunk → store in a short variable
→ reference the variable inline. If an inline expression is longer than
round(x, 2), it belongs in a chunk.
glue approach for cleaner inline strings# glue() builds strings by interpolating {expressions} — useful for
# constructing longer templated sentences in chunks
library(glue)
income_summary <- aq |>
group_by(income_group) |>
summarise(mean_pm25 = round(mean(pm25), 1), .groups = "drop")
sentences <- glue_data(
income_summary,
"{income_group} sites: mean PM2.5 = {mean_pm25} µg/m³"
)
sentences
## Low-income sites: mean PM2.5 = 15 µg/m³
## Middle-income sites: mean PM2.5 = 14.1 µg/m³
## High-income sites: mean PM2.5 = 12.8 µg/m³
Using glue() is cleaner than paste() for
building sentences from data frames, and the result can be embedded
inline with Low-income sites: mean PM2.5 = 15 µg/m³ to pull
a specific line.
A clean regression table is one of the most common outputs in an
environmental science report. broom::tidy() extracts model
coefficients as a data frame, which you can then pass to any of the
three table packages.
tidy(lm_fit, conf.int = TRUE) |>
mutate(
sig = case_when(
p.value < 0.001 ~ "***",
p.value < 0.01 ~ "**",
p.value < 0.05 ~ "*",
p.value < 0.1 ~ ".",
TRUE ~ ""
),
across(where(is.numeric), ~ round(.x, 4))
) |>
select(term, estimate, std.error, statistic, p.value, conf.low, conf.high, sig) |>
kable(
caption = "OLS regression: PM2.5 ~ canopy + temperature + income group. Significance codes: *** p<.001 ** p<.01 * p<.05 . p<.10",
col.names = c("Term", "Estimate", "SE", "t", "p-value",
"CI low", "CI high", "Sig.")
) |>
kable_styling(full_width = FALSE,
bootstrap_options = c("striped", "hover", "condensed"))
| Term | Estimate | SE | t | p-value | CI low | CI high | Sig. |
|---|---|---|---|---|---|---|---|
| (Intercept) | 4.5690 | 13.6611 | 0.3345 | 0.7449 | -25.8698 | 35.0078 | |
| canopy_pct | -0.1498 | 0.0378 | -3.9642 | 0.0027 | -0.2340 | -0.0656 | ** |
| temp_c | 0.4429 | 0.3528 | 1.2553 | 0.2379 | -0.3433 | 1.2291 | |
| income_groupMiddle-income | 0.3926 | 0.1171 | 3.3528 | 0.0073 | 0.1317 | 0.6536 | ** |
| income_groupHigh-income | 0.7820 | 0.1247 | 6.2728 | 0.0001 | 0.5042 | 1.0598 | *** |
glance(lm_fit) |>
select(r.squared, adj.r.squared, sigma, statistic, p.value,
df, df.residual, nobs, AIC, BIC) |>
mutate(across(where(is.numeric), ~ round(.x, 4))) |>
pivot_longer(everything(), names_to = "Statistic", values_to = "Value") |>
kable(caption = "Model fit statistics from glance()") |>
kable_styling(full_width = FALSE, font_size = 13) |>
column_spec(1, bold = TRUE, color = "#1e3a2f")
| Statistic | Value |
|---|---|
| r.squared | 0.9977 |
| adj.r.squared | 0.9968 |
| sigma | 0.1804 |
| statistic | 1091.9607 |
| p.value | 0.0000 |
| df | 4.0000 |
| df.residual | 10.0000 |
| nobs | 15.0000 |
| AIC | -2.8975 |
| BIC | 1.3508 |
fit_sum <- summary(lm_fit)
r2 <- round(fit_sum$r.squared, 3)
adj_r2 <- round(fit_sum$adj.r.squared, 3)
f_stat <- round(fit_sum$fstatistic[1], 2)
f_df1 <- fit_sum$fstatistic[2]
f_df2 <- fit_sum$fstatistic[3]
f_p <- pf(fit_sum$fstatistic[1], f_df1, f_df2, lower.tail = FALSE)
f_p_fmt <- ifelse(f_p < 0.001, "< 0.001", paste0("= ", round(f_p, 3)))
canopy_coef <- round(coef(lm_fit)["canopy_pct"], 3)
canopy_p <- round(tidy(lm_fit)$p.value[tidy(lm_fit)$term == "canopy_pct"], 4)
tidy(lm_fit, conf.int = TRUE) |>
mutate(sig = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
p.value < 0.05 ~ "*", TRUE ~ "")) |>
gt() |>
tab_header(
title = md("**Regression Coefficients**"),
subtitle = "PM2.5 ~ canopy cover + temperature + income group"
) |>
cols_label(
term = "Term", estimate = "Estimate",
std.error = "SE", statistic = "t",
p.value = "p-value", conf.low = "CI low",
conf.high = "CI high", sig = "Sig."
) |>
fmt_number(columns = c(estimate, std.error, statistic, conf.low, conf.high),
decimals = 3) |>
fmt_number(columns = p.value, decimals = 4) |>
data_color(
columns = p.value,
method = "numeric",
palette = c("#27ae60","#f6c90e","#e74c3c"),
reverse = TRUE,
domain = c(0, 0.2)
) |>
tab_style(
style = cell_text(weight = "bold"),
locations = cells_body(columns = sig, rows = sig != "")
) |>
tab_footnote(
footnote = "*** p<.001 ** p<.01 * p<.05",
locations = cells_column_labels(columns = sig)
)
| Regression Coefficients | |||||||
| PM2.5 ~ canopy cover + temperature + income group | |||||||
| Term | Estimate | SE | t | p-value | CI low | CI high | Sig.1 |
|---|---|---|---|---|---|---|---|
| (Intercept) | 4.569 | 13.661 | 0.334 | 0.7449 | −25.870 | 35.008 | |
| canopy_pct | −0.150 | 0.038 | −3.964 | 0.0027 | −0.234 | −0.066 | ** |
| temp_c | 0.443 | 0.353 | 1.255 | 0.2379 | −0.343 | 1.229 | |
| income_groupMiddle-income | 0.393 | 0.117 | 3.353 | 0.0073 | 0.132 | 0.654 | ** |
| income_groupHigh-income | 0.782 | 0.125 | 6.273 | 0.0001 | 0.504 | 1.060 | *** |
| 1 *** p<.001 ** p<.01 * p<.05 | |||||||
✅ Before including a table in a report
Every column has a human-readable label (no underscores, units in the header). Numbers are rounded to a sensible number of decimal places — never more than the measurement precision warrants. The caption is self-contained: a reader who only sees the table understands what it shows without reading the surrounding text. Footnotes explain abbreviations, sample sizes, and statistical thresholds.
✅ Before writing a number in prose
Is this value derived from data? If yes, it should be inline R, not typed. Is it rounded appropriately? Is the corresponding table also updated (or is the table the source)? If you remove the inline expression and replace it with the literal number, will the document be wrong after the next data update?
⚠️ Common table mistakes
Forgetting results=‘asis’ (table renders as raw HTML
text). Using kable() inside a for-loop without
results=‘asis’ on the chunk (loop output is silently
swallowed). Rounding inside a kable() call with
digits= but then the number in the inline sentence uses a
different rounding — they disagree. Calling flextable() in
a PDF document.
kableExtra for HTML and PDF reports, gt for
polished standalone HTML, and flextable when delivering a
Word document to a collaborator or committee.
results=‘asis’ is not optional. Any chunk
that outputs HTML table markup must carry this option, or the raw tags
appear as text rather than a rendered table.
r2 <- round(summary(fit)$r.squared, 3) — then use
0.998 in prose. This is debuggable, readable, and
reproducible.
broom::tidy() and glance() unlock
inline model reporting. Extract coefficients and fit statistics
once, store them in named objects, and build your results paragraph from
those objects rather than transcribing numbers by hand.
purrr to generate tables for
many groups at once