Skip to content
R for the Rest of Us Logo

Ally Guide 17 min read

Designing Effective Tables

On this page

A table is a data visualization, too. Printing a data frame to the console dumps numbers on the page; a well-designed table guides the reader’s eye to what matters, which is the same job a good chart does. R makes polished tables easy, and I reach for the {gt} package, one of the most popular options and one that follows good design principles by default.

This page walks through the core principles of good table design and how to apply each one with {gt}. The principles, and much of the code, are adapted from Tom Mock’s excellent post “10+ Guidelines for Better Tables in R”.

Creating a Data Frame

You will begin by creating a data frame that you can use to make tables throughout this chapter. First, load the packages you need (the {tidyverse} for general data manipulation functions, {gapminder} for the data you’ll use, {gt} to make the tables, and {gtExtras} to do some table formatting):

library(tidyverse)
library(gapminder)
library(gt)
library(gtExtras)

As you saw earlier, the {gapminder} package provides country-level demographic statistics. To make a data frame for your table, you’ll use just a few countries (the first four, in alphabetical order: Afghanistan, Albania, Algeria, and Angola) and three years (1952, 1972, and 1992). The {gapminder} data has many years, but these will suffice to demonstrate table-making principles. The following code creates a data frame called gdp:

gdp <- gapminder |>
  filter(country %in% c("Afghanistan", "Albania", "Algeria", "Angola")) |>
  select(country, year, gdpPercap) |>
  mutate(country = as.character(country)) |>
  pivot_wider(
    id_cols = country,
    names_from = year,
    values_from = gdpPercap
  ) |>
  select(country, `1952`, `1972`, `1992`) |>
  rename(Country = country)

Here’s what gdp looks like:

gdp
# A tibble: 4 × 4
  Country      `1952`  `1972`  `1992`
  <chr>         <dbl>   <dbl>   <dbl>
1 Afghanistan  779.45  739.98  649.34
2 Albania     1601.1  3313.4  2497.4 
3 Algeria     2449.0  4182.7  5023.2 
4 Angola      3520.6  5473.3  2627.8 

Now that you have some data, you’ll use it to make a table.

Table Design Principles

Unsurprisingly, the principles of good table design are similar to those for data visualization more generally. This section covers six of the most important ones.

Minimize Clutter

You can minimize clutter in your tables by removing unnecessary elements. For example, one common source of table clutter is grid lines, as shown in Table 1.

table_gridlines <- gdp |>
  gt() |>
  tab_style(
    style = cell_borders(
      side = "all",
      color = "black",
      weight = px(1),
      style = "solid"
    ),
    locations = list(
      cells_body(
        everything()
      ),
      cells_column_labels(
        everything()
      )
    )
  ) |>
  opt_table_lines(extent = "none")
table_gridlines

Table 1: A table with grid lines everywhere can be distracting.

Having grid lines around every single cell in your table is unnecessary and distracts from the goal of communicating clearly. A table with minimal or even no grid lines (Table 2) is a much more effective communication tool.

table_default <- gdp |>
  gt()
table_default

Table 2: A table with only horizontal grid lines is more effective.

I mentioned that {gt} uses good table design principles by default, and this is a great example. The second table, with minimal grid lines, requires just two lines of code—piping the gdp data into the gt() function, which creates a table:

gdp |>
  gt()

To add grid lines to every part of the example, you’d have to add more code. Here, the code that follows the gt() function adds grid lines:

gdp |>
  gt() |>
  tab_style(
    style = cell_borders(
      side = "all",
      color = "black",
      weight = px(1),
      style = "solid"
    ),
    locations = list(
      cells_body(
        everything()
      ),
      cells_column_labels(
        everything()
      )
    )
  ) |>
  opt_table_lines(extent = "none")

Since I don’t recommend taking this approach, I won’t walk you through this code. However, if you wanted to remove additional grid lines, you could do so like this:

gdp |>
  gt() |>
  tab_style(
    style = cell_borders(color = "transparent"),
    locations = cells_body()
  )

The tab_style() function uses a two-step approach. First, it identifies the style to modify (in this case, the borders), then it specifies where to apply these modifications. Here, tab_style() tells R to modify the borders using the cell_borders() function, making the borders transparent, and to apply this transformation to the cells_body() location (versus, say, the cells_column_labels() for only the first row).

To see all options, check out the list of helper functions on the {gt} package documentation website at https://gt.rstudio.com/reference/index.html#helper-functions.

Running this code outputs a table with no grid lines at all in the body (Table 3).

table_clean <- gdp |>
  gt() |>
  tab_style(
    style = cell_borders(color = "transparent"),
    locations = cells_body()
  )
