Table Template: Create your own {gt} table theme

Build a reusable {gt} table theme step by step: header, row striping, spanners and column labels, then wrap it all in one function.
gt
tables
R programming
beginners
Author

Gutama Girja Urago

Published

April 2, 2024

Modified

November 6, 2026

Most analysis ends with a table: summary statistics for a report, balance checks for an evaluation, a results table for a paper. R prints data frames in the console just fine, but those printouts are not something you would paste into a report. The gt package turns a data frame into a clean, formatted table that you can put in a Quarto document, a web page or a Word file.

In this post we build a table step by step, one layer of styling at a time, and then wrap all the steps into a single function. Once you have that function, every table in your report can have the same look with one line of code. The post was inspired by this video tutorial.

NoteWhat you will learn
  • the parts of a gt table and what each one is called
  • how tab_style() works: what to change and where to change it
  • how to add a title, striped rows, column group labels (spanners) and a source note
  • how to turn your styling into a reusable theme function

Setting up

You need two packages: gt for the table and dplyr to prepare the data. If you have never installed them, run the install.packages() line once (it is commented out here so it does not reinstall every time).

# install.packages(c("gt", "dplyr"))   # run once if needed
library(gt)
library(dplyr)

The parts of a gt table

gt names each part of a table, and you will use these names when you style it. From top to bottom:

  • Header: the title and an optional subtitle.
  • Column labels: the names above each column.
  • Spanners: labels that sit above a group of related columns, such as “Sepal” above “Length” and “Width”.
  • Body: the rows of data.
  • Footer: source notes and footnotes under the table.

Keep this list in mind. Almost every gt function either adds one of these parts or changes how one of them looks.

Prepare the data

We use R’s built-in iris dataset, which has flower measurements for three species of iris. To keep the table short, we take the first two rows of each species:

iris_sample <- iris |>
  mutate(Species = stringr::str_to_title(Species)) |>
  group_by(Species) |>
  slice_head(n = 2) |>
  ungroup() |>
  select(Species, everything())

iris_sample
# A tibble: 6 × 5
  Species    Sepal.Length Sepal.Width Petal.Length Petal.Width
  <chr>             <dbl>       <dbl>        <dbl>       <dbl>
1 Setosa              5.1         3.5          1.4         0.2
2 Setosa              4.9         3            1.4         0.2
3 Versicolor          7           3.2          4.7         1.4
4 Versicolor          6.4         3.2          4.5         1.5
5 Virginica           6.3         3.3          6           2.5
6 Virginica           5.8         2.7          5.1         1.9

What each step does:

  • mutate(Species = stringr::str_to_title(Species)) capitalizes the species names, so “setosa” becomes “Setosa”. The stringr:: prefix lets us use one function from the stringr package without loading the whole package.
  • group_by(Species) and slice_head(n = 2) keep the first two rows of each species. ungroup() removes the grouping again so it does not affect later steps.
  • select(Species, everything()) moves Species to the first column and keeps all the others after it.

Your first gt table

Turning a data frame into a gt table takes one function:

tbl <- gt(iris_sample)
tbl
Species Sepal.Length Sepal.Width Petal.Length Petal.Width
Setosa 5.1 3.5 1.4 0.2
Setosa 4.9 3.0 1.4 0.2
Versicolor 7.0 3.2 4.7 1.4
Versicolor 6.4 3.2 4.5 1.5
Virginica 6.3 3.3 6.0 2.5
Virginica 5.8 2.7 5.1 1.9

This already looks better than the console output. Each of the steps below takes tbl, adds one thing to it with the pipe |>, and saves the result back into tbl. That way you can run the post chunk by chunk and watch the table change.

Add a header

tab_header() adds a title and a subtitle above the table:

tbl <- tbl |>
  tab_header(
    title = "Main Title",
    subtitle = "Subtitle"
  )

tbl
Main Title
Subtitle
Species Sepal.Length Sepal.Width Petal.Length Petal.Width
Setosa 5.1 3.5 1.4 0.2
Setosa 4.9 3.0 1.4 0.2
Versicolor 7.0 3.2 4.7 1.4
Versicolor 6.4 3.2 4.5 1.5
Virginica 6.3 3.3 6.0 2.5
Virginica 5.8 2.7 5.1 1.9

In a real report, the title says what the table shows (“Iris measurements by species”) and the subtitle adds detail (“First two plants of each species, in cm”).

How styling works: tab_style()

Before we change any colors, it helps to see how gt thinks about style. tab_style() always takes two arguments:

So “make the title bold” becomes “style = bold text, location = the title”.

Style the title

Here we make the title black and bold. cells_title(groups = "title") points at the title only, so the subtitle keeps its normal style:

