Advanced Table Formatting with gt

· 6 min read · Updated March 19, 2026 · intermediate
r tidyverse gt tables data-visualization

Quick Start: Make a Table in 5 Minutes

library(gt)

exibble |>
  gt(rowname_col = "row") |>
  tab_header(title = "Quick Start Table") |>
  fmt_number(columns = num, decimals = 2) |>
  fmt_currency(columns = currency, currency = "USD")

That’s it. Pipe data into gt(), chain formatting, print. Read on for the details.

Recipe 1: Formatted Financial Table

Build a table showing stock prices with currency, dates, and large number formatting.

library(gt)
library(dplyr)

sp500_sample <- sp500 |>
  filter(date >= as.Date("2010-06-07") & date <= as.Date("2010-06-14")) |>
  select(-adj_close)

sp500_sample |>
  gt() |>
  tab_header(
    title = "S&P 500",
    subtitle = "June 7–14, 2010"
  ) |>
  fmt_currency(columns = c(open, close, high, low)) |>
  fmt_date(columns = date, date_style = "wd_m_day_year") |>
  fmt_number(columns = volume, suffixing = TRUE) |>
  cols_align(align = "center", columns = -date) |>
  cols_width(
    date ~ px(140),
    everything() ~ px(100)
  )

What each line does:

  • gt() — creates the table object
  • tab_header() — adds title and subtitle
  • fmt_currency() — formats price columns as USD currency
  • fmt_date() — formats the date column
  • fmt_number(suffixing = TRUE) — turns volume like 131000000 into 131M
  • cols_align(align = "center") — centers all columns except date
  • cols_width() — sets column widths in pixels

Recipe 2: Conditional Cell Styling

Highlight cells that meet a condition — for example, mark high values in green and low values in red.

library(gt)

countrypops |>
  filter(country_code_3 %in% c("CHN", "IND", "USA", "GBR")) |>
  filter(year == 2020) |>
  gt(rowname_col = "country_code_3") |>
  # Green for >200M population, red for <100M
  fmt_number(columns = population, suffixing = TRUE, n_sigfig = 3) |>
  tab_style(
    style = cell_fill(color = "#d4edda"),
    locations = cells_body(
      columns = population,
      rows = population > 200000000
    )
  ) |>
  tab_style(
    style = cell_fill(color = "#f8d7da"),
    locations = cells_body(
      columns = population,
      rows = population < 100000000
    )
  ) |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_body(columns = population)
  ) |>
  cols_align(align = "center") |>
  opt_row_striping()

How the conditional logic works:

  • cells_body(columns = population, rows = population > 200000000) targets only rows where population exceeds 200 million
  • Each tab_style() call adds a new layer of styling — later calls can override earlier ones for the same cells
  • opt_row_striping() adds alternating row shading on top of the conditional fills

Recipe 3: Color Cells from Data Values

Use hex color codes stored in your data to color cells dynamically with from_column().

library(gt)

color_table <- tibble(
  fruit = c("Apple", "Banana", "Cherry"),
  hex = c("#FF5733", "#28B463", "#E74C3C"),
  calories = c(52, 89, 50)
)

color_table |>
  gt(rowname_col = "fruit") |>
  # Initialize color column with empty strings — no sub_missing needed
  cols_add(color = paste0(hex)) |>
  tab_style(
    style = cell_fill(color = from_column(column = "hex")),
    locations = cells_body(columns = color)
  ) |>
  tab_style(
    style = cell_text(font = "Courier New"),
    locations = cells_body()
  ) |>
  cols_width(stub() ~ px(100), everything() ~ px(120)) |>
  cols_hide(columns = hex) |>
  tab_options(table_body.hlines.style = "none")

Key steps:

  1. cols_add(color = paste0(hex)) creates a helper column by copying the hex values — paste0() handles the type correctly without needing rep()
  2. from_column(column = "hex") reads each hex value and applies it as the fill color for the corresponding cell in the color column
  3. cols_hide(columns = hex) hides the original hex column from display
  4. Using font = "Courier New" avoids the system_fonts() dependency from the systemfonts package

Recipe 4: Multi-Level Headers with Spanners and Row Groups

Create a publication-ready table with row groups and multi-column spanners, like you see in financial reports.

library(gt)
library(dplyr)