table_clean

Table 3: A clean-looking table with grid lines only on the header row and the bottom

Save this table as an object called table_no_gridlines so that you can add to it later.

table_no_gridlines <- gdp |>
  gt() |>
  tab_style(
    style = cell_borders(color = "transparent"),
    locations = cells_body()
  )

Differentiate the Header from the Body

While reducing clutter is an important goal, going too far can have negative consequences. A table with no grid lines at all can make it hard to differentiate between the header row and the table body. Consider Table 4, for example.

table_no_grid_lines <- gdp |>
  gt() |>
  opt_table_lines(extent = "none")
table_no_grid_lines

Table 4: An unclear table with all grid lines removed

By making the header row bold, you can make it stand out better:

table_no_gridlines |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )

Starting with the table_no_gridlines object, this code applies formatting with the tab_style() function in two steps. First, it specifies that it wants to alter the text style by using the cell_text() function to set the weight to bold. Second, it sets the location for this transformation to the header row using the cells_column_labels() function. Table 5 shows what the table looks like with its header row bolded.

table <- table_no_gridlines |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
table

Table 5: Making the header row more obvious using bold

Save this table as table_bold_header in order to add further formatting.

table_bold_header <- table_no_gridlines |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )

Align Appropriately

A third principle of high-quality table design is appropriate alignment. Specifically, numbers in tables should be right-aligned. Tom Mock explains that left-aligning or center-aligning numbers “impairs the ability to clearly compare numbers and decimal places. Right alignment lets you align decimal places and numbers for easy parsing.”

Let’s look at this principle in action. In Table 6, the 1952 column is left-aligned, the 1972 column is center-aligned, and the 1992 column is right-aligned.

table_mixed_alignment <- table_bold_header |>
  cols_align(
    align = "left",
    columns = 2
  ) |>
  cols_align(
    align = "center",
    columns = 3
  ) |>
  cols_align(
    align = "right",
    columns = 4
  )
table_mixed_alignment

Table 6: Comparing numerical data aligned to the left (1952), center (1972), and right (1992)

You can see how much easier it is to compare the values in the 1992 column than those in the other two columns. In both the 1952 and 1972 columns, it’s challenging to compare the values because the numbers in the same position (the tens place, for example) aren’t aligned vertically. In the 1992 column, however, the number in the tens place in Afghanistan (4) aligns with the number in the tens place in Albania (9) and all other countries, making it much easier to scan the table.

As with other tables, you actually have to override the defaults to get the {gt} package to misalign the columns, as demonstrated in the following code:

table_bold_header |>
  cols_align(
    align = "left",
    columns = 2
  ) |>
  cols_align(
    align = "center",
    columns = 3
  ) |>
  cols_align(
    align = "right",
    columns = 4
  )

By default, {gt} will right-align numeric values. Don’t change anything, and you’ll be golden.

Right alignment is best practice for numeric columns, but for text columns, use left alignment. As Jon Schwabish points out in his article “Ten Guidelines for Better Tables” in the Journal of Benefit-Cost Analysis, it’s much easier to read longer text cells when they are left-aligned. To see the benefit of left-aligning text, add a country with a long name to your table. I’ve added Bosnia and Herzegovina and saved this as a data frame called gdp_with_bosnia. You’ll see that I’m using nearly the same code I used previously to create the gdp data frame:

gdp_with_bosnia <- gapminder |>
  filter(country %in% c("Afghanistan", "Albania", "Algeria", "Angola", "Bosnia and Herzegovina")) |>
  select(country, year, gdpPercap) |>
  mutate(country = as.character(country)) |>
  pivot_wider(
    id_cols = country,
    names_from = year,
    values_from = gdpPercap
  ) |>
  select(country, `1952`, `1972`, `1992`) |>
  rename(Country = country)

Here’s what the gdp_with_bosnia data frame looks like:

gdp_with_bosnia
# A tibble: 5 × 4
  Country                 `1952`  `1972`  `1992`
  <chr>                    <dbl>   <dbl>   <dbl>
1 Afghanistan             779.45  739.98  649.34
2 Albania                1601.1  3313.4  2497.4 
3 Algeria                2449.0  4182.7  5023.2 
4 Angola                 3520.6  5473.3  2627.8 
5 Bosnia and Herzegovina  973.53 2860.2  2546.8 

Now take the gdp_with_bosnia data frame and create a table with the Country column center-aligned. In the table in Table 7, it’s hard to scan the country names, and that center-aligned column just looks a bit weird.