tbl <- tbl |>
  tab_style(
    style = cell_text(color = "black", weight = "bold"),
    locations = cells_title(groups = "title")
  )

tbl
Main Title
Subtitle
Species Sepal.Length Sepal.Width Petal.Length Petal.Width
Setosa 5.1 3.5 1.4 0.2
Setosa 4.9 3.0 1.4 0.2
Versicolor 7.0 3.2 4.7 1.4
Versicolor 6.4 3.2 4.5 1.5
Virginica 6.3 3.3 6.0 2.5
Virginica 5.8 2.7 5.1 1.9

To style the subtitle instead, you would use groups = "subtitle".

Stripe the rows

Shading every other row makes a wide table easier to read across. We give odd-numbered rows a light gray background:

tbl <- tbl |>
  tab_style(
    style = cell_fill(color = "grey90"),
    locations = cells_body(rows = seq(1, nrow(iris_sample), by = 2))
  )

tbl
Main Title
Subtitle
Species Sepal.Length Sepal.Width Petal.Length Petal.Width
Setosa 5.1 3.5 1.4 0.2
Setosa 4.9 3.0 1.4 0.2
Versicolor 7.0 3.2 4.7 1.4
Versicolor 6.4 3.2 4.5 1.5
Virginica 6.3 3.3 6.0 2.5
Virginica 5.8 2.7 5.1 1.9

seq(1, nrow(iris_sample), by = 2) produces the row numbers 1, 3, 5 and so on, up to the number of rows in the data. Using nrow() instead of typing the numbers means this still works if you change how many rows you keep.

Adjust table options

tab_options() controls settings that apply to the whole table. Here we stretch the table to the full width of the page, give the column labels a blue background, and left-align the title:

tbl <- tbl |>
  tab_options(
    table.width = pct(100),
    column_labels.background.color = "steelblue",
    heading.align = "left"
  )

tbl
Main Title
Subtitle
Species Sepal.Length Sepal.Width Petal.Length Petal.Width
Setosa 5.1 3.5 1.4 0.2
Setosa 4.9 3.0 1.4 0.2
Versicolor 7.0 3.2 4.7 1.4
Versicolor 6.4 3.2 4.5 1.5
Virginica 6.3 3.3 6.0 2.5
Virginica 5.8 2.7 5.1 1.9

pct(100) means 100 percent of the available width. gt has over a hundred options like these; the tab_options() reference page lists them all.

Group columns with spanners

The iris column names contain a pattern: Sepal.Length, Sepal.Width, Petal.Length and Petal.Width. The part before the dot is the flower part and the part after it is the measurement. tab_spanner_delim() splits each name at a delimiter (here the dot) and turns the first part into a spanner above the columns:

tbl <- tbl |>
  tab_spanner_delim(delim = ".")

tbl
Main Title
Subtitle
Species
Sepal
Petal
Length Width Length Width
Setosa 5.1 3.5 1.4 0.2
Setosa 4.9 3.0 1.4 0.2
Versicolor 7.0 3.2 4.7 1.4
Versicolor 6.4 3.2 4.5 1.5
Virginica 6.3 3.3 6.0 2.5
Virginica 5.8 2.7 5.1 1.9

The four columns are now grouped under “Sepal” and “Petal”, and each column label is just “Length” or “Width”. The table is easier to read and narrower.

Make the labels stand out

Finally, we make the column labels bold and the spanners bold and a little larger:

tbl <- tbl |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_column_labels()
  ) |>
  tab_style(
    style = cell_text(weight = "bold", size = "medium"),
    locations = cells_column_spanners()
  )

tbl
Main Title
Subtitle
Species
Sepal
Petal
Length Width Length Width
Setosa 5.1 3.5 1.4 0.2
Setosa 4.9 3.0 1.4 0.2
Versicolor 7.0 3.2 4.7 1.4
Versicolor 6.4 3.2 4.5 1.5
Virginica 6.3 3.3 6.0 2.5
Virginica 5.8 2.7 5.1 1.9

Two tab_style() calls are chained with |> here: one for the labels and one for the spanners.

Add a source note

A table in a report should say where its numbers come from. tab_source_note() adds a line to the footer:

tbl <- tbl |>
  tab_source_note(source_note = "Source: R's built-in iris dataset.")

tbl
Main Title
Subtitle
Species
Sepal
Petal
Length Width Length Width
Setosa 5.1 3.5 1.4 0.2
Setosa 4.9 3.0 1.4 0.2
Versicolor 7.0 3.2 4.7 1.4
Versicolor 6.4 3.2 4.5 1.5
Virginica 6.3 3.3 6.0 2.5
Virginica 5.8 2.7 5.1 1.9
Source: R's built-in iris dataset.

Make it reusable

