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.
- 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).
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”. Thestringr::prefix lets us use one function from the stringr package without loading the whole package. -
group_by(Species)andslice_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())movesSpeciesto 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:
-
style: what to change. Helper functions describe it, for examplecell_text()for the font,cell_fill()for the background color andcell_borders()for the lines. -
locations: where to change it. Another set of helpers points at a part of the table:cells_title(),cells_column_labels(),cells_column_spanners(),cells_body()and so on, matching the parts listed earlier.
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 withnrow(). Inside a function that receives a gt table, it is simpler to switch on gt’s built-in striping and set its color intab_options(). The result looks the same. -
subtitleanddelimhave defaults.subtitle = NULLmeans “no subtitle” unless you give one, anddelim = "."matches theiriscolumn 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:
| 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:
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, buttblitself does not change unless you writetbl <- 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., passdelim = "_". -
Hard-coded row numbers.
rows = c(1, 3, 5)gives the wrong stripes, without any warning, as soon as the number of rows changes. Useseq()withnrow(), oropt_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.