table_center_aligned <- gdp_with_bosnia |>
  gt() |>
  tab_style(
    style = cell_borders(color = "transparent"),
    locations = cells_body()
  ) |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_column_labels()
  ) |>
  cols_align(
    columns = "Country",
    align = "center"
  )
table_center_aligned

Table 7: Center-aligned text can be hard to read, especially when it includes longer values.

This is another example where you have to change the {gt} defaults to mess things up. In addition to right-aligning numeric columns by default, {gt} left-aligns character columns. As long as you don’t touch anything, you’ll get the alignment you’re looking for.

If you ever do want to override the default alignments, you can use the cols_align() function. For example, here’s how to make the table with center-aligned country names:

gdp_with_bosnia |>
  gt() |>
  tab_style(
    style = cell_borders(color = "transparent"),
    locations = cells_body()
  ) |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_column_labels()
  ) |>
  cols_align(
    columns = "Country",
    align = "center"
  )

The columns argument tells {gt} which columns to align, and the align argument selects the alignment (left, right, or center).

Use the Correct Level of Precision

In all of the tables you’ve made so far, you’ve used the data exactly as it came to you. The data in the numeric columns, for example, extends to four decimal places—almost certainly too many. Having more decimal places makes a table harder to read, so you should always strike a balance between what Jon Schwabish describes as “necessary precision and a clean, spare table.”

Here’s a good rule of thumb: if adding more decimal places would change some action, keep them; otherwise, take them out. In my experience, people tend to leave too many decimal places in, putting too much importance on a very high degree of accuracy (and, in the process, reducing the legibility of their tables).

In the GDP table, you can use the fmt_currency() function to format the numeric values:

table_bold_header |>
  fmt_currency(
    columns = c(`1952`, `1972`, `1992`),
    decimals = 0
  )

The {gt} package has a whole series of functions for formatting values in tables, all of which start with fmt_. This code applies fmt_currency() to the 1952, 1972, and 1992 columns, then uses the decimals argument to tell fmt_currency() to format the values with zero decimal places. After all, the difference between a GDP of $779.4453 and $779 is unlikely to lead to different decisions.

This produces values formatted as dollars. The fmt_currency() function automatically adds a thousands-place comma to make the values even easier to read (Table 8).

table_dollar_signs <- table_bold_header |>
  fmt_currency(
    columns = c(`1952`, `1972`, `1992`),
    decimals = 0
  )
table_dollar_signs

Table 8: Rounding dollar amounts to whole numbers and adding dollar signs can simplify data.

Save your table for reuse as table_whole_numbers.

table_whole_numbers <- table_bold_header |>
  fmt_currency(
    columns = c(`1952`, `1972`, `1992`),
    decimals = 0
  )

Use Color Intentionally

So far, our table hasn’t used any color. We’ll add some now to highlight outlier values. Especially for readers who want to scan your table, highlighting outliers with color can help significantly. Let’s make the highest value in the year 1952 a different color. To do this, we again use the tab_style() function:

table_whole_numbers |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1952`,
      rows = `1952` == max(`1952`)
    )
  )

This function uses cell_text() to change the color of the text to orange and make it bold. Within the cells_body() function, the locations() function specifies the columns and rows to which the changes will apply. The columns argument is simply set to the year whose values are being changed, but setting the rows requires a more complicated formula. The code rows =1952== max(1952) applies the text transformation to rows whose value is equal to the maximum value in that year.

Repeating this code for the 1972 and 1992 columns generates the result shown in Table 9 (which represents the orange values in grayscale for print purposes).

table_highlight_values <- table_whole_numbers |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1952`,
      rows = `1952` == max(`1952`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1972`,
      rows = `1972` == max(`1972`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1992`,
      rows = `1992` == max(`1992`)
    )
  )
table_highlight_values

Table 9: Using color to highlight important values, such as the largest number in each year

The {gt} package makes it straightforward to add color to highlight outlier values.