We now have a table we like, but it took eight steps. Copying those steps for every table in a report would be slow, and the tables would drift apart as you tweak one and forget the others. A function solves this. (If functions are new to you, my post on R functions covers the basics.)

my_gt_theme <- function(gt_table, title, subtitle = NULL, delim = ".") {
  gt_table |>
    tab_header(title = title, subtitle = subtitle) |>
    tab_style(
      style = cell_text(color = "black", weight = "bold"),
      locations = cells_title(groups = "title")
    ) |>
    opt_row_striping() |>
    tab_options(
      row.striping.background_color = "grey90",
      table.width = pct(100),
      column_labels.background.color = "steelblue",
      heading.align = "left"
    ) |>
    tab_spanner_delim(delim = delim) |>
    tab_style(
      style = cell_text(weight = "bold"),
      locations = cells_column_labels()
    ) |>
    tab_style(
      style = cell_text(weight = "bold", size = "medium"),
      locations = cells_column_spanners()
    )
}

This is the same set of steps as above, with three changes worth pointing out:

  • The first argument is a gt table. That means the function fits into a pipe: gt(data) |> my_gt_theme(...), just like gt’s own functions.
  • Row striping uses opt_row_striping(). Earlier we picked the odd rows ourselves with nrow(). Inside a function that receives a gt table, it is simpler to switch on gt’s built-in striping and set its color in tab_options(). The result looks the same.
  • subtitle and delim have defaults. subtitle = NULL means “no subtitle” unless you give one, and delim = "." matches the iris column names. You only need to supply them when you want something different.

Now the whole table takes one line:

gt(iris_sample) |>
  my_gt_theme(
    title = "Easy and Beautiful Theme",
    subtitle = "With very minimal code"
  )
Easy and Beautiful Theme
With very minimal code
Species
Sepal
Petal
Length Width Length Width
Setosa 5.1 3.5 1.4 0.2
Setosa 4.9 3.0 1.4 0.2
Versicolor 7.0 3.2 4.7 1.4
Versicolor 6.4 3.2 4.5 1.5
Virginica 6.3 3.3 6.0 2.5
Virginica 5.8 2.7 5.1 1.9

And it works on any data frame. Here are the first six cars in mtcars. Their column names have no dots, so tab_spanner_delim() leaves them alone and no spanners appear:

head(mtcars) |>
  gt(rownames_to_stub = TRUE) |>
  my_gt_theme(title = "Motor Trend cars", subtitle = "First six rows")
Motor Trend cars
First six rows
mpg cyl disp hp drat wt qsec vs am gear carb
Mazda RX4 21.0 6 160 110 3.90 2.620 16.46 0 1 4 4
Mazda RX4 Wag 21.0 6 160 110 3.90 2.875 17.02 0 1 4 4
Datsun 710 22.8 4 108 93 3.85 2.320 18.61 1 1 4 1
Hornet 4 Drive 21.4 6 258 110 3.08 3.215 19.44 1 0 3 1
Hornet Sportabout 18.7 8 360 175 3.15 3.440 17.02 0 0 3 2
Valiant 18.1 6 225 105 2.76 3.460 20.22 1 0 3 1

rownames_to_stub = TRUE keeps the car names, which mtcars stores as row names, as the first column of the table.

Saving your table

To use the table outside R, save it with gtsave(). The file extension decides the format:

final_table <- gt(iris_sample) |>
  my_gt_theme(title = "Iris measurements", subtitle = "First two plants per species")

gtsave(final_table, "iris_table.html")   # web page
gtsave(final_table, "iris_table.docx")   # Word document

Saving as an image (.png) also works, but needs the webshot2 package and a Chrome browser on your computer.

Common mistakes

  • Forgetting to save the result. tbl |> tab_header(...) prints a table with a header, but tbl itself does not change unless you write tbl <- tbl |> ....
  • Wrong location helper. Styling column labels with cells_body() (or the other way round) runs without error but changes the wrong part of the table. Check which part you mean against the list at the top.
  • Spanners that do not appear. tab_spanner_delim() only works when the column names actually contain the delimiter. If yours use _ instead of ., pass delim = "_".
  • Hard-coded row numbers. rows = c(1, 3, 5) gives the wrong stripes, without any warning, as soon as the number of rows changes. Use seq() with nrow(), or opt_row_striping().

Recap

A gt table is built in layers: header, column labels, spanners, body and footer. tab_header(), tab_spanner_delim() and tab_source_note() add parts, tab_style() changes how a part looks by combining a style with a location, and tab_options() sets table-wide options. Once you are happy with a look, put the steps in a function that takes a gt table as its first argument. From then on, every table in your report gets the same style with one line, and changing the style means changing one function.

Back to top