Tables and Reporting

Marc Los Huertos

EA030 · Compiled 2026-04-14

Overview kableExtra gt flextable Inline R Model Output Checklist

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.


1. Overview: Choosing a Package

kableExtra
  • Fastest to write
  • HTML and PDF (LaTeX)
  • Natural fit with knitr::kable()
  • Row/column specs, spanning headers
  • Best for: quick tables in reports
gt
  • Rich grammar — layers like ggplot2
  • HTML output only (RTF/Word via export)
  • Sparklines, colour scales, footnotes
  • Best for: polished HTML reports
flextable
  • Designed for Word / PowerPoint
  • Works in HTML too
  • Cell-level control
  • Best for: documents sent to collaborators
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.


📦 kableExtra
The fastest path from a data frame to a formatted table — one pipe chain from data to publication.

2. kableExtra

2.1 The minimal table

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
  )
PM2.5 and temperature by site and income group
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

2.2 Column specifications

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)))
Column formatting with column_spec()
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

2.3 Row-level visual encoding

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"))
Sites ranked by PM2.5 — ▲ top 3 highest · ✓ below 11 µg/m³
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

2.4 Grouped rows and spanning headers

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))
Summary by income group and urban setting
Pollution & Heat Metrics
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

2.5 Table notes

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)
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.
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

2.6 Scrollable tables for long data

# 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")
All 15 site-income combinations (scrollable)
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
A grammar of tables — build up a table layer by layer, just like ggplot2 builds up a figure.

3. gt

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.

3.1 Basic gt table

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

3.2 Colour scales with 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

3.3 Spanners (grouped column headers)

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

3.4 Striped rows and bold stub

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

3.5 Summary rows

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

📄 flextable
The best choice when your report is a Word document — renders identically in HTML and .docx.

4. flextable

4.1 Basic flextable

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()
Basic flextable output — renders in HTML and Word

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

4.2 Themes

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().

4.3 Conditional formatting and custom header

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

4.4 Merged header cells (spanning)

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
Embed live computed values directly in prose — numbers that update automatically every time you knit.

5. Inline R in RMarkdown

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.

5.1 Basic inline values

The dataset contains `r nrow(aq)` rows across `r n_distinct(aq\(site)&#96; urban settings and &#96;r n_distinct(aq\)income_group)` income groups.
↳ renders as
The dataset contains 15 rows across 5 urban settings and 3 income groups.

5.2 Formatted numbers

Raw numeric output is often too many decimal places for prose. Use round(), format(), or scales::comma() to format inline:

The overall mean PM2.5 was `r round(mean(aq\(pm25), 1)&#96; µg/m³ (SD = &#96;r round(sd(aq\)pm25), 1)`). The highest single-site mean was `r round(max(aq\(pm25), 1)&#96; µg/m³ recorded at &#96;r aq\)site[which.max(aq$pm25)]`.
↳ renders as
The overall mean PM2.5 was 14 µg/m³ (SD = 3.2). The highest single-site mean was 19.4 µg/m³ recorded at Parking Lot.
# 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
Low-income sites averaged `r round(pm25_low, 1)` µg/m³, compared to `r round(pm25_high, 1)` µg/m³ at high-income sites — a difference of `r round(pm25_diff, 1)` µg/m³ (`r round(pct_diff, 0)`% higher).
↳ renders as
Low-income sites averaged 15 µg/m³, compared to 12.8 µg/m³ at high-income sites — a difference of 2.3 µg/m³ (18% higher).

5.3 Inline statistical results

# 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)
A one-way ANOVA indicated that PM2.5 differed significantly by income group (F(`r df1`, `r df2`) = `r f_val`, p `r p_fmt`). Canopy cover was negatively correlated with PM2.5 (r = `r round(r_val, 2)`).
↳ renders as
A one-way ANOVA indicated that PM2.5 differed significantly by income group (F(2, 12) = 0.6, p = 0.567). Canopy cover was negatively correlated with PM2.5 (r = -0.99).

5.4 Inline dates and counts

This report was compiled on `r format(Sys.Date(), “%B %d, %Y”)`. Of the `r nrow(aq)` site-group combinations, `r sum(aq\(pm25 > 12)&#96; (&#96;r round(mean(aq\)pm25 > 12) * 100, 0)`%) exceeded the EPA annual PM2.5 standard of 12 µg/m³.
↳ renders as
This report was compiled on April 14, 2026. Of the 15 site-group combinations, 9 (60%) exceeded the EPA annual PM2.5 standard of 12 µg/m³.

⚠️ 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.

5.5 The 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.


6. Reporting Model Output

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.

6.1 broom::tidy() + kableExtra

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"))
</.001>
OLS regression: PM2.5 ~ canopy + temperature + income group. Significance codes: *** p<.001 p .>
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 ***

6.2 Model fit statistics

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")
Model fit statistics from glance()
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

6.3 Reporting a model result inline

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)
The model explained `r r2 * 100`% of the variance in PM2.5 (adj. R² = `r adj_r2`; F(`r f_df1`, `r f_df2`) = `r f_stat`, p `r f_p_fmt`). Each additional percent of canopy cover was associated with a `r abs(canopy_coef)` µg/m³ `r ifelse(canopy_coef < 0, “decrease”, “increase”)` in PM2.5 (p = `r canopy_p`).
↳ renders as
The model explained 99.8% of the variance in PM2.5 (adj. R² = 0.997; F(4, 10) = 1091.96, p < 0.001). Each additional percent of canopy cover was associated with a 0.15 µg/m³ decrease in PM2.5 (p = 0.0027).

6.4 gt model table with colour

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

7. Checklist

✅ 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.


🔑 Tables and Reporting — key takeaways


What’s next?