table_with_color <- table_whole_numbers |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1952`,
      rows = `1952` == max(`1952`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1972`,
      rows = `1972` == max(`1972`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1992`,
      rows = `1992` == max(`1992`)
    )
  )

Add a Data Visualization Where Appropriate

Adding color to highlight outliers is one way to help guide the reader’s attention. Another way is to incorporate graphs into tables. Tom Mock developed an add-on package for {gt} called {gtExtras} that makes it possible to do just this. For example, say you want to show how the GDP of each country changes over time. To do that, you can add a new column that visualizes this trend using a sparkline (essentially, a simple line chart):

gdp_with_trend <- gdp |>
  group_by(Country) |>
  mutate(Trend = list(c(`1952`, `1972`, `1992`))) |>
  ungroup()

The gt_plt_sparkline() function requires you to provide the values needed to make the sparkline in a single column. To accomplish this, the code creates a variable called Trend, using group_by() and mutate(), to hold a list of the values for each country. For Afghanistan, for example, Trend would contain 779.4453145, 739.9811058, and 649.3413952. Save this data as an object called gdp_with_trend.

Now you create your table as before but add the gt_plt_sparkline() function to the end of the code. Within this function, specify which column to use to create the sparkline (Trend) as follows:

gdp_with_trend |>
  gt() |>
  tab_style(
    style = cell_borders(color = "transparent"),
    locations = cells_body()
  ) |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_column_labels()
  ) |>
  fmt_currency(
    columns = c(`1952`, `1972`, `1992`),
    decimals = 0
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1952`,
      rows = `1952` == max(`1952`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1972`,
      rows = `1972` == max(`1972`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1992`,
      rows = `1992` == max(`1992`)
    )
  ) |>
  gt_plt_sparkline(
    column = Trend,
    label = FALSE,
    palette = c("black", "transparent", "transparent", "transparent", "transparent")
  )

Setting label = FALSE removes text labels that gt_plt_sparkline() adds by default, then adds a palette argument to make the sparkline black and all other elements of it transparent. (By default, the function will make different parts of the sparkline different colors.) The stripped-down sparkline in Table 10 allows the reader to see the trend for each country at a glance.

table_with_sparklines <- gdp_with_trend |>
  gt() |>
  tab_style(
    style = cell_borders(color = "transparent"),
    locations = cells_body()
  ) |>
  tab_style(
    style = cell_text(weight = "bold"),
    locations = cells_column_labels()
  ) |>
  fmt_currency(
    columns = c(`1952`, `1972`, `1992`),
    decimals = 0
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1952`,
      rows = `1952` == max(`1952`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1972`,
      rows = `1972` == max(`1972`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1992`,
      rows = `1992` == max(`1992`)
    )
  ) |>
  gt_plt_sparkline(
    column = Trend,
    label = FALSE,
    palette = c("black", "transparent", "transparent", "transparent", "transparent")
  )
gt_image(table_with_sparklines, "sparklines")

Table 10: A table with sparklines can show changes in data over time.

The {gtExtras} package can do much more than merely create sparklines. Its set of theme functions allows you to make your tables look like those published by FiveThirtyEight, the New York Times, the Guardian, and other news outlets.

As an example, try removing the formatting you’ve applied so far and instead use the gt_theme_538() function to style the table. Then take a look at tables on the FiveThirtyEight website. You should see similarities to the one in Table 11.

table_fivethirtyeight <- gdp |>
  group_by(Country) |>
  mutate(Trend = list(c(`1952`, `1972`, `1992`))) |>
  ungroup() |>
  gt() |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1952`,
      rows = `1952` == max(`1952`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1972`,
      rows = `1972` == max(`1972`)
    )
  ) |>
  tab_style(
    style = cell_text(
      color = "orange",
      weight = "bold"
    ),
    locations = cells_body(
      columns = `1992`,
      rows = `1992` == max(`1992`)
    )
  ) |>
  fmt_currency(
    columns = c(`1952`, `1972`, `1992`),
    decimals = 0
  ) |>
  gt_plt_sparkline(
    column = Trend,
    label = FALSE,
    palette = c("black", "transparent", "transparent", "transparent", "transparent")
  ) |>
  gt_theme_538()
gt_image(table_fivethirtyeight, "fivethirtyeight")

Table 11: A table redone in the FiveThirtyEight style

Add-on packages like {gtExtras} are common in the table-making landscape. If you’re working with the {reactable} package to make interactive tables, for example, you can also use the {reactablefmtr} to add interactive sparklines, themes, and more. You’ll learn more about making interactive tables in websites.

Summary

Many of the tweaks you made to your table in this chapter are quite subtle. Changes like removing excess grid lines, bolding header text, right-aligning numeric values, and adjusting the level of precision can often go unnoticed, but if you skip them, your table will be far less effective. The final product isn’t flashy, but it does communicate clearly.

You used the {gt} package to make your high-quality table, and as you’ve repeatedly seen, this package has good defaults built in. Often, you don’t need to change much in your code to make effective tables. But no matter which package you use, it’s essential to treat tables as worthy of just as much thought as other kinds of data visualization.

With Quarto, you can integrate your tables directly into a report, so they render as part of the final document whenever you export it. What’s better than using just a few lines of code to make publication-ready tables?

Additional Resources

Reading time 17 min
Updated August 28, 2026
Topics Data Visualization

On this page