countrypops |>
  filter(country_code_3 %in% c("CHN", "IND", "USA", "GBR")) |>
  filter(year >= 2015) |>
  # Convert year to character so it's not treated as numeric
  mutate(year = as.character(year)) |>
  pivot_wider(names_from = year, values_from = population) |>
  gt(rowname_col = "country_code_3") |>
  tab_spanner(
    label = "Population (millions)",
    columns = where(is.numeric)
  ) |>
  tab_header(
    title = "Country Population Over Time",
    subtitle = "Selected countries, 2015–2020"
  ) |>
  fmt_number(columns = where(is.numeric), decimals = 0, use_seps = TRUE) |>
  cols_align(align = "center", columns = where(is.numeric)) |>
  cols_width(stub() ~ px(120), everything() ~ px(110)) |>
  tab_style(
    style = cell_fill(color = "#2c3e50"),
    locations = cells_title()
  ) |>
  tab_style(
    style = cell_text(color = "white", weight = "bold"),
    locations = cells_column_spanners()
  ) |>
  opt_row_striping(row_striping_weight = 1) |>
  tab_source_note(source_note = "Source: countrypops dataset (gt package)")

What makes this look polished:

  • mutate(year = as.character(year)) before pivot_wider() ensures year columns are character type, so where(is.numeric) targets only the population columns
  • tab_spanner() creates a multi-column header grouping all year columns
  • Dark header with white text via cell_fill() + cell_text()
  • opt_row_striping() adds alternating row colors
  • tab_source_note() adds attribution at the bottom

Recipe 5: Styled Table with Tab Options

Take full control of the table’s look with tab_options() and the opt_*() family.

library(gt)

exibble |>
  gt(rowname_col = "row") |>
  tab_header(
    title = "Styled Table Example",
    subtitle = "Demonstrating tab_options and opt_* functions"
  ) |>
  fmt_number(columns = num, decimals = 2) |>
  fmt_currency(columns = currency) |>
  fmt_date(columns = date) |>
  tab_options(
    table.background.color = "#f8f9fa",
    table.font.size = "small",
    column_labels.background.color = "#2c3e50",
    column_labels.font.color = "white",
    column_labels.font.weight = "bold",
    row_group.background.color = "#ecf0f1",
    data_row.padding = px(4),
    table_body.hlines.style = "solid",
    table_body.hlines.color = "#dee2e6",
    table_body.vlines.style = "none",
    table.outline.color = "#2c3e50",
    table.outline.width = px(2)
  ) |>
  opt_row_striping(row_striping_weight = 1) |>
  tab_footnote(
    footnote = "Sample footnote",
    locations = cells_column_labels(columns = num)
  ) |>
  opt_footnote_marks(marks = "letters") |>
  tab_source_note(source_note = "Source: exibble dataset (gt package)")

The styling layers:

  1. tab_options() — global table appearance (backgrounds, borders, padding)
  2. opt_row_striping() — alternating row colors
  3. tab_footnote() + opt_footnote_marks() — footnote with letter-style marks
  4. tab_source_note() — attribution at the bottom

Recipe 6: Using dplyr Groups for Row Groups

If your data is already grouped with dplyr::group_by(), gt picks it up automatically.

library(gt)
library(dplyr)

mtcars |>
  mutate(model = rownames(mtcars)) |>
  select(model, mpg, cyl, disp, hp) |>
  group_by(cyl) |>
  gt(rowname_col = "model") |>
  tab_header(title = "Cars by Cylinder Count") |>
  fmt_number(columns = c(mpg, disp, hp), decimals = 1) |>
  opt_row_striping() |>
  cols_align(align = "center", columns = -model) |>
  cols_width(stub() ~ px(140), everything() ~ px(80)) |>
  tab_options(row_group.background.color = "#e8f4f8")

Important: when data is grouped with dplyr::group_by(), the groupname_col argument in gt() is ignored. Use ungroup() before gt() if you do not want row groups.

Saving Your Table

Export your table with gtsave():

table <- exibble |>
  gt(rowname_col = "row") |>
  fmt_number(columns = num, decimals = 2)

gtsave(table, "my_table.html")        # HTML output
gtsave(table, "my_table.png", zoom = 2)  # raster image
gtsave(table, "my_table.tex")          # LaTeX output
gtsave(table, "my_table.rtf")          # RTF output

The zoom argument for image output controls resolution. Higher values produce sharper output.

See Also